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.
- package/.claude-plugin/plugin.json +1 -1
- package/.codex-plugin/plugin.json +1 -1
- package/CHANGELOG.md +23 -0
- package/README.md +19 -17
- package/dist/lib.mjs +84 -18
- package/dist/pitroom.mjs +226 -61
- package/docs/backends.md +4 -1
- package/gemini-extension.json +1 -1
- package/package.json +1 -1
|
@@ -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.
|
|
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.
|
|
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
|
-
|
|
252
|
-
|
|
253
|
-
|
|
|
254
|
-
|
|
255
|
-
| OpenCode `
|
|
256
|
-
|
|
|
257
|
-
|
|
|
258
|
-
| OpenCode `big-pickle` |
|
|
259
|
-
| OpenCode `
|
|
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
|
|
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
|
|
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|
|
|
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|
|
|
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 (
|
|
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
|
|
2509
|
-
const
|
|
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
|
-
|
|
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
|
-
|
|
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) =>
|
|
3229
|
-
|
|
3230
|
-
"
|
|
3231
|
-
|
|
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|
|
|
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|
|
|
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 (
|
|
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) =>
|
|
2976
|
-
|
|
2977
|
-
"
|
|
2978
|
-
|
|
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
|
|
3137
|
-
const
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
3704
|
+
tokens: meta.usage?.total ?? 0,
|
|
3628
3705
|
returned: meta.returnedTokens ?? 0,
|
|
3629
3706
|
workerCost: workerCostOf(meta.usage) ?? 0,
|
|
3630
|
-
...meta.usage
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
|
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
|
-
(
|
|
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
|
|
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
|
-
//
|
|
5452
|
-
noFallback:
|
|
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.
|
|
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
|
-
|
|
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
|
|
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:
|
|
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
|
|
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(
|
|
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
|
-
|
|
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
|
|
8428
|
-
const
|
|
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)
|
|
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
|
|
package/gemini-extension.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pitroom",
|
|
3
|
-
"version": "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.
|
|
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",
|