pitroom 0.25.0 → 0.26.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.25.0",
4
+ "version": "0.26.0",
5
5
  "author": {
6
6
  "name": "TahaAtas"
7
7
  },
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pitroom",
3
- "version": "0.25.0",
3
+ "version": "0.26.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,28 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.26.0
4
+
5
+ ### Workers
6
+ - **A limit part way no longer fails the run.** A worker that hit a plan's usage limit or a used-up quota after it had started (a Codex or Claude session limit, an Antigravity quota) failed the run, fallbacks or not: about a third of the runs that failed on a limit had a fallback that was never tried. Now the next worker takes the task over. An edit carries on in the same tree, told which files were already changed and to finish rather than start over; a read starts over. The run's cost is what both used, and the run's notes say who handed over.
7
+ - **Audits have fallbacks.** An audit ran on one auditor only, and lost the audit when that one was rate-limited. It now falls back to the other auditors (the audit and cheap tiers, the fallbacks, the worker), never to the worker it audits, at any effort level.
8
+ - **Cooldowns last as long as the limit.** "try again at 8:56 PM" and "resets 7:40pm (Europe/Istanbul)" are read as times of day (in the zone named, else the machine's), so an exhausted model is not tried again every 20 minutes until then. A used-up allowance with no time given (OpenCode's free usage limit) waits an hour instead of 20 minutes.
9
+ - **"usage limit" and "session limit" count as limits on every worker** (they did on Claude Code and Codex only).
10
+ - **`rankWorkers` ranks by the same kind of work.** A worker's record mixed its answers and its changes, though they part ways: in one machine's month of runs, `muse-spark` finished all 61 of its questions and 15 of its 19 changes, `space-bunny` 25 of 26 and 7 of 9. The chain is now ordered by the record of the run's kind (a question, a change or a review), and a change's record counts the reviews of it as its checks (spec met and nothing to fix), as an answer's counts its audits. The note says which record it used (`ranked by record on changes`).
11
+
12
+ ### Windows
13
+ - **Git from Scoop gets the git shim.** Scoop's `shims\git.exe` is a launcher far from Git for Windows' `sh.exe`, so the shim (layer 1) was left out and only git's own config guarded the worker. Pitroom now follows the launcher's `git.shim` to the real git.
14
+ - **`HEAD^` from PowerShell is `HEAD^` again.** Windows starts a batch file such as `git.cmd` through a `cmd /c` line, which removes a `^` and fills in a `%name%` that names a variable before the batch file runs: from PowerShell, `git show HEAD^` showed `HEAD`, and `--format=%cd%n` got the current folder, where git.exe would have got them as written. When Windows starts `git.cmd` itself, the shim now takes the arguments from that raw line; a `cmd /d /s /c` line, which may hold more than one command, keeps `%*`. An unquoted `& | < >` is still acted on by that `cmd /c` line, as for any batch file.
15
+
16
+ ### Savings
17
+ - **No finished run is missing from the savings ledger.** A run whose process was killed between its final record and its ledger line, and a worker that crashed, were never counted (about 1.5% of finished runs). `pitroom savings`, `pitroom history` and the dashboard now record the finished runs the ledger missed, each once.
18
+ - **A done run counts even when its CLI reported no steps** (some OpenCode reviews): it answered, so it is in "Finished". Runs that failed before reaching the model stay out, as before.
19
+ - **An estimated cost says so.** `pitroom history` has a COST column, and `pitroom savings --models` its cost: a cost the worker's CLI did not report (Codex, Gemini) and that was estimated from a price (your `workerPrices`, else the catalog's) reads `~$0.40`, a reported one `$0.40`, a free one `free`. `history --json` rows carry `cost` and `costEstimated`.
20
+ - **Prices from your agent**: `pitroom_info` has a `prices` topic: the primary's price the savings are measured against and the price each worker in use gets, or the `worker` named. `pitroom prices WORKER…` does the same on the command line, and now also prints the primary's price.
21
+
22
+ ### Docs and tests
23
+ - **The review benchmark measures false alarms.** 16 new packages make a change that cannot alter what the code does (a comparison mirrored, `i < n` to `n > i`, only where it is the same expression; Pitroom's own also in its committed `dist/`), looking like the planted bugs. `big-pickle` raised no false alarm in 13 reviews; `nemotron-3-ultra` raised 4 in 14 and `mimo-v2.6-flash` 2 in 13, each filing a change it called harmless as Important for style. The README's table has the new column. `test/bench-equiv.test.mjs` checks the rule: what it rejects, and that each mirrored expression gives the same value for thousands of inputs.
24
+ - The benchmark's scorer read a review with no Critical heading (it went straight to Important) as having no findings: a few found bugs were counted as misses (`nemotron-3-ultra` 97% to 100%, `muse-spark` 93% to 97%, `longcat` 83% to 86%).
25
+
3
26
  ## 0.25.0
4
27
 
5
28
  ### Dashboard
package/README.md CHANGED
@@ -107,7 +107,7 @@ In isolate mode nothing reaches your tree until you apply it.
107
107
  <summary>12 more features</summary>
108
108
 
109
109
  - **Receipts, not vibes.** Tokens the worker burned, tokens returned to your agent, compression ratio, and an estimate of what your primary model would have charged. `pitroom savings --card card.svg` makes a shareable card.
110
- - **Self-healing worker chain.** Free models get rate-limited or retired. List fallback workers once (`fallback`) and a run that hits "model not found", 429 or quota errors moves to the next model automatically. Still no model hardcoded: the chain is yours.
110
+ - **Self-healing worker chain.** Free models get rate-limited or retired. List fallback workers once (`fallback`) and a run that hits "model not found", 429 or quota errors moves to the next model automatically, also when a plan's usage limit stops a worker part way (the next one finishes the task). Still no model hardcoded: the chain is yours.
111
111
  - **A crew, not just one worker.** `pitroom crew` starts several workers at once, `pitroom watch --json` streams one line per change so your agent follows them live, `pitroom wait -g` collects every answer, `pitroom apply -g` lands isolated patches in order. Up to 20 workers run at once by default (`maxParallel`, 30 at most), the rest queue, and the fallback chain absorbs free-tier rate limits; parallel writers are refused.
112
112
  - **Faster, not only cheaper.** Workers run in the background and side by side (up to 20 at once) while your agent keeps working, so slow reading and review stop blocking it. In the [benchmark](#benchmarks), 470 s of worker time finished in 156 s of wall clock, about 3× faster than one question after another, and your agent's own context stayed small.
113
113
  - **Free first, dearer only when needed.** Runs and plan tasks start on the `cheap` tier, OpenCode's free model; `standard` (Codex) and `capable` (Claude Code) are for tasks that need them, and an optional `review` tier names who reviews. Gemini CLI with a free Google AI Studio key is one more cheap worker (small daily quota per model). `pitroom models` shows what each worker offers, the effort levels (`low` … `xhigh`) each model accepts, what you say it costs (`costs`) and what it used; `--effort` sets the level per run. Details: [Models, costs and effort](#models-costs-and-effort).
@@ -244,21 +244,23 @@ Only workers that ran are in the table. Models that could not answer at all (a p
244
244
  </details>
245
245
 
246
246
  <details>
247
- <summary>Does a review find a planted bug?</summary>
247
+ <summary>Does a review find a planted bug, and leave a harmless change alone?</summary>
248
248
 
249
249
  **Does a review find a planted bug?** A bug was changed into real code (an inverted check, a swapped `&&`/`||`, a flipped `return`) in React, Django, Kubernetes and Pitroom itself, committed as a bare "tidy", and `pitroom review` was asked to review the commit. 29 packages (14 with the bug alone, 15 with the bug and three comment-only edits around it). A review counts as a find when a Critical or Important finding names the changed file and either cites a line within 5 or names the changed identifier or its function; "exact line" is the strict version (within 3).
250
250
 
251
- | Worker and model | Bug found | Exact line | Reviews | Mean time | Slowest |
252
- |---|---|---|---|---|---|
253
- | OpenCode `space-bunny-free` | 100% | 100% | 29 of 29 | 2 min 19 s | 5 min 46 s |
254
- | OpenCode `nemotron-3-ultra-free` | 97% | 79% | 29 of 29 | 1 min 45 s | 3 min 29 s |
255
- | OpenCode `longcat-2.5-preview-free` | 83% | 83% | 29 of 29 | 2 min 53 s | 8 min (1 timed out) |
256
- | Codex `gpt-6-sol` | 100% | 100% | 9 of 29 | 38 s | 52 s |
257
- | OpenCode `muse-spark-1.3-contributor-free` | 100% | 57% | 14 of 29 | 48 s | 2 min 0 s |
258
- | OpenCode `big-pickle` | 100% | 92% | 13 of 29 | 2 min 28 s | 5 min 27 s |
259
- | OpenCode `mimo-v2.6-flash-free` | 100% | 100% | 10 of 29 | 4 min 6 s | 7 min 40 s |
251
+ **Does it leave a harmless change alone?** 16 more packages look the same (a comparison changed on one or two lines, the same "tidy" message) but cannot change what the code does: the two sides of a comparison are swapped and the operator mirrored (`i < n` becomes `n > i`), only where that is the same expression. A Critical or Important finding on one of them is a false alarm.
252
+
253
+ | Worker and model | Bug found | Exact line | False alarm | Reviews | Mean time | Slowest |
254
+ |---|---|---|---|---|---|---|
255
+ | OpenCode `nemotron-3-ultra-free` | 100% | 83% | 29% (4 of 14) | 29 + 14 | 1 min 33 s | 3 min 29 s |
256
+ | OpenCode `space-bunny-free` | 100% | 100% | - | 29 | 2 min 19 s | 5 min 46 s |
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 |
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 |
261
+ | OpenCode `longcat-2.5-preview-free` | 86% | 86% | - | 29 (1 timed out) | 2 min 53 s | 8 min 0 s |
260
262
 
261
- Almost every model found almost every bug, so this test separates them on speed and precision, not on whether they can review: `muse-spark` described the right bug but often quoted a wrong line number (57% exact), and `gpt-6-sol` and `muse-spark` were the fastest by a wide margin. Only three models finished all 29: the other four are scored on the reviews that ran before a provider limit (the free tier's usage limit, and the Codex plan's, which `gpt-6-sol` reached), and the rest are left out, not counted as misses, so their rows rest on 9 to 14 reviews. Every change is only 2 to 5 lines, which is easy; there is no false-alarm rate (the comment-only "clean" packages turned out to contain comments that were really wrong, so they cannot show one); and React, Django and Kubernetes may be in a model's training data. The harness and every raw review are in [`benchmarks/review-bugs`](benchmarks/review-bugs).
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).
262
264
 
263
265
  </details>
264
266
 
@@ -276,7 +278,7 @@ To repeat it on your own repository: `pitroom crew -d <repo> -g bench "<question
276
278
 
277
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:
278
280
 
279
- - **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. `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 (`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.
280
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.
281
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`.
282
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`.
@@ -353,7 +355,7 @@ Each tool runs the matching `pitroom` command, so every rule of the CLI applies
353
355
  - **Progress.** While a call waits for a run, a client that sent a progress token gets a `notifications/progress` every few seconds ("running · read · 12s · 3 steps, 2 tool calls · last: …"), and a client that resets its timeout when progress comes in (the protocol allows it; not every client does) does not give up on a long run. **Cancelling** a `pitroom_run`, `pitroom_review` or `pitroom_audit` call stops the run it started (a cancelled `pitroom_wait` only stops waiting).
354
356
  - **Resources.** The latest runs are listed as `pitroom://run/<id>` (the report) and, for runs that changed files, `pitroom://run/<id>/patch` (the exact diff), so a client can attach one to a conversation. A client can **subscribe** to a run (`resources/subscribe`) and is told when it changes state (`notifications/resources/updated`), and every client is told when the newest run changes (`notifications/resources/list_changed`), so it need not poll; over HTTP these arrive on the session's event stream (a GET).
355
357
  - **Prompts.** `research`, `implement`, `review` and `crew` say how to use Pitroom for that job (in Claude Code they appear as `/mcp__pitroom__research`, …), and every skill is a prompt of the same name (`using-pitroom`, `pitroom-research`, …), so a client without skills gets the same guidance. The server's instructions tell the agent to use the skills alongside the tools.
356
- - **More than runs:** `pitroom_run` takes `tasks` (independent tasks in parallel, one worker each) and `continue` (a follow-up in the same worker session); `pitroom_info` reports without changing anything, by `topic`: `runs`, `history` (find an earlier answer before asking again), `stats` and `models` (pick a worker), `savings`, `cooldown`, `config`, `doctor`; `pitroom_stop` with `cooldowns` tries rate-limited models again.
358
+ - **More than runs:** `pitroom_run` takes `tasks` (independent tasks in parallel, one worker each) and `continue` (a follow-up in the same worker session); `pitroom_info` reports without changing anything, by `topic`: `runs`, `history` (find an earlier answer before asking again), `stats` and `models` (pick a worker), `savings`, `prices` (USD per 1M tokens of the primary and of each worker in use, or of the `worker` named), `cooldown`, `config`, `doctor`; `pitroom_stop` with `cooldowns` tries rate-limited models again.
357
359
  - **Over HTTP.** `pitroom mcp --http [--port N]` (default 7117) serves the same tools at `http://127.0.0.1:7117/mcp` for clients that connect to a URL, for example `claude mcp add --transport http pitroom http://127.0.0.1:7117/mcp --header "Authorization: Bearer $(cat ~/.local/state/pitroom/mcp-token)"` (the command is printed when it starts). It is a local service: it listens on 127.0.0.1 only, needs the bearer token (made on first start, kept at `<state dir>/mcp-token` with owner-only permissions, or set with `PITROOM_MCP_TOKEN`), and refuses a Host or Origin that is not local, so a web page cannot use it. Anyone who has the token can run workers as you, so keep it private. It works in the directory it was started in (printed when it starts; `-d DIR` for another). Each client has its own session; a call that asks for progress is answered as an event stream. Ending a session or stopping the server ends the waiting, not the runs: collect them with `pitroom_wait` or the CLI. `pitroom install --mcp` registers the stdio command, which needs no token and no running process.
358
360
 
359
361
  </details>
@@ -712,7 +714,7 @@ claude haiku low/medium/high/xhigh/max ? - -
712
714
 
713
715
  Pitroom knows no vendor prices of its own and, unless you turn on `priceFeed` (see the config notes), fetches none, so a cost is what you enter: `"costs": {"codex:gpt-6-sol": 1, "codex:gpt-6.1-sol": 2}` in the config, in any unit (they are only compared; dollar prices for the receipts of Codex and Gemini go in `workerPrices`, see the config notes). `pitroom doctor` then prints the cost of the models in use and says when you priced a cheaper one of the same worker. A target that names only an effort (`codex:#low`) uses the model from `models`; a Codex or Claude Code worker with no pinned model gets a warning, because it would run the vendor's own default, which can change and cost more. `--effort LEVEL` sets the level for one run; your agent picks model and level from `pitroom models` (cheapest that fits: `low` for lookups, `medium` for ordinary changes, `high` for reviews).
714
716
 
715
- Fallbacks cross backends (e.g. `"fallback": ["codex:#low", "opencode"]`): a worker that is rate-limited, logged out or missing its model hands the task to the next. Follow-ups (`--continue`) always stay on the worker that owns the session. Writing an adapter: [docs/backends.md](docs/backends.md). A model that says "rate limited" (a daily quota used up, an overloaded provider) is remembered for the time its message gives, or a guess: the next runs skip it while a fallback is left, so they do not each wait for it to fail. `pitroom cooldown` lists what is skipped and until when, `--clear` tries it again, and `doctor` shows it too.
717
+ Fallbacks cross backends (e.g. `"fallback": ["codex:#low", "opencode"]`): a worker that is rate-limited, logged out or missing its model hands the task to the next. A limit can come part way, after the worker started (a Codex or Claude plan's usage limit, an Antigravity quota): the next worker then takes the task over instead of the run failing. An edit (`-i`, `-w`) carries on in the same tree, and the next worker is told which files the one before it already changed and to finish, not start over; a read starts over. What both used is the run's cost. An audit falls back to the other auditors (the audit and cheap tiers, the fallbacks, the worker), never to the worker it audits. Follow-ups (`--continue`) always stay on the worker that owns the session. Writing an adapter: [docs/backends.md](docs/backends.md). A model that says "rate limited" (a daily quota used up, an overloaded provider) is remembered for the time its message gives ("retry in 4h", "try again at 8:56 PM", "resets 7:40pm (Europe/Istanbul)"), or a guess (an overload 20 minutes, a used-up allowance an hour, a daily quota four hours): the next runs skip it while a fallback is left, so they do not each wait for it to fail. `pitroom cooldown` lists what is skipped and until when, `--clear` tries it again, and `doctor` shows it too.
716
718
 
717
719
  </details>
718
720
 
@@ -763,12 +765,12 @@ Or put defaults in `~/.config/pitroom/config.json` (flags and env still win); `p
763
765
  - `audit`: the chance (0 to 1, default 0 = off) that a finished read run is re-checked in the background, see [Audits](#audits). The `audit` tier (else `cheap`) names who does it.
764
766
  - `costs`: your relative cost per `worker:model`, only compared with each other; `maxParallel`: workers at once (default 20, at most 30).
765
767
  - `workerPrices`: USD per 1M tokens of a worker model, for the CLIs that report no cost (Codex, Gemini): `"workerPrices": {"codex:gpt-6.1-sol": "1.25,10,0.125"}` is "in,out[,cachedIn]", keyed `backend:model` (the model that ran, else the one you asked for) or `backend` alone. Without one the receipt says `worker cost n/a` and the run counts as free in the savings, which overstates them (`pitroom doctor` warns); with one the cost is estimated from the tokens, shown as `~$0.016 (estimated from your workerPrices)`, kept in the ledger, history and dashboard, and taken off the savings. Pitroom knows no vendor prices and does not fetch them, so this is your number.
766
- - `priceFeed`: keep a price catalog up to date (default `false`; `PITROOM_PRICE_FEED=1`). The catalog is the public [models.dev](https://models.dev) list OpenCode also reads (USD per 1M tokens of about 8,000 models): at most once a day (`priceFeedHours`, default 24), in a background process after a run, never while a run waits, Pitroom sends a plain `GET` for that one file (`priceFeedUrl` / `PITROOM_PRICE_FEED_URL` to use a mirror, `priceFeedHours` / `PITROOM_PRICE_FEED_HOURS` for the interval) and keeps a trimmed copy (about 400 KB) in its state directory; a failed refresh keeps the old copy and is not retried for an hour. It is only a fallback: your `workerPrices` come first, and the receipt says which was used (`estimated from models.dev prices`). With it, `"primary": "<model id>"` can name any model of the catalog for the savings comparison (`"primary": "claude-opus-5-5"`), where without it only the four presets exist. `pitroom prices` shows the feed, the catalog and the price each worker in use gets; `pitroom prices --refresh` fetches now. What it sends: [PRIVACY.md](PRIVACY.md). The listed prices are API list prices: a worker on a subscription (Codex with a ChatGPT account) does not pay them, so for such a worker the estimate is what the same tokens would cost on the API.
768
+ - `priceFeed`: keep a price catalog up to date (default `false`; `PITROOM_PRICE_FEED=1`). The catalog is the public [models.dev](https://models.dev) list OpenCode also reads (USD per 1M tokens of about 8,000 models): at most once a day (`priceFeedHours`, default 24), in a background process after a run, never while a run waits, Pitroom sends a plain `GET` for that one file (`priceFeedUrl` / `PITROOM_PRICE_FEED_URL` to use a mirror, `priceFeedHours` / `PITROOM_PRICE_FEED_HOURS` for the interval) and keeps a trimmed copy (about 400 KB) in its state directory; a failed refresh keeps the old copy and is not retried for an hour. It is only a fallback: your `workerPrices` come first, and the receipt says which was used (`estimated from models.dev prices`). With it, `"primary": "<model id>"` can name any model of the catalog for the savings comparison (`"primary": "claude-opus-5-5"`), where without it only the four presets exist. `pitroom prices` shows the feed, the catalog, the primary's price and the price each worker in use gets (`pitroom prices codex:gpt-6.1-sol` the price of the one named); `pitroom prices --refresh` fetches now. What it sends: [PRIVACY.md](PRIVACY.md). The listed prices are API list prices: a worker on a subscription (Codex with a ChatGPT account) does not pay them, so for such a worker the estimate is what the same tokens would cost on the API.
767
769
  - `notify`: a desktop notification when a run that went to the background (`--bg`, a crew, an MCP call) has ended, so a long job does not have to be watched (default `false`; `PITROOM_NOTIFY=1`). Runs that went well but took less than `notifyAfter` seconds (default 15; `PITROOM_NOTIFY_AFTER`) and audits are not announced (a failure always is), and a crew says so once, when its last run ends, judged as a whole. A worker process that is killed outright never gets to say so. The built-in notifier uses `osascript` on macOS and `notify-send` on Linux (none for Windows yet); `notifyCommand` runs a command of your own instead, in a shell, with the text in `PITROOM_NOTIFY_TITLE` and `PITROOM_NOTIFY_BODY` (and `PITROOM_NOTIFY_RUN`, `PITROOM_NOTIFY_STATE`): for example `"notifyCommand": "curl -s -d \"$PITROOM_NOTIFY_BODY\" ntfy.sh/my-topic"`.
768
770
  - `mcpDash`: whether a run that an MCP client starts, and that is still going after a few seconds, also starts the dashboard and returns its address (default `true`; `PITROOM_MCP_DASH=0` or `"mcpDash": false` turns it off), see [the MCP server](#1-install-pick-your-agent).
769
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).
770
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`).
771
- - `rankWorkers`: order the worker and its fallbacks by their record of the last 30 days: the share of runs that ended well (rate limits left out), times the share of audited answers that were confirmed (with 3 or more). Only workers with 5 runs or more 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`).
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`).
772
774
  - `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).
773
775
 
774
776
  </details>
package/dist/lib.mjs CHANGED
@@ -759,7 +759,7 @@ function invocation(req) {
759
759
  return { command, args: [...prefix, ...args], env };
760
760
  }
761
761
  function classify(message) {
762
- if (/RESOURCE_EXHAUSTED|\b429\b|quota|rate.?limit|too many requests|overloaded|\b503\b|capacity/i.test(message)) return "rate-limited";
762
+ if (/usage limit|session limit|RESOURCE_EXHAUSTED|\b429\b|quota|rate.?limit|too many requests|overloaded|\b503\b|capacity/i.test(message)) return "rate-limited";
763
763
  if (/not (signed|logged) in|\bsign.?in\b|\blog ?in\b|authenticat|PERMISSION_DENIED|oauth|credential|keyring|keychain|\b401\b|unauthori[sz]ed/i.test(message)) {
764
764
  return "auth";
765
765
  }
@@ -997,7 +997,7 @@ function classify2(message) {
997
997
  if (/authentication_failed|failed to authenticate|oauth|not logged in|\/login|invalid api key|\b40[13]\b|unauthori[sz]ed/i.test(message)) {
998
998
  return "auth";
999
999
  }
1000
- if (/rate.?limit|too many requests|\b429\b|\b529\b|overloaded|usage limit|limit reached|quota|capacity/i.test(message)) return "rate-limited";
1000
+ if (/usage limit|session limit|limit reached|rate.?limit|too many requests|\b429\b|\b529\b|overloaded|quota|capacity/i.test(message)) return "rate-limited";
1001
1001
  if (/model[^.]*(not found|does not exist|invalid|not available)|invalid model|not_found_error/i.test(message)) return "model-unavailable";
1002
1002
  return "other";
1003
1003
  }
@@ -1198,7 +1198,7 @@ function classify3(message) {
1198
1198
  if (/not supported|model[^.]*(not found|does not exist|unknown|unavailable)|unsupported model|no such model/i.test(message)) {
1199
1199
  return "model-unavailable";
1200
1200
  }
1201
- if (/rate.?limit|too many requests|\b429\b|usage limit|quota|overloaded|\b50[23]\b|capacity/i.test(message)) return "rate-limited";
1201
+ if (/usage limit|session limit|rate.?limit|too many requests|\b429\b|quota|overloaded|\b50[23]\b|capacity/i.test(message)) return "rate-limited";
1202
1202
  if (/unauthori[sz]ed|\b40[13]\b|not logged in|log ?in|api.?key|credits?\b|billing|subscription/i.test(message)) return "auth";
1203
1203
  return "other";
1204
1204
  }
@@ -1436,7 +1436,7 @@ function classify4(message) {
1436
1436
  if (/IneligibleTier|no longer supported for Gemini|error authenticating|not logged in|please (log ?in|sign in)|credentials|api key|UNAUTHENTICATED|PERMISSION_DENIED|\b40[13]\b|unauthori[sz]ed/i.test(message)) {
1437
1437
  return "auth";
1438
1438
  }
1439
- if (/RESOURCE_EXHAUSTED|\b429\b|quota|rate.?limit|too many requests|overloaded|\b503\b|exhausted your capacity|capacity/i.test(message)) return "rate-limited";
1439
+ if (/usage limit|session limit|RESOURCE_EXHAUSTED|\b429\b|quota|rate.?limit|too many requests|overloaded|\b503\b|exhausted your capacity|capacity/i.test(message)) return "rate-limited";
1440
1440
  if (/model.{0,80}(not found|not available|no longer available|does not exist|unsupported|invalid)|\b404\b|NOT_FOUND|invalid model|is not supported/i.test(message)) return "model-unavailable";
1441
1441
  return "other";
1442
1442
  }
@@ -1880,7 +1880,7 @@ function majorVersion(output) {
1880
1880
  }
1881
1881
  function classify5(message) {
1882
1882
  if (/model not found|ModelNotFound|unknown model|no such model|deprecated/i.test(message)) return "model-unavailable";
1883
- if (/rate.?limit|too many requests|\b429\b|quota|overloaded|\b50[23]\b|unavailable|capacity/i.test(message)) return "rate-limited";
1883
+ if (/usage limit|session limit|rate.?limit|too many requests|\b429\b|quota|overloaded|\b50[23]\b|unavailable|capacity/i.test(message)) return "rate-limited";
1884
1884
  if (/provider\.auth|FreeTierError|free tier|unauthori[sz]ed|\b40[13]\b|api.?key|insufficient|credits?\b|billing|payment/i.test(message)) {
1885
1885
  return "auth";
1886
1886
  }
@@ -2225,7 +2225,7 @@ function invocation7(req) {
2225
2225
  }
2226
2226
  function classify6(message) {
2227
2227
  if (/\b40[13]\b|incorrect api key|invalid api key|unauthori[sz]ed|authenticat|not logged in|no auth/i.test(message)) return "auth";
2228
- if (/\b429\b|rate.?limit|too many requests|quota|capacity|overloaded|\b529\b/i.test(message)) return "rate-limited";
2228
+ if (/usage limit|session limit|\b429\b|rate.?limit|too many requests|quota|capacity|overloaded|\b529\b/i.test(message)) return "rate-limited";
2229
2229
  if (/model[^.]*(not found|does not exist|not available|not supported)|invalid model|model_not_found|\b404\b/i.test(message)) return "model-unavailable";
2230
2230
  return "other";
2231
2231
  }
@@ -2493,6 +2493,7 @@ function fill(template, values) {
2493
2493
  // src/core/cooldown.ts
2494
2494
  var MAX_MS = 24 * 36e5;
2495
2495
  var OVERLOAD_MS = 20 * 6e4;
2496
+ var USAGE_MS = 36e5;
2496
2497
  var DAILY_MS = 4 * 36e5;
2497
2498
  function retryAfterMs(message) {
2498
2499
  const compact2 = /(?:retry|try again|resets?|available again)[^0-9]{0,24}((?:\d+(?:\.\d+)?\s*[dhms]\s*)+)/i.exec(message)?.[1];
@@ -2505,10 +2506,41 @@ function retryAfterMs(message) {
2505
2506
  }
2506
2507
  return ms > 0 ? ms : void 0;
2507
2508
  }
2508
- function cooldownMs(message) {
2509
- const told = retryAfterMs(message);
2509
+ function wallClock(at, timeZone) {
2510
+ const p = Object.fromEntries(
2511
+ new Intl.DateTimeFormat("en-US", { timeZone, hourCycle: "h23", year: "numeric", month: "numeric", day: "numeric", hour: "numeric", minute: "numeric", second: "numeric" }).formatToParts(at).map((x) => [x.type, Number(x.value)])
2512
+ );
2513
+ return Date.UTC(p.year, p.month - 1, p.day, p.hour, p.minute, p.second);
2514
+ }
2515
+ function retryAtMs(message, now = Date.now()) {
2516
+ const m = /(?:try again at|resets?(?: at)?)\s+(\d{1,2})(?::(\d{2}))?\s*([ap])\.?m\b\.?(?:\s*\(([A-Za-z_]+(?:\/[A-Za-z0-9_+-]+)*)\))?/i.exec(message) ?? /(?:try again at|resets?(?: at)?)\s+(\d{1,2}):(\d{2})()(?!\d)(?:\s*\(([A-Za-z_]+(?:\/[A-Za-z0-9_+-]+)*)\))?/i.exec(message);
2517
+ if (!m) return void 0;
2518
+ let hour = Number(m[1]);
2519
+ const minute = Number(m[2] ?? 0);
2520
+ const half = m[3]?.toLowerCase();
2521
+ if (minute > 59 || (half ? hour < 1 || hour > 12 : hour > 23)) return void 0;
2522
+ if (half === "p" && hour < 12) hour += 12;
2523
+ if (half === "a" && hour === 12) hour = 0;
2524
+ let zone = m[4];
2525
+ try {
2526
+ if (zone) new Intl.DateTimeFormat("en-US", { timeZone: zone });
2527
+ } catch {
2528
+ zone = void 0;
2529
+ }
2530
+ const wall = wallClock(now, zone);
2531
+ for (const day of [0, 1]) {
2532
+ const d = new Date(wall);
2533
+ const target = Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate() + day, hour, minute);
2534
+ const at = target - (wallClock(target, zone) - target);
2535
+ if (at > now) return at - now;
2536
+ }
2537
+ return void 0;
2538
+ }
2539
+ function cooldownMs(message, now = Date.now()) {
2540
+ const told = retryAfterMs(message) ?? retryAtMs(message, now);
2510
2541
  if (told !== void 0) return Math.min(Math.max(told, 6e4), MAX_MS);
2511
- return /daily|per.?day|per day|exhausted your/i.test(message) ? DAILY_MS : OVERLOAD_MS;
2542
+ if (/daily|per.?day|per day|exhausted your/i.test(message)) return DAILY_MS;
2543
+ return /usage limit|session limit|quota|FreeUsageLimit/i.test(message) ? USAGE_MS : OVERLOAD_MS;
2512
2544
  }
2513
2545
 
2514
2546
  // src/core/answers.ts
@@ -2722,7 +2754,7 @@ function scoreOf(r) {
2722
2754
  return r.audited >= MIN_AUDITS ? ok * (r.agreed / r.audited) : ok;
2723
2755
  }
2724
2756
  var pct = (x) => `${Math.round(x * 100)}%`;
2725
- function rankChain(chain, recordOf) {
2757
+ function rankChain(chain, recordOf, work) {
2726
2758
  const scored = chain.map((t, i) => ({ t, i, score: scoreOf(recordOf(t)) }));
2727
2759
  const slots = scored.filter((s) => s.score !== void 0);
2728
2760
  if (slots.length < 2) return { chain };
@@ -2733,7 +2765,7 @@ function rankChain(chain, recordOf) {
2733
2765
  const by = (t) => scored.find((s) => s.t === t).score;
2734
2766
  return {
2735
2767
  chain: out,
2736
- note: `ranked by record ("rankWorkers"): ${describeTarget(out[0])} (${pct(by(out[0]))}) goes before ${describeTarget(chain[0])} (${pct(by(chain[0]))})`
2768
+ note: `ranked by record${work ? ` on ${work}` : ""} ("rankWorkers"): ${describeTarget(out[0])} (${pct(by(out[0]))}) goes before ${describeTarget(chain[0])} (${pct(by(chain[0]))})`
2737
2769
  };
2738
2770
  }
2739
2771
 
@@ -2821,11 +2853,16 @@ function savedUsd(usage, returnedTokens, price = primaryPrice()) {
2821
2853
  const readingTheReport = returnedTokens * price.input / 1e6;
2822
2854
  return Math.max(0, costAt(usage, price) - (workerCostOf(usage) ?? 0) - readingTheReport);
2823
2855
  }
2856
+ var SETTLING_MS = 10 * 6e4;
2824
2857
  function readLedger(sinceMs) {
2825
2858
  if (!fs16.existsSync(ledgerFile())) return [];
2859
+ const seen = /* @__PURE__ */ new Set();
2826
2860
  return fs16.readFileSync(ledgerFile(), "utf8").split("\n").filter(Boolean).flatMap((l) => {
2827
2861
  try {
2828
- return [JSON.parse(l)];
2862
+ const e = JSON.parse(l);
2863
+ if (seen.has(e.id)) return [];
2864
+ seen.add(e.id);
2865
+ return [e];
2829
2866
  } catch {
2830
2867
  return [];
2831
2868
  }
@@ -3199,6 +3236,15 @@ exec "$real" "$@"
3199
3236
  function shimDir() {
3200
3237
  return path18.join(home(), "shim", `v${VERSION}`);
3201
3238
  }
3239
+ function scoopTarget(exe) {
3240
+ try {
3241
+ const text = fs19.readFileSync(exe.replace(/\.exe$/i, ".shim"), "utf8");
3242
+ const target = /^\s*path\s*=\s*"?([^"\r\n]+?)"?\s*$/m.exec(text)?.[1];
3243
+ return target && fs19.statSync(target).isFile() ? target : void 0;
3244
+ } catch {
3245
+ return void 0;
3246
+ }
3247
+ }
3202
3248
  function findRealGit(skip) {
3203
3249
  const same = (a, b) => WINDOWS ? a.toLowerCase() === b.toLowerCase() : a === b;
3204
3250
  for (const dir of (process.env.PATH ?? "").split(path18.delimiter).filter(Boolean)) {
@@ -3206,7 +3252,7 @@ function findRealGit(skip) {
3206
3252
  const p = path18.join(dir, WINDOWS ? "git.exe" : "git");
3207
3253
  try {
3208
3254
  fs19.accessSync(p, fs19.constants.X_OK);
3209
- if (fs19.statSync(p).isFile()) return p;
3255
+ if (fs19.statSync(p).isFile()) return WINDOWS && scoopTarget(p) || p;
3210
3256
  } catch {
3211
3257
  }
3212
3258
  }
@@ -3225,11 +3271,28 @@ function findGitSh(realGit) {
3225
3271
  }
3226
3272
  return void 0;
3227
3273
  }
3228
- var cmdShim = (sh) => `@echo off\r
3229
- set "MSYS2_ARG_CONV_EXCL=*"\r
3230
- "${sh}" "%~dp0git" %*\r
3231
- exit /b %errorlevel%\r
3232
- `;
3274
+ var cmdShim = (sh) => [
3275
+ "@echo off",
3276
+ "setlocal EnableExtensions DisableDelayedExpansion",
3277
+ 'set "MSYS2_ARG_CONV_EXCL=*"',
3278
+ 'set "PITROOM_SELF=%~f0"',
3279
+ 'set q=^"',
3280
+ "setlocal EnableDelayedExpansion",
3281
+ 'set "line=!cmdcmdline!"',
3282
+ 'if not "!line: /d =!"=="!line!" goto plain',
3283
+ 'if not "!line: /s =!"=="!line!" goto plain',
3284
+ 'set "tail=!line:*/c ""%PITROOM_SELF%"=!"',
3285
+ 'if "!tail!"=="!line!" set "tail=!line:*/c "%PITROOM_SELF%=!"',
3286
+ 'if "!tail!"=="!line!" goto plain',
3287
+ 'if "!tail:~-1!"=="!q!" set "tail=!tail:~0,-1!"',
3288
+ `"${sh}" "%~dp0git" !tail!`,
3289
+ "exit /b !errorlevel!",
3290
+ ":plain",
3291
+ "endlocal",
3292
+ `"${sh}" "%~dp0git" %*`,
3293
+ "exit /b %errorlevel%",
3294
+ ""
3295
+ ].join("\r\n");
3233
3296
  var HOOK_VERSION = 1;
3234
3297
  var REF_HOOK = `#!/bin/sh
3235
3298
  # pitroom git guard (layer 2, v${HOOK_VERSION}): refuse ref updates made by workers.
@@ -3431,6 +3494,7 @@ export {
3431
3494
  brief,
3432
3495
  card,
3433
3496
  catalogPrice,
3497
+ cmdShim,
3434
3498
  compact,
3435
3499
  cooldownMs,
3436
3500
  costAt,
@@ -3475,11 +3539,13 @@ export {
3475
3539
  requestStop,
3476
3540
  resolveRun,
3477
3541
  retryAfterMs,
3542
+ retryAtMs,
3478
3543
  retryCommands,
3479
3544
  runDir,
3480
3545
  runFile,
3481
3546
  runsDir,
3482
3547
  savedUsd,
3548
+ scoopTarget,
3483
3549
  scoreAnswer,
3484
3550
  scoreOf,
3485
3551
  secretWarning,
package/dist/pitroom.mjs CHANGED
@@ -775,7 +775,7 @@ function invocation(req) {
775
775
  return { command, args: [...prefix, ...args], env };
776
776
  }
777
777
  function classify(message) {
778
- if (/RESOURCE_EXHAUSTED|\b429\b|quota|rate.?limit|too many requests|overloaded|\b503\b|capacity/i.test(message)) return "rate-limited";
778
+ if (/usage limit|session limit|RESOURCE_EXHAUSTED|\b429\b|quota|rate.?limit|too many requests|overloaded|\b503\b|capacity/i.test(message)) return "rate-limited";
779
779
  if (/not (signed|logged) in|\bsign.?in\b|\blog ?in\b|authenticat|PERMISSION_DENIED|oauth|credential|keyring|keychain|\b401\b|unauthori[sz]ed/i.test(message)) {
780
780
  return "auth";
781
781
  }
@@ -1013,7 +1013,7 @@ function classify2(message) {
1013
1013
  if (/authentication_failed|failed to authenticate|oauth|not logged in|\/login|invalid api key|\b40[13]\b|unauthori[sz]ed/i.test(message)) {
1014
1014
  return "auth";
1015
1015
  }
1016
- if (/rate.?limit|too many requests|\b429\b|\b529\b|overloaded|usage limit|limit reached|quota|capacity/i.test(message)) return "rate-limited";
1016
+ if (/usage limit|session limit|limit reached|rate.?limit|too many requests|\b429\b|\b529\b|overloaded|quota|capacity/i.test(message)) return "rate-limited";
1017
1017
  if (/model[^.]*(not found|does not exist|invalid|not available)|invalid model|not_found_error/i.test(message)) return "model-unavailable";
1018
1018
  return "other";
1019
1019
  }
@@ -1214,7 +1214,7 @@ function classify3(message) {
1214
1214
  if (/not supported|model[^.]*(not found|does not exist|unknown|unavailable)|unsupported model|no such model/i.test(message)) {
1215
1215
  return "model-unavailable";
1216
1216
  }
1217
- if (/rate.?limit|too many requests|\b429\b|usage limit|quota|overloaded|\b50[23]\b|capacity/i.test(message)) return "rate-limited";
1217
+ if (/usage limit|session limit|rate.?limit|too many requests|\b429\b|quota|overloaded|\b50[23]\b|capacity/i.test(message)) return "rate-limited";
1218
1218
  if (/unauthori[sz]ed|\b40[13]\b|not logged in|log ?in|api.?key|credits?\b|billing|subscription/i.test(message)) return "auth";
1219
1219
  return "other";
1220
1220
  }
@@ -1452,7 +1452,7 @@ function classify4(message) {
1452
1452
  if (/IneligibleTier|no longer supported for Gemini|error authenticating|not logged in|please (log ?in|sign in)|credentials|api key|UNAUTHENTICATED|PERMISSION_DENIED|\b40[13]\b|unauthori[sz]ed/i.test(message)) {
1453
1453
  return "auth";
1454
1454
  }
1455
- if (/RESOURCE_EXHAUSTED|\b429\b|quota|rate.?limit|too many requests|overloaded|\b503\b|exhausted your capacity|capacity/i.test(message)) return "rate-limited";
1455
+ if (/usage limit|session limit|RESOURCE_EXHAUSTED|\b429\b|quota|rate.?limit|too many requests|overloaded|\b503\b|exhausted your capacity|capacity/i.test(message)) return "rate-limited";
1456
1456
  if (/model.{0,80}(not found|not available|no longer available|does not exist|unsupported|invalid)|\b404\b|NOT_FOUND|invalid model|is not supported/i.test(message)) return "model-unavailable";
1457
1457
  return "other";
1458
1458
  }
@@ -1896,7 +1896,7 @@ function majorVersion(output) {
1896
1896
  }
1897
1897
  function classify5(message) {
1898
1898
  if (/model not found|ModelNotFound|unknown model|no such model|deprecated/i.test(message)) return "model-unavailable";
1899
- if (/rate.?limit|too many requests|\b429\b|quota|overloaded|\b50[23]\b|unavailable|capacity/i.test(message)) return "rate-limited";
1899
+ if (/usage limit|session limit|rate.?limit|too many requests|\b429\b|quota|overloaded|\b50[23]\b|unavailable|capacity/i.test(message)) return "rate-limited";
1900
1900
  if (/provider\.auth|FreeTierError|free tier|unauthori[sz]ed|\b40[13]\b|api.?key|insufficient|credits?\b|billing|payment/i.test(message)) {
1901
1901
  return "auth";
1902
1902
  }
@@ -2241,7 +2241,7 @@ function invocation7(req) {
2241
2241
  }
2242
2242
  function classify6(message) {
2243
2243
  if (/\b40[13]\b|incorrect api key|invalid api key|unauthori[sz]ed|authenticat|not logged in|no auth/i.test(message)) return "auth";
2244
- if (/\b429\b|rate.?limit|too many requests|quota|capacity|overloaded|\b529\b/i.test(message)) return "rate-limited";
2244
+ if (/usage limit|session limit|\b429\b|rate.?limit|too many requests|quota|capacity|overloaded|\b529\b/i.test(message)) return "rate-limited";
2245
2245
  if (/model[^.]*(not found|does not exist|not available|not supported)|invalid model|model_not_found|\b404\b/i.test(message)) return "model-unavailable";
2246
2246
  return "other";
2247
2247
  }
@@ -2946,6 +2946,15 @@ exec "$real" "$@"
2946
2946
  function shimDir() {
2947
2947
  return path14.join(home(), "shim", `v${VERSION}`);
2948
2948
  }
2949
+ function scoopTarget(exe) {
2950
+ try {
2951
+ const text = fs15.readFileSync(exe.replace(/\.exe$/i, ".shim"), "utf8");
2952
+ const target = /^\s*path\s*=\s*"?([^"\r\n]+?)"?\s*$/m.exec(text)?.[1];
2953
+ return target && fs15.statSync(target).isFile() ? target : void 0;
2954
+ } catch {
2955
+ return void 0;
2956
+ }
2957
+ }
2949
2958
  function findRealGit(skip) {
2950
2959
  const same2 = (a, b) => WINDOWS ? a.toLowerCase() === b.toLowerCase() : a === b;
2951
2960
  for (const dir of (process.env.PATH ?? "").split(path14.delimiter).filter(Boolean)) {
@@ -2953,7 +2962,7 @@ function findRealGit(skip) {
2953
2962
  const p = path14.join(dir, WINDOWS ? "git.exe" : "git");
2954
2963
  try {
2955
2964
  fs15.accessSync(p, fs15.constants.X_OK);
2956
- if (fs15.statSync(p).isFile()) return p;
2965
+ if (fs15.statSync(p).isFile()) return WINDOWS && scoopTarget(p) || p;
2957
2966
  } catch {
2958
2967
  }
2959
2968
  }
@@ -2972,11 +2981,28 @@ function findGitSh(realGit) {
2972
2981
  }
2973
2982
  return void 0;
2974
2983
  }
2975
- var cmdShim = (sh) => `@echo off\r
2976
- set "MSYS2_ARG_CONV_EXCL=*"\r
2977
- "${sh}" "%~dp0git" %*\r
2978
- exit /b %errorlevel%\r
2979
- `;
2984
+ var cmdShim = (sh) => [
2985
+ "@echo off",
2986
+ "setlocal EnableExtensions DisableDelayedExpansion",
2987
+ 'set "MSYS2_ARG_CONV_EXCL=*"',
2988
+ 'set "PITROOM_SELF=%~f0"',
2989
+ 'set q=^"',
2990
+ "setlocal EnableDelayedExpansion",
2991
+ 'set "line=!cmdcmdline!"',
2992
+ 'if not "!line: /d =!"=="!line!" goto plain',
2993
+ 'if not "!line: /s =!"=="!line!" goto plain',
2994
+ 'set "tail=!line:*/c ""%PITROOM_SELF%"=!"',
2995
+ 'if "!tail!"=="!line!" set "tail=!line:*/c "%PITROOM_SELF%=!"',
2996
+ 'if "!tail!"=="!line!" goto plain',
2997
+ 'if "!tail:~-1!"=="!q!" set "tail=!tail:~0,-1!"',
2998
+ `"${sh}" "%~dp0git" !tail!`,
2999
+ "exit /b !errorlevel!",
3000
+ ":plain",
3001
+ "endlocal",
3002
+ `"${sh}" "%~dp0git" %*`,
3003
+ "exit /b %errorlevel%",
3004
+ ""
3005
+ ].join("\r\n");
2980
3006
  var HOOK_VERSION = 1;
2981
3007
  var REF_HOOK = `#!/bin/sh
2982
3008
  # pitroom git guard (layer 2, v${HOOK_VERSION}): refuse ref updates made by workers.
@@ -3116,6 +3142,7 @@ import fs17 from "node:fs";
3116
3142
  import path15 from "node:path";
3117
3143
  var MAX_MS = 24 * 36e5;
3118
3144
  var OVERLOAD_MS = 20 * 6e4;
3145
+ var USAGE_MS = 36e5;
3119
3146
  var DAILY_MS = 4 * 36e5;
3120
3147
  var file = () => path15.join(home(), "cooldowns.json");
3121
3148
  function cooldownKey(target, backend) {
@@ -3133,10 +3160,41 @@ function retryAfterMs(message) {
3133
3160
  }
3134
3161
  return ms > 0 ? ms : void 0;
3135
3162
  }
3136
- function cooldownMs(message) {
3137
- const told = retryAfterMs(message);
3163
+ function wallClock(at, timeZone) {
3164
+ const p = Object.fromEntries(
3165
+ new Intl.DateTimeFormat("en-US", { timeZone, hourCycle: "h23", year: "numeric", month: "numeric", day: "numeric", hour: "numeric", minute: "numeric", second: "numeric" }).formatToParts(at).map((x) => [x.type, Number(x.value)])
3166
+ );
3167
+ return Date.UTC(p.year, p.month - 1, p.day, p.hour, p.minute, p.second);
3168
+ }
3169
+ function retryAtMs(message, now = Date.now()) {
3170
+ const m = /(?:try again at|resets?(?: at)?)\s+(\d{1,2})(?::(\d{2}))?\s*([ap])\.?m\b\.?(?:\s*\(([A-Za-z_]+(?:\/[A-Za-z0-9_+-]+)*)\))?/i.exec(message) ?? /(?:try again at|resets?(?: at)?)\s+(\d{1,2}):(\d{2})()(?!\d)(?:\s*\(([A-Za-z_]+(?:\/[A-Za-z0-9_+-]+)*)\))?/i.exec(message);
3171
+ if (!m) return void 0;
3172
+ let hour = Number(m[1]);
3173
+ const minute = Number(m[2] ?? 0);
3174
+ const half = m[3]?.toLowerCase();
3175
+ if (minute > 59 || (half ? hour < 1 || hour > 12 : hour > 23)) return void 0;
3176
+ if (half === "p" && hour < 12) hour += 12;
3177
+ if (half === "a" && hour === 12) hour = 0;
3178
+ let zone = m[4];
3179
+ try {
3180
+ if (zone) new Intl.DateTimeFormat("en-US", { timeZone: zone });
3181
+ } catch {
3182
+ zone = void 0;
3183
+ }
3184
+ const wall = wallClock(now, zone);
3185
+ for (const day of [0, 1]) {
3186
+ const d = new Date(wall);
3187
+ const target = Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate() + day, hour, minute);
3188
+ const at = target - (wallClock(target, zone) - target);
3189
+ if (at > now) return at - now;
3190
+ }
3191
+ return void 0;
3192
+ }
3193
+ function cooldownMs(message, now = Date.now()) {
3194
+ const told = retryAfterMs(message) ?? retryAtMs(message, now);
3138
3195
  if (told !== void 0) return Math.min(Math.max(told, 6e4), MAX_MS);
3139
- return /daily|per.?day|per day|exhausted your/i.test(message) ? DAILY_MS : OVERLOAD_MS;
3196
+ if (/daily|per.?day|per day|exhausted your/i.test(message)) return DAILY_MS;
3197
+ return /usage limit|session limit|quota|FreeUsageLimit/i.test(message) ? USAGE_MS : OVERLOAD_MS;
3140
3198
  }
3141
3199
  function read() {
3142
3200
  try {
@@ -3160,7 +3218,7 @@ var activeCooldown = (key, now = Date.now()) => activeCooldowns(now)[key];
3160
3218
  function recordCooldown(key, targetName, message, now = Date.now()) {
3161
3219
  try {
3162
3220
  const all = activeCooldowns(now);
3163
- all[key] = { until: new Date(now + cooldownMs(message)).toISOString(), reason: message.replace(/\s+/g, " ").trim().slice(0, 160), target: targetName };
3221
+ all[key] = { until: new Date(now + cooldownMs(message, now)).toISOString(), reason: message.replace(/\s+/g, " ").trim().slice(0, 160), target: targetName };
3164
3222
  write(all);
3165
3223
  } catch {
3166
3224
  }
@@ -3349,12 +3407,22 @@ function focusedRate(base2, task, record2) {
3349
3407
  return { rate: Math.min(rate2, 1), why };
3350
3408
  }
3351
3409
  function pickAuditor(meta) {
3410
+ return auditors(meta)[0];
3411
+ }
3412
+ function auditors(meta) {
3352
3413
  const eff = effective();
3353
3414
  const tiers = eff.tiers.value;
3354
3415
  const def = parseTarget(eff.worker.value, DEFAULT_BACKEND).backend;
3355
3416
  const ran = meta.ran ?? meta.worker;
3417
+ const bare = (t) => ({ backend: t.backend, model: t.model?.split("#")[0] || void 0 });
3356
3418
  const candidates = [tiers.audit, tiers.cheap, ...eff.fallback.value, eff.worker.value].filter((s) => !!s);
3357
- return candidates.find((c) => !sameTarget(parseTarget(c, def), ran));
3419
+ const out = [];
3420
+ for (const c of candidates) {
3421
+ const t = bare(parseTarget(c, def));
3422
+ if (sameTarget(t, bare(ran)) || out.some((o) => sameTarget(bare(parseTarget(o, def)), t))) continue;
3423
+ out.push(c);
3424
+ }
3425
+ return out;
3358
3426
  }
3359
3427
  function relativeToAuditor(answer, meta) {
3360
3428
  let out = answer;
@@ -3611,12 +3679,21 @@ function workerPrice(backend, ...models) {
3611
3679
  }
3612
3680
  var costAt = (usage2, price) => (usage2.input * price.input + usage2.cacheRead * price.cachedInput + (usage2.output + usage2.reasoning) * price.output) / 1e6;
3613
3681
  var workerCostOf = (usage2) => usage2?.cost ?? usage2?.costEstimate;
3682
+ function addUsage(a, b) {
3683
+ const sum = (k) => (a[k] ?? 0) + (b[k] ?? 0);
3684
+ const out = { input: sum("input"), output: sum("output"), reasoning: sum("reasoning"), cacheRead: sum("cacheRead"), cacheWrite: sum("cacheWrite"), total: sum("total"), steps: sum("steps"), toolCalls: sum("toolCalls"), denied: sum("denied") };
3685
+ const ca = workerCostOf(a);
3686
+ const cb = workerCostOf(b);
3687
+ if (ca === void 0 && cb === void 0) return out;
3688
+ if (a.cost !== void 0 && b.cost !== void 0) return { ...out, cost: a.cost + b.cost };
3689
+ return { ...out, costEstimate: (ca ?? 0) + (cb ?? 0), costSource: a.costSource ?? b.costSource };
3690
+ }
3614
3691
  function savedUsd(usage2, returnedTokens, price = primaryPrice()) {
3615
3692
  const readingTheReport = returnedTokens * price.input / 1e6;
3616
3693
  return Math.max(0, costAt(usage2, price) - (workerCostOf(usage2) ?? 0) - readingTheReport);
3617
3694
  }
3618
3695
  function record(meta) {
3619
- if (!meta.usage?.steps || meta.auditOf) return;
3696
+ if (meta.auditOf || !meta.usage?.steps && meta.state !== "done") return false;
3620
3697
  const entry = {
3621
3698
  id: meta.id,
3622
3699
  at: meta.endedAt ?? (/* @__PURE__ */ new Date()).toISOString(),
@@ -3624,22 +3701,49 @@ function record(meta) {
3624
3701
  state: meta.state,
3625
3702
  backend: (meta.ran ?? meta.worker).backend,
3626
3703
  model: meta.resolvedModel ?? (meta.ran ?? meta.worker).model,
3627
- tokens: meta.usage.total,
3704
+ tokens: meta.usage?.total ?? 0,
3628
3705
  returned: meta.returnedTokens ?? 0,
3629
3706
  workerCost: workerCostOf(meta.usage) ?? 0,
3630
- ...meta.usage.cost === void 0 && meta.usage.costEstimate !== void 0 ? { costEstimated: true, costSource: meta.usage.costSource } : {},
3707
+ ...meta.usage?.cost === void 0 && meta.usage?.costEstimate !== void 0 ? { costEstimated: true, costSource: meta.usage.costSource } : {},
3631
3708
  saved: meta.savedUsd ?? 0,
3632
3709
  price: primaryPrice().name
3633
3710
  };
3634
3711
  fs20.mkdirSync(path19.dirname(ledgerFile()), { recursive: true });
3635
3712
  fs20.appendFileSync(ledgerFile(), `${JSON.stringify(entry)}
3636
3713
  `);
3714
+ return true;
3715
+ }
3716
+ var SETTLING_MS = 10 * 6e4;
3717
+ function settleLedger(metas) {
3718
+ const known = new Set(readLedger().map((e) => e.id));
3719
+ let n = 0;
3720
+ for (const m of metas ?? finishedRuns()) {
3721
+ if (known.has(m.id) || !TERMINAL.includes(m.state)) continue;
3722
+ if (m.pid !== process.pid && isAlive(m.pid) && (!m.endedAt || Date.now() - Date.parse(m.endedAt) < SETTLING_MS)) continue;
3723
+ if (record(m)) {
3724
+ known.add(m.id);
3725
+ n++;
3726
+ }
3727
+ }
3728
+ return n;
3729
+ }
3730
+ function* finishedRuns() {
3731
+ for (const id2 of listRunIds()) {
3732
+ try {
3733
+ yield readMeta(id2);
3734
+ } catch {
3735
+ }
3736
+ }
3637
3737
  }
3638
3738
  function readLedger(sinceMs2) {
3639
3739
  if (!fs20.existsSync(ledgerFile())) return [];
3740
+ const seen = /* @__PURE__ */ new Set();
3640
3741
  return fs20.readFileSync(ledgerFile(), "utf8").split("\n").filter(Boolean).flatMap((l) => {
3641
3742
  try {
3642
- return [JSON.parse(l)];
3743
+ const e = JSON.parse(l);
3744
+ if (seen.has(e.id)) return [];
3745
+ seen.add(e.id);
3746
+ return [e];
3643
3747
  } catch {
3644
3748
  return [];
3645
3749
  }
@@ -4036,6 +4140,7 @@ function archivedId(ref) {
4036
4140
  }
4037
4141
  }
4038
4142
  var VERIFY_FAILED = "(r.state = 'done' AND json_extract(r.meta_json, '$.verifyResult.ok') = 0)";
4143
+ var COST_ESTIMATED = "(json_extract(r.meta_json, '$.usage.cost') IS NULL AND json_extract(r.meta_json, '$.usage.costEstimate') IS NOT NULL)";
4039
4144
  var ftsQuery = (text) => text.split(/\s+/).filter(Boolean).map((w) => `"${w.replace(/"/g, '""')}"*`).join(" ");
4040
4145
  function listHistory(q = {}) {
4041
4146
  const db = openDb();
@@ -4070,7 +4175,7 @@ function listHistory(q = {}) {
4070
4175
  try {
4071
4176
  const total = db.prepare(`SELECT COUNT(*) AS n FROM runs r ${base2}`).get(...args).n;
4072
4177
  const page2 = q.beforeId ? `${base2 ? `${base2} AND` : "WHERE"} r.id < ?` : base2;
4073
- const rows = db.prepare(`SELECT r.*, json_extract(r.meta_json, '$.audit.state') AS audit_state, json_extract(r.meta_json, '$.audit.verdict') AS audit_verdict, ${VERIFY_FAILED} AS verify_failed FROM runs r ${page2} ORDER BY r.id DESC LIMIT ?`).all(...args, ...q.beforeId ? [q.beforeId] : [], Math.min(Math.max(q.limit ?? 30, 1), 200));
4178
+ const rows = db.prepare(`SELECT r.*, json_extract(r.meta_json, '$.audit.state') AS audit_state, json_extract(r.meta_json, '$.audit.verdict') AS audit_verdict, ${VERIFY_FAILED} AS verify_failed, ${COST_ESTIMATED} AS cost_estimated FROM runs r ${page2} ORDER BY r.id DESC LIMIT ?`).all(...args, ...q.beforeId ? [q.beforeId] : [], Math.min(Math.max(q.limit ?? 30, 1), 200));
4074
4179
  return {
4075
4180
  total,
4076
4181
  rows: rows.map((r) => ({
@@ -4088,6 +4193,8 @@ function listHistory(q = {}) {
4088
4193
  seconds: r.seconds ?? void 0,
4089
4194
  steps: r.steps ?? void 0,
4090
4195
  tokens: r.tokens ?? void 0,
4196
+ cost: r.cost ?? void 0,
4197
+ ...r.cost_estimated ? { costEstimated: true } : {},
4091
4198
  saved: r.saved ?? void 0,
4092
4199
  files: r.files_changed ?? 0,
4093
4200
  applied: !!r.applied
@@ -4122,18 +4229,32 @@ function auditStats(db, since2) {
4122
4229
  }
4123
4230
  return { total, byWorker };
4124
4231
  }
4125
- function workerRecord(backend, model, sinceMs2) {
4232
+ var WORK_SQL = { research: "kind = 'research'", change: "kind LIKE 'change%'", review: "kind LIKE '%review'" };
4233
+ function workerRecord(backend, model, sinceMs2, work) {
4126
4234
  const db = openDb();
4127
4235
  if (!db) return void 0;
4128
4236
  const since2 = new Date(sinceMs2).toISOString();
4129
4237
  const fits = (m) => !model || m === model || !!m?.endsWith(`/${model}`) || model.endsWith(`/${m}`);
4130
4238
  const rec = { runs: 0, ok: 0, audited: 0, agreed: 0 };
4131
4239
  try {
4132
- for (const r of db.prepare(`SELECT model, SUM(NOT ${LIMITED}) runs, SUM(state='done') ok FROM runs WHERE backend = ? AND started_at >= ? AND ${NOT_AUDIT} AND ${ATTEMPTED} GROUP BY model`).all(backend, since2)) {
4240
+ for (const r of db.prepare(`SELECT model, SUM(NOT ${LIMITED}) runs, SUM(state='done') ok FROM runs WHERE backend = ? AND started_at >= ? AND ${NOT_AUDIT} AND ${ATTEMPTED} AND ${work ? WORK_SQL[work] : "1"} GROUP BY model`).all(backend, since2)) {
4133
4241
  if (!fits(r.model)) continue;
4134
4242
  rec.runs += r.runs ?? 0;
4135
4243
  rec.ok += r.ok ?? 0;
4136
4244
  }
4245
+ if (work === "review") return rec;
4246
+ if (work === "change") {
4247
+ const reviews = db.prepare(`SELECT r.model, json_extract(v.meta_json, '$.verdict.spec') spec, json_extract(v.meta_json, '$.verdict.quality') quality, COUNT(*) n
4248
+ FROM runs v JOIN runs r ON r.id = v.review_of
4249
+ WHERE r.backend = ? AND v.started_at >= ? AND v.state = 'done' AND r.kind LIKE 'change%' AND json_extract(v.meta_json, '$.reviewOf') IS NOT NULL
4250
+ GROUP BY r.model, spec, quality`).all(backend, since2);
4251
+ for (const r of reviews) {
4252
+ if (!fits(r.model) || !["approved", "needs-fixes"].includes(r.quality)) continue;
4253
+ rec.audited += r.n;
4254
+ if (r.spec === "pass" && r.quality === "approved") rec.agreed += r.n;
4255
+ }
4256
+ return rec;
4257
+ }
4137
4258
  const audits = db.prepare(`SELECT r.model, json_extract(a.meta_json, '$.auditVerdict') v, COUNT(*) n
4138
4259
  FROM runs a JOIN runs r ON r.id = json_extract(a.meta_json, '$.auditOf')
4139
4260
  WHERE r.backend = ? AND a.started_at >= ? AND a.state = 'done' AND json_extract(a.meta_json, '$.auditOf') IS NOT NULL
@@ -4279,7 +4400,7 @@ function scoreOf(r) {
4279
4400
  return r.audited >= MIN_AUDITS ? ok * (r.agreed / r.audited) : ok;
4280
4401
  }
4281
4402
  var pct = (x) => `${Math.round(x * 100)}%`;
4282
- function rankChain(chain, recordOf) {
4403
+ function rankChain(chain, recordOf, work) {
4283
4404
  const scored = chain.map((t, i) => ({ t, i, score: scoreOf(recordOf(t)) }));
4284
4405
  const slots = scored.filter((s) => s.score !== void 0);
4285
4406
  if (slots.length < 2) return { chain };
@@ -4290,7 +4411,7 @@ function rankChain(chain, recordOf) {
4290
4411
  const by = (t) => scored.find((s) => s.t === t).score;
4291
4412
  return {
4292
4413
  chain: out,
4293
- note: `ranked by record ("rankWorkers"): ${describeTarget(out[0])} (${pct(by(out[0]))}) goes before ${describeTarget(chain[0])} (${pct(by(chain[0]))})`
4414
+ note: `ranked by record${work ? ` on ${work}` : ""} ("rankWorkers"): ${describeTarget(out[0])} (${pct(by(out[0]))}) goes before ${describeTarget(chain[0])} (${pct(by(chain[0]))})`
4294
4415
  };
4295
4416
  }
4296
4417
 
@@ -4451,7 +4572,7 @@ function formatReport(meta, finalText = readSummary(meta), maxLines = 400) {
4451
4572
  const status = meta.taskStatus ? ` \xB7 STATUS ${meta.taskStatus}` : "";
4452
4573
  out.push(`plan: ${planName(meta.plan.file)} \xB7 Task ${meta.plan.step}: ${meta.plan.title}${status}`);
4453
4574
  }
4454
- for (const a of meta.attempts ?? []) out.push(a.skipped ? `skipped: ${a.target} (${a.error.slice(0, 160)})` : `fallback: ${a.target} failed (${a.error.slice(0, 160)})`);
4575
+ for (const a of meta.attempts ?? []) out.push(a.skipped ? `skipped: ${a.target} (${a.error.slice(0, 160)})` : a.partial ? `fallback: ${a.target} hit a limit part way and handed the task over (${a.error.slice(0, 160)})` : `fallback: ${a.target} failed (${a.error.slice(0, 160)})`);
4455
4576
  if (meta.error) out.push(`error: ${meta.error}`);
4456
4577
  for (const w of meta.warnings) out.push(`warning: ${w}`);
4457
4578
  if (meta.state === "timeout") {
@@ -5161,7 +5282,7 @@ function inside2(file2, roots) {
5161
5282
  var escape = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
5162
5283
 
5163
5284
  // src/core/run.ts
5164
- var VERSION2 = true ? "0.25.0" : "0.0.0-dev";
5285
+ var VERSION2 = true ? "0.26.0" : "0.0.0-dev";
5165
5286
  function planWork(o) {
5166
5287
  if (!o.plan) return { task: o.task.trim(), tier: o.tier };
5167
5288
  if (o.continueFrom) throw new UserError("a follow-up continues its parent's task; drop --plan/--step");
@@ -5200,12 +5321,14 @@ function prepareRun(o) {
5200
5321
  worker = o.model ? { ...ran, model: o.model } : ran;
5201
5322
  fallback = o.noFallback ? [] : parent.fallback.filter((t) => t.backend === ran.backend);
5202
5323
  } else {
5203
- const chain = resolveChain({ worker: o.worker, model: o.model, tier: work.tier, effort: o.effort, noFallback: o.noFallback });
5324
+ const chain = resolveChain({ worker: o.worker, model: o.model, tier: work.tier, effort: o.effort, noFallback: o.noFallback || !!o.fallbackTo });
5204
5325
  ({ worker, fallback } = chain);
5326
+ if (o.fallbackTo && !o.noFallback) fallback = o.fallbackTo.map((w) => resolveChain({ worker: w, noFallback: true }).worker);
5205
5327
  warnings2.push(...chain.warnings);
5206
5328
  if (effective().rankWorkers.value && !o.worker && !o.model && !work.tier && fallback.length) {
5207
5329
  const since2 = Date.now() - 30 * 864e5;
5208
- const ranked = rankChain([worker, ...fallback], (t) => workerRecord(t.backend, t.model, since2));
5330
+ const work2 = o.review ? "review" : o.mode === "read" ? "research" : "change";
5331
+ const ranked = rankChain([worker, ...fallback], (t) => workerRecord(t.backend, t.model, since2, work2), work2 === "change" ? "changes" : work2);
5209
5332
  [worker, ...fallback] = ranked.chain;
5210
5333
  if (ranked.note) warnings2.push(ranked.note);
5211
5334
  }
@@ -5396,7 +5519,8 @@ async function execute(meta) {
5396
5519
  }
5397
5520
  }
5398
5521
  if (i === chain.length - 1) break;
5399
- (meta.attempts ??= []).push({ target: describeTarget(target), error: why.message });
5522
+ if (why.partial) handOver(meta, backend, target);
5523
+ (meta.attempts ??= []).push({ target: describeTarget(target), error: why.message, ...why.partial ? { partial: true } : {} });
5400
5524
  for (const f of ["events.jsonl", "stderr.log"]) {
5401
5525
  const from = runFile(meta.id, f);
5402
5526
  if (fs31.existsSync(from)) fs31.renameSync(from, runFile(meta.id, f.replace(".", `.attempt-${i + 1}.`)));
@@ -5422,7 +5546,7 @@ function autoAudit(meta) {
5422
5546
  let rate2 = auditRate(meta);
5423
5547
  if (meta.auditRate === void 0 && effective().auditFocus.value) {
5424
5548
  const ran = meta.ran ?? meta.worker;
5425
- const focus = focusedRate(rate2, meta.task, workerRecord(ran.backend, meta.resolvedModel ?? ran.model, Date.now() - 30 * 864e5));
5549
+ const focus = focusedRate(rate2, meta.task, workerRecord(ran.backend, meta.resolvedModel ?? ran.model, Date.now() - 30 * 864e5, "research"));
5426
5550
  rate2 = focus.rate;
5427
5551
  if (focus.why.length) {
5428
5552
  meta.auditFocus = { rate: rate2, why: focus.why };
@@ -5436,7 +5560,7 @@ function autoAudit(meta) {
5436
5560
  }
5437
5561
  function startAudit(meta, worker) {
5438
5562
  const answer = read2(runFile(meta.id, "summary.md"));
5439
- const auditor = worker ?? pickAuditor(meta);
5563
+ const [auditor, ...others] = worker ? [worker] : auditors(meta);
5440
5564
  if (!auditor) return void 0;
5441
5565
  const a = prepareRun({
5442
5566
  mode: "read",
@@ -5448,8 +5572,9 @@ function startAudit(meta, worker) {
5448
5572
  timeoutSec: Math.min(meta.timeoutSec, 15 * 60),
5449
5573
  allowNonGit: true,
5450
5574
  web: false,
5451
- // never fall back to the worker being audited
5452
- noFallback: true,
5575
+ // the other auditors, never the worker being audited; one you named has none
5576
+ noFallback: !!worker,
5577
+ fallbackTo: others,
5453
5578
  group: meta.group,
5454
5579
  audit: { of: meta.id }
5455
5580
  });
@@ -5572,7 +5697,9 @@ function sharedSnapshot(meta, root) {
5572
5697
  function attempt(meta, backend, target) {
5573
5698
  const inv = backend.invocation({
5574
5699
  mode: meta.mode,
5575
- prompt: buildPrompt(meta.mode, meta.task, !!meta.parent, !!meta.auditOf),
5700
+ prompt: buildPrompt(meta.mode, meta.handoff ? `${meta.task}
5701
+
5702
+ ${meta.handoff}` : meta.task, !!meta.parent, !!meta.auditOf),
5576
5703
  cwd: meta.cwd,
5577
5704
  model: target.model,
5578
5705
  sessionId: meta.sessionId,
@@ -5591,9 +5718,28 @@ function attempt(meta, backend, target) {
5591
5718
  var read2 = (f) => fs31.existsSync(f) ? fs31.readFileSync(f, "utf8") : "";
5592
5719
  function retryableFailure(meta, backend, res) {
5593
5720
  const run2 = backend.parse(read2(runFile(meta.id, "events.jsonl")));
5594
- if (run2.usage.steps > 0) return void 0;
5595
5721
  const f = backend.failure(run2, read2(runFile(meta.id, "stderr.log")), res.code);
5596
- return f && f.kind !== "other" ? f : void 0;
5722
+ if (!f || f.kind === "other") return void 0;
5723
+ if (run2.usage.steps === 0) return f;
5724
+ return f.kind === "rate-limited" ? { ...f, partial: true } : void 0;
5725
+ }
5726
+ function handOver(meta, backend, target) {
5727
+ const run2 = backend.parse(read2(runFile(meta.id, "events.jsonl")));
5728
+ const used = { ...run2.usage };
5729
+ if (used.cost === void 0) {
5730
+ const price = workerPrice(target.backend, run2.model, target.model);
5731
+ if (price) Object.assign(used, { costEstimate: costAt(used, price), costSource: price.source ?? "config" });
5732
+ }
5733
+ meta.priorUsage = meta.priorUsage ? addUsage(meta.priorUsage, used) : used;
5734
+ if (meta.mode === "read") return;
5735
+ let changed = [];
5736
+ try {
5737
+ const root = meta.mode === "isolate" ? meta.worktree : meta.repoRoot;
5738
+ if (meta.baseTree) changed = diffTrees(root, meta.baseTree, snapshotTree(root, meta.mode === "isolate" ? meta.link : [])).changes.map((c) => c.path);
5739
+ } catch {
5740
+ }
5741
+ const list2 = changed.length ? `: ${changed.slice(0, 30).join(", ")}${changed.length > 30 ? `, and ${changed.length - 30} more` : ""}` : "";
5742
+ meta.handoff = `Another worker started this task and stopped part way, at its usage limit. Its edits are already in the files${list2}. Do not start over: check what it changed against the task, keep what is right, fix what is not, and finish the task.`;
5597
5743
  }
5598
5744
  var HINTS = {
5599
5745
  "model-unavailable": "the model is not available; fix the worker config, pass --worker/--model, or configure fallback workers (see `pitroom doctor`)",
@@ -5659,6 +5805,7 @@ function finalize(meta, res) {
5659
5805
  meta.usage.costSource = price.source ?? "config";
5660
5806
  }
5661
5807
  }
5808
+ if (meta.priorUsage && meta.usage) meta.usage = addUsage(meta.priorUsage, meta.usage);
5662
5809
  meta.returnedTokens = estimateTokens(formatReport(meta, run2.finalText));
5663
5810
  meta.savedUsd = meta.auditOf ? 0 : savedUsd(meta.usage, meta.returnedTokens);
5664
5811
  writeMeta(meta);
@@ -6103,6 +6250,7 @@ var TOPICS = {
6103
6250
  stats: ["since"],
6104
6251
  savings: ["since", "perModel"],
6105
6252
  models: ["worker", "all"],
6253
+ prices: ["worker"],
6106
6254
  cooldown: [],
6107
6255
  config: [],
6108
6256
  doctor: []
@@ -6128,6 +6276,8 @@ function infoArgs(topic, a) {
6128
6276
  return { args: ["history", "stats", ...since(a)] };
6129
6277
  case "savings":
6130
6278
  return { args: ["savings", ...since(a), ...bool(a, "perModel") ? ["--models"] : []] };
6279
+ case "prices":
6280
+ return { args: ["prices", ...worker ? ["--", worker] : []] };
6131
6281
  case "models":
6132
6282
  return { args: ["models", ...worker ? [worker] : [], ...bool(a, "all") ? ["--all"] : []] };
6133
6283
  case "doctor":
@@ -6210,7 +6360,7 @@ var TOOLS2 = [
6210
6360
  {
6211
6361
  name: "pitroom_info",
6212
6362
  title: "Pitroom information",
6213
- description: "Reports, changing nothing. topic: runs (latest runs; running, group), history (search earlier answers before asking again; text, state, model, since, limit), stats (success rate, time, tokens, audits per worker; since), savings (since, perModel), models (a backend's models and costs; worker, all), cooldown (rate-limited models being skipped), config, doctor (setup check).",
6363
+ description: "Reports, changing nothing. topic: runs (latest runs; running, group), history (search earlier answers before asking again; text, state, model, since, limit), stats (success rate, time, tokens, audits per worker; since), savings (since, perModel), models (a backend's models and costs; worker, all), prices (USD per 1M tokens: the primary the savings are measured against, and each worker in use or the one named; worker), cooldown (rate-limited models being skipped), config, doctor (setup check).",
6214
6364
  inputSchema: {
6215
6365
  type: "object",
6216
6366
  properties: {
@@ -6753,6 +6903,7 @@ function toRun(m) {
6753
6903
  }
6754
6904
  function dashState(opts = {}) {
6755
6905
  const limit = Math.min(Math.max(opts.limit ?? 40, 1), 200);
6906
+ const scanned = [];
6756
6907
  const metas = [];
6757
6908
  const groups = /* @__PURE__ */ new Set();
6758
6909
  for (const id2 of listRunIds().slice(-SCAN).reverse()) {
@@ -6762,6 +6913,7 @@ function dashState(opts = {}) {
6762
6913
  } catch {
6763
6914
  continue;
6764
6915
  }
6916
+ scanned.push(m);
6765
6917
  if (m.group) groups.add(m.group);
6766
6918
  if (opts.group && m.group !== opts.group) continue;
6767
6919
  metas.push(m);
@@ -6769,6 +6921,7 @@ function dashState(opts = {}) {
6769
6921
  const whole = new Set(metas.slice(0, limit).flatMap((m) => m.group ? [m.group] : []));
6770
6922
  const runs = metas.filter((m, i) => i < limit || m.group && whole.has(m.group)).map(toRun);
6771
6923
  const now = Date.now();
6924
+ settleLedger(scanned);
6772
6925
  const ledger = readLedger();
6773
6926
  const within = (at, days2) => !!at && Date.parse(at) >= now - days2 * DAY_MS;
6774
6927
  const per = (f) => Object.fromEntries(Object.entries(PERIODS).map(([p, days2]) => [p, f(days2)]));
@@ -7058,6 +7211,7 @@ async function dashCommand(o) {
7058
7211
  throw new UserError("pitroom dash did not start (is the port taken? try --port 0)");
7059
7212
  }
7060
7213
  importRuns();
7214
+ settleLedger();
7061
7215
  const explicit = o.port !== void 0;
7062
7216
  let dash;
7063
7217
  try {
@@ -8179,7 +8333,8 @@ async function cmdAudit(p) {
8179
8333
  if (m.mode !== "read") throw new UserError(`run ${m.id} changed files; an audit re-checks a read run's answer: pitroom review ${m.id} judges a change`);
8180
8334
  const answer = readSummary(m);
8181
8335
  if (!answer.trim()) throw new UserError(`run ${m.id} gave no answer to audit`);
8182
- const worker = flag(p, "worker") ?? (flag(p, "tier") ? void 0 : pickAuditor(m));
8336
+ const named = flag(p, "worker") ?? flag(p, "tier");
8337
+ const [worker, ...others] = named ? [flag(p, "worker")] : auditors(m);
8183
8338
  if (!worker && !flag(p, "tier")) {
8184
8339
  throw new UserError('no other worker to audit with: name one with -W, or configure tiers "audit" or "cheap", or fallback workers, that differ from the one that answered', 3);
8185
8340
  }
@@ -8189,7 +8344,8 @@ async function cmdAudit(p) {
8189
8344
  dir: m.dir,
8190
8345
  worker,
8191
8346
  group: flag(p, "group") ?? m.group,
8192
- noFallback: true,
8347
+ noFallback: !!named,
8348
+ fallbackTo: others,
8193
8349
  audit: { of: m.id },
8194
8350
  auditRate: void 0
8195
8351
  });
@@ -8207,33 +8363,37 @@ async function cmdPrices(p) {
8207
8363
  if (!has(p, "quiet")) console.log(`price catalog updated: ${r.models} priced models`);
8208
8364
  }
8209
8365
  const st = catalogStatus();
8210
- const used = (() => {
8366
+ const priced = (t) => {
8367
+ const backend = getBackend(t.backend);
8368
+ const model = (t.model ?? "").split("#")[0];
8369
+ const price = workerPrice(t.backend, model);
8370
+ return {
8371
+ worker: model ? `${t.backend}:${model}` : t.backend,
8372
+ reportsCost: backend.capabilities.reportsCost,
8373
+ price: price ? { name: price.name, source: price.source, input: price.input, output: price.output, cachedInput: price.cachedInput } : void 0
8374
+ };
8375
+ };
8376
+ const asked = p.positional.length > 0;
8377
+ const used = asked ? p.positional.map((spec) => priced(parseTarget(spec, resolveChain().worker.backend))) : (() => {
8211
8378
  try {
8212
8379
  const chain = resolveChain();
8213
- return [chain.worker, ...chain.fallback].map((t) => {
8214
- const backend = getBackend(t.backend);
8215
- const model = (t.model ?? "").split("#")[0];
8216
- const price = workerPrice(t.backend, model);
8217
- return {
8218
- worker: model ? `${t.backend}:${model}` : t.backend,
8219
- reportsCost: backend.capabilities.reportsCost,
8220
- price: price ? { name: price.name, source: price.source, input: price.input, output: price.output, cachedInput: price.cachedInput } : void 0
8221
- };
8222
- });
8380
+ return [chain.worker, ...chain.fallback].map(priced);
8223
8381
  } catch {
8224
8382
  return [];
8225
8383
  }
8226
8384
  })();
8227
- if (has(p, "json")) return console.log(JSON.stringify({ ...st, workers: used }, null, 2)), 0;
8385
+ const primary = primaryPrice();
8386
+ if (has(p, "json")) return console.log(JSON.stringify({ ...st, primary, workers: used }, null, 2)), 0;
8228
8387
  console.log(`price feed: ${st.enabled ? "on" : 'off ("priceFeed": true in the config turns it on: Pitroom then fetches one public file, see PRIVACY.md)'}`);
8229
8388
  console.log(`source: ${st.url}${st.enabled ? `, refreshed after a run when older than ${st.hours} h` : ""}`);
8230
8389
  console.log(st.models ? `catalog: ${st.models} priced models, updated ${st.ageHours < 1 ? "within the hour" : `${st.ageHours.toFixed(1)} h ago`}` : "catalog: none yet (pitroom prices --refresh fetches it)");
8231
8390
  if (st.lastFailure) console.log(`last try: failed: ${st.lastFailure.replace(/^\S+ /, "")}`);
8391
+ console.log(`primary: ${primary.name}: in ${primary.input} \xB7 out ${primary.output} \xB7 cached ${primary.cachedInput} USD/1M (what the savings are measured against)`);
8232
8392
  if (used.length) {
8233
- console.log("\nthe workers in use:");
8393
+ console.log(asked ? "\nthe workers asked about:" : "\nthe workers in use:");
8234
8394
  for (const u of used) {
8235
8395
  const what2 = u.reportsCost ? "reports its own cost" : u.price ? `in ${u.price.input} \xB7 out ${u.price.output} \xB7 cached ${u.price.cachedInput} USD/1M (${u.price.source === "catalog" ? `price catalog ${catalogLabel()}` : "your workerPrices"})` : "no price: its runs count as free in the savings";
8236
- console.log(` ${u.worker.padEnd(36)} ${what2}`);
8396
+ console.log(` ${u.worker.padEnd(Math.max(36, ...used.map((x) => x.worker.length)))} ${what2}`);
8237
8397
  }
8238
8398
  }
8239
8399
  return 0;
@@ -8405,6 +8565,7 @@ function cmdHistory(p) {
8405
8565
  return 0;
8406
8566
  }
8407
8567
  importRuns();
8568
+ settleLedger();
8408
8569
  const since2 = sinceMs(flag(p, "since"));
8409
8570
  if (sub === "stats") {
8410
8571
  const s = historyStats(since2);
@@ -8424,14 +8585,16 @@ ${[fmt2(head2), ...rows2.map(fmt2)].join("\n")}`);
8424
8585
  const { rows, total } = listHistory({ text: p.positional.join(" "), model: flag(p, "model"), state: flag(p, "state"), group: flag(p, "group"), sinceMs: since2, limit });
8425
8586
  if (has(p, "json")) return console.log(JSON.stringify({ total, rows }, null, 2)), 0;
8426
8587
  if (!rows.length) return console.log(total ? "nothing on this page" : "no matching runs"), 0;
8427
- const body = rows.map((r) => [r.id, when(r.startedAt), r.verifyFailed ? "verify failed" : r.state, `${r.backend}${r.model ? ` (${r.model.split("/").pop()})` : ""}`, secs(r.seconds), r.task.length > 60 ? `${r.task.slice(0, 59)}\u2026` : r.task]);
8428
- const head = ["RUN", "WHEN", "STATE", "WORKER", "TIME", "TASK"];
8588
+ const cost = (r) => r.cost === void 0 ? "-" : r.cost === 0 ? "free" : `${r.costEstimated ? "~" : ""}${usd(r.cost)}`;
8589
+ const body = rows.map((r) => [r.id, when(r.startedAt), r.verifyFailed ? "verify failed" : r.state, `${r.backend}${r.model ? ` (${r.model.split("/").pop()})` : ""}`, secs(r.seconds), cost(r), r.task.length > 60 ? `${r.task.slice(0, 59)}\u2026` : r.task]);
8590
+ const head = ["RUN", "WHEN", "STATE", "WORKER", "TIME", "COST", "TASK"];
8429
8591
  const widths = head.map((h, i) => Math.max(h.length, ...body.map((r) => r[i].length)));
8430
8592
  const fmt = (r, header = false) => r.map((c, i) => {
8431
8593
  const cell2 = i === r.length - 1 ? c : c.padEnd(widths[i]);
8432
8594
  return header ? dim(cell2) : i === 2 ? stateColour(r[2], cell2) : cell2;
8433
8595
  }).join(" ");
8434
8596
  console.log([fmt(head, true), ...body.map((r) => fmt(r))].join("\n"));
8597
+ if (rows.some((r) => r.costEstimated)) console.log(dim("\n~ estimated from a price: the worker's CLI reported no cost"));
8435
8598
  if (total > rows.length) console.log(`
8436
8599
  ${rows.length} of ${total} \xB7 --limit N for more \xB7 pitroom show <run> for one`);
8437
8600
  return 0;
@@ -8556,19 +8719,21 @@ function cmdInit(p) {
8556
8719
  }
8557
8720
  function cmdSavings(p) {
8558
8721
  const since2 = flag(p, "since") ?? "all";
8722
+ settleLedger();
8559
8723
  const t = totals(readLedger(sinceMs(since2)));
8560
8724
  if (has(p, "models")) {
8561
8725
  const by = /* @__PURE__ */ new Map();
8562
8726
  for (const e of readLedger(sinceMs(since2))) {
8563
8727
  const key = `${e.backend ?? "?"} ${e.model ?? "(default model)"}`;
8564
- const a = by.get(key) ?? { runs: 0, tokens: 0, returned: 0, cost: 0, saved: 0 };
8565
- by.set(key, { runs: a.runs + 1, tokens: a.tokens + e.tokens, returned: a.returned + e.returned, cost: a.cost + e.workerCost, saved: a.saved + e.saved });
8728
+ const a = by.get(key) ?? { runs: 0, tokens: 0, returned: 0, cost: 0, estimated: false, saved: 0 };
8729
+ by.set(key, { runs: a.runs + 1, tokens: a.tokens + e.tokens, returned: a.returned + e.returned, cost: a.cost + e.workerCost, estimated: a.estimated || !!e.costEstimated, saved: a.saved + e.saved });
8566
8730
  }
8567
8731
  const width = Math.max(12, ...[...by.keys()].map((k) => k.length));
8568
8732
  console.log(`${"worker model".padEnd(width)} ${"runs".padStart(5)} ${"processed".padStart(10)} ${"returned".padStart(9)} ${"cost".padStart(8)} ${"saved".padStart(8)}`);
8569
8733
  for (const [key, a] of [...by].sort((x, y) => y[1].runs - x[1].runs)) {
8570
- console.log(`${key.padEnd(width)} ${String(a.runs).padStart(5)} ${compact(a.tokens).padStart(10)} ${compact(a.returned).padStart(9)} ${usd(a.cost).padStart(8)} ${usd(a.saved).padStart(8)}`);
8734
+ console.log(`${key.padEnd(width)} ${String(a.runs).padStart(5)} ${compact(a.tokens).padStart(10)} ${compact(a.returned).padStart(9)} ${`${a.estimated ? "~" : ""}${usd(a.cost)}`.padStart(8)} ${usd(a.saved).padStart(8)}`);
8571
8735
  }
8736
+ if ([...by.values()].some((a) => a.estimated)) console.log(dim("\n~ the cost is estimated from a price, in part or in all: the worker's CLI reported none"));
8572
8737
  return 0;
8573
8738
  }
8574
8739
  const period = since2 === "all" ? "all time" : `last ${since2.replace("d", " days")}`;
@@ -9061,7 +9226,7 @@ Usage
9061
9226
  pitroom statusline [--then CMD] status-bar line: running workers, savings this week (after CMD's)
9062
9227
  pitroom hook-card PostToolUse hook: a card after each Bash \`pitroom\` command
9063
9228
  pitroom hook-start SessionStart hook: introduces Pitroom to the agent (Codex; Claude Code uses its plugin)
9064
- pitroom prices [--refresh] [--json] the price catalog (config "priceFeed") and the price each worker in use gets
9229
+ pitroom prices [WORKER\u2026] [--refresh] [--json] the price catalog (config "priceFeed"), the primary's price and the price each worker in use (or named) gets
9065
9230
  pitroom cooldown [--clear] models that said "rate limited" and are skipped for a while (a quota used up);
9066
9231
  --clear tries them again
9067
9232
  pitroom mcp [-d DIR] [--http [--port N]]
package/docs/backends.md CHANGED
@@ -44,7 +44,10 @@ interface Backend {
44
44
  ```
45
45
 
46
46
  `failure().kind` is `model-unavailable`, `rate-limited`, `auth` or `other`. Anything
47
- but `other`, when the worker did no work, makes the core try the next target.
47
+ but `other`, when the worker did no work, makes the core try the next target. A
48
+ `rate-limited` failure does so even after work: the next target takes the task over (an edit
49
+ in the same tree, told what was already changed). So classify a plan's usage limit or a
50
+ used-up quota as `rate-limited`, and a context-length error as `other`.
48
51
 
49
52
  ## Mapping Pitroom's modes
50
53
 
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pitroom",
3
- "version": "0.25.0",
3
+ "version": "0.26.0",
4
4
  "description": "A free pit crew for your expensive coding agent: hand reading, fixes, tests and reviews to cheap worker agents and get back the answer, the exact diff and a receipt. Fourteen skills adapt a full development workflow. Needs the pitroom command (npm i -g pitroom).",
5
5
  "contextFileName": "gemini-context.md"
6
6
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pitroom",
3
- "version": "0.25.0",
3
+ "version": "0.26.0",
4
4
  "mcpName": "io.github.ATASTECH/pitroom",
5
5
  "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, Claude Code or Gemini CLI workers, with receipts that prove the savings.",
6
6
  "type": "module",