bareloop 0.33.0 → 0.34.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -5,6 +5,22 @@ All notable changes to bareloop are documented here. Format:
5
5
  [SemVer](https://semver.org/spec/v2.0.0.html). Pre-1.0: **minor** = a ladder rung or
6
6
  feature lands, **patch** = docs, fixes, scaffolding.
7
7
 
8
+ ## [0.34.0] - 2026-10-01
9
+
10
+ ### Fixed
11
+
12
+ - Judge rounds now carry `rateSource` (`'caller'` under your price, else the guess label the worker rounds carry), so a customer-priced judge round counts as vouched, not other, in the provenance readout and the monthly counts. Dollar totals are unchanged. Evidence: live run `mup3h70u` (136 worker rounds `caller`, 14 judge rounds without it).
13
+ - The monthly-limit refusal now names the money a run in progress holds: `Max $4.00 (monthly limit, $5.95 held by a run in progress)` (`runs` when several); with nothing held the text is unchanged. `monthSpend` / `checkMonthlyRoom` also return `heldUsd` and `heldRuns`.
14
+ - The interview's "Run it now?" now starts the packaged door (`bin/bareloop.mjs author`, same flags) instead of `scripts/run-author.mjs`, which the published package does not ship.
15
+ - One "is this run alive" rule (`runIsAlive`, `src/runlist.js`) now drives both the panel's [▶] / [?] glyph and the monthly limit's hold: the run row's pid first (`isLiveRunner`), the spine-mtime rule only for a row with no pid. A run whose pid is alive but whose spine is quiet for 10+ minutes now reads running, not died; a dead pid with a fresh spine reads died. A `job-end` on the spine still wins.
16
+ - `--resume` now also refuses when the predecessor run's own run-list row carries the pid of a live bareloop runner, not only when a watchdog kill record names it (same refusal text).
17
+ - Every panel GET/HEAD route now refuses (`403`) a request whose `Host` is not the panel's own `127.0.0.1:<port>` (one shared `checkHostGuard`, also used by the write routes' guard), so a page on another origin cannot read the run list through a rebound DNS name. No token is needed to read.
18
+ - The monthly-limit check (and so every run start) no longer parses the spine of every run ever listed: it reads only runs listed in the current local month, plus any claim still held by a live runner. The Money tab's all-time figures still read everything.
19
+ - A key row priced 0 / 0 is still accepted, but the `price yours:` readout (run preview and run tail) now ends with a warning that the price is $0 so spend and limits read $0.
20
+ - A run that stops at $0 on a SOURCE-MANIFEST-RED, DESTINATION-RED or CLOSE-TIMING-RED now releases its monthly claim (the same `released` entry as a run that never started), so it leaves no ghost row and the month no longer reads "at least".
21
+ - A spec whose `budgetUsd` is not a finite number (e.g. `1e999`) is refused by `validateJob` at $0, and the monthly-limit check (`checkMonthlyRoom` / `claimRun`) no longer reads a non-finite cap as $0 — it never fits.
22
+ - A bundle money halt that used the whole signed ceiling now says no room is left under the signed ceiling (and prints no resume line) instead of a `--budget <more than X, at most X>` placeholder nothing can satisfy.
23
+
8
24
  ## [0.33.0] — 2026-09-30
9
25
 
10
26
  ### Added
@@ -158,7 +158,7 @@ minting claim, or the shell-owned retry cap — all unknown-field reds.
158
158
  | `provider` | `anthropic-api` \| `openai-api` \| `gemini-api` \| `clipipe-subscription` | menu (`PROVIDERS`); part of the lineage key by definition. Only `anthropic-api` is guaranteed (F48); `openai-api` (PRD item 28) goes through the provider factory (`src/providers.js`), reads `OPENAI_API_KEY`, and admits only `deepseek-flash` today (F171: swapped from the retired `deepseek-chat`) — see "Worker surface" above for its `baseUrl` field and per-model gating; `gemini-api` (PRD item 31.3) reads `GEMINI_API_KEY` and is ADMITTED-PENDING-PROBE — zero runs, and every launch says so (`PROBE_STATUS`/`probeWarningLines`); `clipipe-subscription` drives the worker natively and needs `opts.nativeProvider` |
159
159
  | `conditions` | `{ providerPath?, closeVerbosity?, taskFraming?, scaffold? }` | declared keys only, string values — the environment label (consumed by the N3 lineage key; recorded on spines from run one) |
160
160
  | `cadence` | `{ unit: hour\|day\|week, every: 1..30 }` | validated now, consumed at N5 (Scheduler) |
161
- | `budgetUsd` | `0 < n <= shell cap` | ceiling chain: workflow ≤ job ≤ shell — each layer may tighten, never exceed |
161
+ | `budgetUsd` | finite `0 < n <= shell cap` | ceiling chain: workflow ≤ job ≤ shell — each layer may tighten, never exceed |
162
162
  | `maxWallMs` | optional integer ms `>= MIN_WALL_MS` (one close timeout) | the run's wall clock. **NO DEFAULT, by ruling** — absent means time-unbounded *by explicit operator choice*, never by fallback (F45: a defaulted cap is a silent second ceiling). Enforcement is a BETWEEN-ROUND deadline, so the honest worst case is `maxWallMs + closeStages × closeTimeoutMs` — every stage of a staged close gets the FULL timeout — and all three numbers are reported (`loop.stop()` cannot cut an in-flight call — F61 measured 500ms→4,018ms). The `MIN_WALL_MS` floor is a ONE-stage, un-autoset-default number, so a spec with many stages OR a raised/autoset `closeTimeoutMs` can validate while its overshoot dwarfs its cap: `runPlan` adds a RUN-START check (PRD item 27/M3) that refuses `wall-under-close-timeout` at $0 once the EFFECTIVE per-stage ceiling is known (autoset or signed), rather than a silent clamp. Operator-only, tighten-only; adding or changing it changes the spec hash |
163
163
  | `closeTimeoutMs` | optional integer ms `>= CLOSE_TIMEOUT_FLOOR_MS` (120,000 — hamr's arbiter floor, 2026-09-07) | PRD item 27/M3's SIGNED override for the close's own per-stage wall-clock cap. Absent (the normal case): the run autosets it from a $0 seed timing pass — `max(FLOOR, K × slowest measured stage)`, `K = 5` (hamr, 2026-09-07: "2 and 5 are fine"). Present: the operator's own number wins outright and may legally sit ABOVE *or below* the autoset estimate (the rates-passthrough shape, F113 — never tighten-only). Arbiter territory exactly like `budgetUsd`/`maxWallMs`/`close[].sha256`: the authoring pipeline's `assembleSpec` refuses a draft carrying it |
164
164
  | `model` | optional non-empty string, exact provider model id (e.g. `"claude-sonnet-5"`) | the WORKER's model, build-list #3. Absent means today's runner default; present it wins over a `--model` flag outright, and a flag naming a different id is refused (`resolveWorkerModel`, `src/job.js`) rather than silently overridden. Part of the signed hash like every other field. Worker only — the JUDGE's model comes from the `judge` row below, which DEFAULTS to this same worker model |
@@ -1860,10 +1860,10 @@ before. bareloop keeps no price list of its own, ever.
1860
1860
  carries it on every metering payload, and bareloop forwards it VERBATIM (`rateSourceFields`,
1861
1861
  `src/planrun.js`): every API `worker-round` (worker, scout, planner, fix loop) carries it beside
1862
1862
  `pricing` today — `'caller'` when a customer price is on the key's row, else `'tier'`/`'default'`
1863
- (the guess). The exceptions read UNKNOWN provenance, correctly, and by the same rule that governs
1864
- the archive: `judge-round` records carry no `rateSource` (the judge cost payload,
1865
- `src/kinds.js` `onJudgeCost`, does not forward it, so a judge round is never counted as vouched,
1866
- priced by you or not), and so does every round archived before the signal existed. The native
1863
+ (the guess). `judge-round` records carry it too (the judge Loop's per-call label, forwarded by
1864
+ `src/kinds.js` `onJudgeCost`): `'caller'` under your price on the judge's row, else the guess label. The
1865
+ exceptions read UNKNOWN provenance, correctly, and by the same rule that governs the archive: every
1866
+ round archived before the signal existed (judge rounds before this change included). The native
1867
1867
  per-turn `worker-turn` carries `null`, bareloop's OWN statement rather than a forwarded one: that
1868
1868
  surface prices the SESSION, so a turn had no rate to guess. A `worker-round`/`worker-turn`
1869
1869
  carries bare-agent's own label beside `pricing`:
@@ -3165,6 +3165,9 @@ monthly limit, your price and the key-row choice apply exactly as on the sub-com
3165
3165
  got none, and a run started from the menu skipped the limit). All three are dispatched by name only: `bareloop run-u <flags…>`,
3166
3166
  `bareloop interview <flags…>`, `bareloop author <flags…>`.
3167
3167
 
3168
+ When two key rows share one provider and endpoint, a CLI run uses the FIRST row in the keys list, so put the
3169
+ key you want first (the panel picks the row by the Model menu).
3170
+
3168
3171
  - **`bareloop export <jobs/x.json> --registry <dir> --out <dir>`** → resolves the spec's
3169
3172
  close-script paths against the spec file's own directory (never the process cwd), calls
3170
3173
  `exportBundle`. On success prints `bundleHash:`, the sorted file list, and "this bundle is
@@ -3205,7 +3208,8 @@ got none, and a run started from the menu skipped the limit). All three are disp
3205
3208
  `--budget`/`--wall`, only if you passed them (a tightened ceiling never silently reverts to the signed
3206
3209
  one). After a MONEY halt the line does NOT repeat the exhausted `--budget` (chain spend is folded, so
3207
3210
  it would halt again at once); it prints `--budget <more than $<spent so far>, at most $<signed
3208
- budgetUsd>>` for you to fill in, and is not runnable until you do. A bundle's spec is
3211
+ budgetUsd>>` for you to fill in, and is not runnable until you do. When the run spent the WHOLE signed
3212
+ ceiling, that range is empty, so no resume line is printed: the block says no room is left under the signed ceiling. A bundle's spec is
3209
3213
  manifest-hashed, so its halt hint never asks you to edit `budgetUsd`.
3210
3214
  6. `resolveBundleSpec` (the `$BARELOOP_BUNDLE` substitution) → tighten `budgetUsd`/`maxWallMs`
3211
3215
  in memory if `--budget`/`--wall` were given → `approveHash = jobSpecHash(that tightened+
@@ -3325,6 +3329,8 @@ got none, and a run started from the menu skipped the limit). All three are disp
3325
3329
  `baselineKind`/`question`: the plain-English question is derived in `src/panel/server.js`
3326
3330
  (`stageQuestionText`) from the signed stage's kind/params only, `null` when no honest
3327
3331
  wording exists (the client then shows the stage's own name). Every
3332
+ GET/HEAD answers only when its `Host` header is the panel's own `127.0.0.1:<port>` (`checkHostGuard`,
3333
+ `src/panel/authorroutes.js`; any other Host is `403`, no token needed to read). Every
3328
3334
  P1 endpoint is GET/HEAD only (anything else — including every write verb — is `405`, outside the
3329
3335
  two human-click-guarded write families `/api/author/*` and `/api/settings/*` below); a URL
3330
3336
  never joins a path segment into a filesystem read — a runid is looked up in the run list
@@ -3370,7 +3376,8 @@ got none, and a run started from the menu skipped the limit). All three are disp
3370
3376
  requires the human-click guard: an `x-bareloop-token` header matching a fresh token minted
3371
3377
  once per server start (templated into `index.html` like the port) AND an Origin/Host
3372
3378
  naming this exact `127.0.0.1:<port>` — a request failing either gets `403`, before the
3373
- route body ever runs. `POST /api/author/start` (one job-card body; `409` while a prior
3379
+ route body ever runs. The token protects against other browser origins, not against other local users or
3380
+ processes on the same machine (local-trust model). `POST /api/author/start` (one job-card body; `409` while a prior
3374
3381
  session is still non-terminal — one authoring session at a time) creates a session
3375
3382
  (`src/panel/authorsession.js`) that runs `prepareSource`/`detectLanguage`/`validateJob`/
3376
3383
  `authorCloseForJob`/`assembleSpec`/`prepareSigning` in-process, exactly the library calls
@@ -3440,7 +3447,7 @@ got none, and a run started from the menu skipped the limit). All three are disp
3440
3447
  mechanically, $0, via `prepareSource`/`proveDestination`), then the picked class's frozen
3441
3448
  question set (`questionsFor`/`requiredAnswersFor`, `src/authorjob.js`), then the operator's
3442
3449
  own job name/budget/wall. On success it writes `answers.json`/`specdraft.json` under `--out`
3443
- and either offers to spawn `scripts/run-author.mjs` (only when a clean key is present and no
3450
+ and either offers to spawn the packaged `bin/bareloop.mjs author` (same flags as `scripts/run-author.mjs`, which the package does not ship; only when a clean key is present and no
3444
3451
  install gap remains — default answer is no) or prints the exact command to run it later.
3445
3452
  Because this module reads a real TTY/piped stdin, `src/cli.js` hands it `stdin`/`stdout`/
3446
3453
  `stderr` (the raw streams, not the wrapped `out`/`err` line-functions every other command
@@ -3559,7 +3566,7 @@ provider its own row, for the worker's provider the row on the worker's endpoint
3559
3566
  (The judge's KEY still follows the worker's row, so with two rows on one endpoint key and price can differ.) A priced round is stamped `rateSource:'caller'` (VOUCHED, see `spendProvenance`); with
3560
3567
  no price it stays the built-in guess, as before. The run preview and the run tail print
3561
3568
  `price yours: in $0.006 / out $1.20 per 1M tokens (DEEPSEEK_API_KEY row)` when a price is set, and
3562
- nothing when none is. **The price is yours, and it sets what the cap means:** the cap and the halts run
3569
+ nothing when none is; a 0 / 0 price is accepted but the line then ends `— WARNING: this price is $0, so spend and limits read $0`. **The price is yours, and it sets what the cap means:** the cap and the halts run
3563
3570
  on the dollars your price produces, so a price set too low weakens the cap (a run can do far more
3564
3571
  than you meant before it halts). When a vendor lists two prices (for example a cache-hit and a
3565
3572
  cache-miss input rate) enter the higher one. bare-agent applies its own cache multipliers (read 0.1×,
@@ -3570,8 +3577,9 @@ re-priced** (a spine and the run list keep the dollars they booked). Not covered
3570
3577
  resolves no key row.
3571
3578
 
3572
3579
  **Monthly $ limit.** `checkMonthlyRoom({ capUsd, home, now })` → `{ ok, leftUsd, limitUsd,
3573
- atLeast }`; `monthlyRefusalText(room)` is the ONE spelling: `Max $<left, 2 decimals> (monthly
3574
- limit)`. It is called at the run-start seam in `src/userrun.js` `execute` (before any token
3580
+ atLeast, heldUsd, heldRuns }`; `monthlyRefusalText(room)` is the ONE spelling: `Max $<left, 2 decimals>
3581
+ (monthly limit)`, and when run(s) in progress hold money beyond what they have spent, the same line
3582
+ reads `Max $4.00 (monthly limit, $5.95 held by a run in progress)` (`runs` when more than one). It is called at the run-start seam in `src/userrun.js` `execute` (before any token
3575
3583
  spends; the CLI prints it to stderr, exit 2) and at the panel's Sign & run (`signRun` in
3576
3584
  `src/panel/authorroutes.js`, refused server-side whatever the page shows). The page only echoes the
3577
3585
  same text under `#jf-cap-money` via `GET /api/author/monthly-check?cap=`. "This month" is the local
@@ -3584,17 +3592,17 @@ records only. `claimRun` (`src/monthly.js`) writes the run's row FIRST (it carri
3584
3592
  its pid is a live bareloop runner (`isLiveRunner`, `src/runlist.js`, also what `--resume` uses to refuse a
3585
3593
  resume against a live run: the process is a node executable with a `bareloop`/`bareloop.mjs`/`run-u.mjs`/
3586
3594
  `u-watchdog.mjs` argument, or is itself named so — a recycled pid running some other program never holds a
3587
- cap, though a recycled node program with such an argument still would), however quiet its spine; a claim whose process is gone is closed by whoever
3595
+ cap, though a recycled node program with such an argument still would), however quiet its spine (`runIsAlive`, `src/runlist.js`, is the one "is this run alive" rule: the panel's [▶] / [?] glyph reads it too; `--resume` also refuses on the pid in the predecessor's own run-list row, not only the watchdog's record); a claim whose process is gone is closed by whoever
3588
3596
  finds it, with a note (`{runid, type:'settled', by:<finder>, reason:'process gone', spentUsd:<floor>}`) and
3589
3597
  counts its real spend. If the sum fits, the run goes; if not, it appends `{type:'released',
3590
3598
  reason:'refused'}` and refuses with the exact text, nothing spent. A run whose row is listed (claimed, or with no limit set) that then exits at $0 before its spine has a first record (a refused
3591
- `--resume` patient, a failing tree setup) appends `{type:'released', reason:'not started'}` too, so it leaves no row. A row that cannot be written, or an
3599
+ `--resume` patient, a failing tree setup) appends `{type:'released', reason:'not started'}` too, so it leaves no row; so does a $0 refusal after the spine exists (`SOURCE-MANIFEST-RED`, `DESTINATION-RED`, `CLOSE-TIMING-RED`). A row that cannot be written, or an
3592
3600
  unreadable config, refuses too — never "no limit". When a run ends it appends its own
3593
3601
  `{runid, type:'settled', by:<runid>, spentUsd, spendComplete, at}`. Two runs may close the same dead claim;
3594
3602
  the FIRST note for a runid is authoritative. `readRunList` folds these entries out of the run rows
3595
3603
  (`events`; a `released` claim's row is not listed). Month totals still read real spend off the spines. An
3596
3604
  older row with no `pid` keeps the spine-mtime rule (`DIED_MTIME_MS`: a spine written to within it is in
3597
- flight, at its leg cap). A run refused because an earlier claim was itself later refused is the accepted
3605
+ flight, at its leg cap). The refusal check parses only the spines of runs listed in the current local month (plus any claim still held by a live runner); the all-time readers still read every run. A run refused because an earlier claim was itself later refused is the accepted
3598
3606
  safe-side cost of doing without a lock.
3599
3607
  Only the refusal reserves the cap (`monthSpend().reservedUsd`); the Money tab's month figure is
3600
3608
  real spend.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bareloop",
3
- "version": "0.33.0",
3
+ "version": "0.34.0",
4
4
  "description": "Workflows that earn their own design, with receipts — agent-authored workflows for repeated, long, verifiable jobs that improve across runs under an un-gameable gate.",
5
5
  "type": "module",
6
6
  "main": "./src/index.js",
@@ -722,7 +722,8 @@ export async function main(argv, deps = {}) {
722
722
  }
723
723
 
724
724
  // ── 6. the paid step, which is a DIFFERENT process under a DIFFERENT ceiling ─
725
- const RUN_AUTHOR = fileURLToPath(new URL('../scripts/run-author.mjs', import.meta.url));
725
+ // the PACKAGED door (`bin/` ships; `scripts/` does not): `bareloop author` takes the same flags as run-author
726
+ const BARELOOP_BIN = fileURLToPath(new URL('../bin/bareloop.mjs', import.meta.url));
726
727
  const childArgs = [
727
728
  '--source', TREE, '--answers', answersFile, '--draft', draftFile,
728
729
  '--verdict', VERDICT, '--out', OUT,
@@ -832,7 +833,7 @@ export async function main(argv, deps = {}) {
832
833
  exitCode = 0;
833
834
  } else {
834
835
  say('');
835
- const child = spawnSyncFn(process.execPath, [RUN_AUTHOR, ...childArgs], { stdio: 'inherit' });
836
+ const child = spawnSyncFn(process.execPath, [BARELOOP_BIN, 'author', ...childArgs], { stdio: 'inherit' });
836
837
  if (child.error) {
837
838
  say(`run-author could not be started (${child.error.message}) — the command above still stands.`);
838
839
  exitCode = 2;
package/src/job.js CHANGED
@@ -289,8 +289,8 @@ export function validateJob(input, { shellCapUsd = 2 } = {}) {
289
289
  }
290
290
 
291
291
  if (spec.budgetUsd === undefined) red('missing-required', 'budgetUsd');
292
- else if (!(typeof spec.budgetUsd === 'number' && spec.budgetUsd > 0 && spec.budgetUsd <= shellCapUsd)) {
293
- red('bounds', 'budgetUsd', `0 < budget <= shell cap ${shellCapUsd} (cap-not-estimate; no self-adjusted budgets, ever)`);
292
+ else if (!(typeof spec.budgetUsd === 'number' && Number.isFinite(spec.budgetUsd) && spec.budgetUsd > 0 && spec.budgetUsd <= shellCapUsd)) {
293
+ red('bounds', 'budgetUsd', `a finite 0 < budget <= shell cap ${shellCapUsd} (cap-not-estimate; no self-adjusted budgets, ever)`);
294
294
  }
295
295
 
296
296
  // T (PRD v1.27/v1.29) — TIME, with exactly budgetUsd's status: operator input,
package/src/judged.js CHANGED
@@ -793,10 +793,28 @@ function topLevelFunctionNames(artifactText) {
793
793
  * so the priced seam is the only legal one.
794
794
  * `rates` is the customer's own price for the judge's row (USD per 1K, `ratesFor`'s
795
795
  * `rates`); null/absent = bare-agent's built-in guess, exactly as before.
796
- * @param {{provider: any, system: string, rates?: {in: number, out: number}|null}} o */
796
+ * @param {{provider: any, system: string, rates?: {in: number, out: number}|null}} o
797
+ * @returns {{run: (msgs: any[], tools: any[], opts?: any) => Promise<any>}} */
797
798
  export const defaultJudgeLoop = ({ provider, system, rates = null }) => {
798
799
  const { Loop } = require('bare-agent');
799
- return new Loop({ provider, system, ...(rates ? { rates } : {}) });
800
+ // BA-21: the Loop's run RESULT carries no `rateSource` — only the per-call metering event
801
+ // does (`onLlmResult`). Capture it there and ride it on the result, so `runLocate` can
802
+ // forward it to the judge-round record beside `costUsd`. Absent (no round metered) = no key.
803
+ /** @type {{rateSource: string|null}|null} */
804
+ let seen = null;
805
+ const loop = new Loop({
806
+ provider, system, ...(rates ? { rates } : {}),
807
+ onLlmResult: (/** @type {{rateSource?: string|null}} */ m) => {
808
+ if (m && typeof m === 'object' && 'rateSource' in m) seen = { rateSource: m.rateSource ?? null };
809
+ },
810
+ });
811
+ return {
812
+ run: async (/** @type {any[]} */ msgs, /** @type {any[]} */ tools, /** @type {any} */ opts) => {
813
+ seen = null;
814
+ const res = await loop.run(msgs, tools, opts);
815
+ return res && typeof res === 'object' && seen ? { ...res, rateSource: /** @type {any} */ (seen).rateSource } : res;
816
+ },
817
+ };
800
818
  };
801
819
 
802
820
  // ── LOCATE: one attempt ─────────────────────────────────────────────────────
@@ -821,7 +839,7 @@ export const defaultJudgeLoop = ({ provider, system, rates = null }) => {
821
839
  * On every route out, red or clean, `onCost` fires exactly once with the honest
822
840
  * read: a paid call that leaves no meter record is F12 wearing a judge's coat.
823
841
  * @param {{artifactText: string, card: any, loopFactory: (o: {provider?: any, system: string}) => any,
824
- * maxTokens?: number, attempt?: number, onCost?: (c: {costUsd: number|null, unpricedRounds: number}) => void,
842
+ * maxTokens?: number, attempt?: number, onCost?: (c: {costUsd: number|null, unpricedRounds: number, rateSource?: string|null}) => void,
825
843
  * callBounds?: {timeoutMs?: number, deadlineMs?: number}}} o
826
844
  * @returns {Promise<{ok: boolean, facts: any|null, red: {axis: string, detail: string}|null,
827
845
  * costUsd: number|null, unpricedRounds: number, truncated: boolean, parseError: boolean,
@@ -852,7 +870,9 @@ export async function runLocate({
852
870
  /** @param {{axis: string, detail: string}|null} red @param {any} facts @param {any} r @param {boolean} truncated @param {boolean} parseError */
853
871
  const out = (red, facts, r, truncated, parseError) => {
854
872
  const { costUsd, unpricedRounds } = r === null ? { costUsd: null, unpricedRounds: 0 } : priceOf(r);
855
- onCost({ costUsd, unpricedRounds });
873
+ // BA-21: the round's provenance rides beside the cost when the loop carried it (see
874
+ // `defaultJudgeLoop`); a loop that never metered a rate adds no key.
875
+ onCost({ costUsd, unpricedRounds, ...(r && typeof r === 'object' && 'rateSource' in r ? { rateSource: r.rateSource } : {}) });
856
876
  return {
857
877
  ok: red === null, facts, red, costUsd, unpricedRounds, truncated, parseError,
858
878
  raw: scrubRaw({
package/src/kinds.js CHANGED
@@ -1871,6 +1871,7 @@ async function runJudgedFloor(stage, ctx) {
1871
1871
  ctx.onJudgeCost({
1872
1872
  stage: stage?.name ?? null, kind: stage?.kind ?? null, path: rel, attempt,
1873
1873
  label: LOCATE_LABEL, model: judgeModel, costUsd: c.costUsd, unpricedRounds: c.unpricedRounds,
1874
+ ...('rateSource' in c ? { rateSource: c.rateSource } : {}),
1874
1875
  });
1875
1876
  },
1876
1877
  });
package/src/ledger.js CHANGED
@@ -410,10 +410,9 @@ export function updateLedger({ ledgerFile, spineFiles }) {
410
410
  // and the plan flow forwards it verbatim onto `worker-round`/`judge-round`/`worker-turn`
411
411
  // (`rateSourceFields`, src/planrun.js — the write side).
412
412
  //
413
- // NAMED rather than papered over: `judge-round`'s own payload (src/kinds.js's
414
- // `onJudgeCost({...})`) does not carry `rateSource` yet, so every judge round reads
415
- // UNKNOWN provenance today — correctly, by the same rule that governs the archive, and
416
- // the day that payload forwards the field the emit site already spreads it.
413
+ // `judge-round`'s payload (src/kinds.js's `onJudgeCost({...})`) forwards `rateSource` from the
414
+ // judge Loop's per-call metering (`defaultJudgeLoop`, src/judged.js); a judge round whose loop
415
+ // carried none — and every judge round archived before this — reads UNKNOWN, by the archive rule.
417
416
  //
418
417
  // `pricing` is UNCHANGED and still strictly two-valued ('priced'|'unpriced'). Nothing
419
418
  // below reads it, nothing below decides anything, and no new escalation category exists:
package/src/monthly.js CHANGED
@@ -14,16 +14,16 @@
14
14
  // run is counted at max(spend so far, capUsd) however quiet its spine; a run with a `job-end` counts
15
15
  // its real spend; a claim whose process is gone counts its floor and is settled — with an attributed
16
16
  // `settled` entry — by whichever later run finds it (the first note wins). An older row with no `pid` keeps the spine
17
- // mtime rule (a fresh spine, written to within DIED_MTIME_MS, is in flight). Only the refusal check
17
+ // mtime rule (a fresh spine, written to within DIED_MTIME_MS, is in flight) — `runIsAlive`, src/runlist.js, the
18
+ // one "is this run alive" rule the panel's glyph shares. Only the refusal check
18
19
  // reserves; the Money tab keeps showing real spend (`usd`).
19
20
  //
20
21
  // Honesty: a run whose spend is not fully known (a died/still-running spine, an unpriced
21
22
  // round, a missing spine file) makes the month total an "at least" figure — never a clean
22
23
  // number. The refusal text stays exactly `Max $X (monthly limit)`; `atLeast` travels beside it.
23
- import { existsSync, statSync } from 'node:fs';
24
+ import { existsSync } from 'node:fs';
24
25
  import { readConfig, configPath, ConfigError } from './config.js';
25
- import { readRunList, appendRun, appendRunEvent, isLiveRunner, DIED_MTIME_MS } from './runlist.js';
26
- export { isLiveRunner };
26
+ import { readRunList, appendRun, appendRunEvent, isLiveRunner, runIsAlive } from './runlist.js';
27
27
  import { parseJsonl } from './replayio.js';
28
28
  import { SPEND_RECORD_TYPES, spendProvenance, floorsFromRecords } from './ledger.js';
29
29
  import { findRow } from './providerrows.js';
@@ -141,23 +141,21 @@ export function legTokens(records) {
141
141
 
142
142
  /**
143
143
  * What the refusal check counts for one run: its spend, unless it is IN FLIGHT (`job-start`, no
144
- * `job-end`, spine written to within DIED_MTIME_MS of `nowMs`), when it is the larger of that
144
+ * `job-end`, `runIsAlive` for a row with no pid — the pid-bearing rows are held by `readLegs`), when it is the larger of that
145
145
  * spend and the leg's own cap — the signed `budgetUsd` on the spine's `job-start` less the fold
146
146
  * a resumed leg inherited (`priorSpentUsd`), the same figure the run-start seam computes
147
147
  * (`legCapUsd`, src/userrun.js). A spine that records no `budgetUsd` reserves nothing extra.
148
148
  * @param {any[]} records
149
- * @param {string} spinePath
149
+ * @param {import('./runlist.js').RunRow} row
150
150
  * @param {number} nowMs
151
151
  * @param {number} usd the leg's spend so far
152
152
  * @returns {number}
153
153
  */
154
- function inFlightCapUsd(records, spinePath, nowMs, usd) {
154
+ function inFlightCapUsd(records, row, nowMs, usd) {
155
155
  if (records.some((r) => r && r.type === 'job-end')) return usd;
156
156
  const start = records.find((r) => r && r.type === 'job-start') ?? null;
157
157
  if (!start || !(typeof start.budgetUsd === 'number' && Number.isFinite(start.budgetUsd))) return usd;
158
- let mtimeMs;
159
- try { mtimeMs = statSync(spinePath).mtimeMs; } catch { return usd; }
160
- if (nowMs - mtimeMs > DIED_MTIME_MS) return usd; // stale — died, counts its floor
158
+ if (!runIsAlive(row, nowMs)) return usd; // stale — died, counts its floor
161
159
  const fold = typeof start.priorSpentUsd === 'number' && Number.isFinite(start.priorSpentUsd) ? start.priorSpentUsd : 0;
162
160
  return Math.max(usd, Math.max(0, start.budgetUsd - fold));
163
161
  }
@@ -178,7 +176,7 @@ function rowsAbove(rows, runid) {
178
176
  /**
179
177
  * Every listed run as one {@link Leg}. The ONE reader the month total, the all-time total
180
178
  * and the per-provider figures share.
181
- * @param {{ home?: string, now?: () => number, aboveRunid?: string }} [opts] `now` = the clock for the no-pid in-flight test; `aboveRunid` = read only the runs listed EARLIER in the file than that run (a claim yields to the claims above it, never below)
179
+ * @param {{ home?: string, now?: () => number, aboveRunid?: string, thisMonthOnly?: boolean }} [opts] `now` = the clock for the no-pid in-flight test; `aboveRunid` = read only the runs listed EARLIER in the file than that run (a claim yields to the claims above it, never below); `thisMonthOnly` = do not parse the spine of a run listed in another local month (the refusal check drops it anyway) unless its claim is still held
182
180
  * @returns {Leg[]}
183
181
  */
184
182
  export function readLegs(opts = {}) {
@@ -195,7 +193,8 @@ export function readLegs(opts = {}) {
195
193
  // pid is a live bareloop runner, however quiet its spine — killed = process gone, not silence.
196
194
  // An older row with no `pid` keeps the spine-mtime rule (`inFlightCapUsd`), never guessed alive.
197
195
  const hasPid = Number.isInteger(row.pid);
198
- const heldCap = hasPid && !settled.has(row.runid) && typeof row.capUsd === 'number' && Number.isFinite(row.capUsd) && isLiveRunner(/** @type {number} */ (row.pid)) ? row.capUsd : null;
196
+ const heldCap = hasPid && !settled.has(row.runid) && typeof row.capUsd === 'number' && Number.isFinite(row.capUsd) && runIsAlive(row, nowMs) ? row.capUsd : null;
197
+ if (opts.thisMonthOnly && heldCap === null && !Number.isNaN(at.getTime()) && !sameLocalMonth(at, new Date(nowMs))) continue;
199
198
  if (Number.isNaN(at.getTime()) || !existsSync(row.spine)) { legs.push({ ...base, reservedUsd: heldCap ?? 0 }); continue; }
200
199
  /** @type {any[]} */
201
200
  let records;
@@ -217,7 +216,7 @@ export function readLegs(opts = {}) {
217
216
  vouchedRounds: prov.vouched.rounds,
218
217
  otherRounds: prov.guessed.rounds + prov.unpriced.rounds + prov.unknown.rounds,
219
218
  reservedUsd: hasPid ? (records.some((r) => r && r.type === 'job-end') || heldCap === null ? leg.usd : Math.max(leg.usd, heldCap))
220
- : inFlightCapUsd(records, row.spine, nowMs, leg.usd),
219
+ : inFlightCapUsd(records, row, nowMs, leg.usd),
221
220
  unreadable: false,
222
221
  });
223
222
  }
@@ -233,7 +232,7 @@ const sameLocalMonth = (at, now) => at.getFullYear() === now.getFullYear() && at
233
232
 
234
233
  /**
235
234
  * @param {{ home?: string, now?: () => number, aboveRunid?: string }} [opts] `aboveRunid`: see {@link readLegs}
236
- * @returns {{ usd: number, atLeast: boolean, runs: number, reservedUsd: number }} `usd` = real spend (the Money tab's figure); `reservedUsd` = the same with in-flight runs at their cap (the refusal check's)
235
+ * @returns {{ usd: number, atLeast: boolean, runs: number, reservedUsd: number, heldUsd: number, heldRuns: number }} `usd` = real spend (the Money tab's figure); `reservedUsd` = the same with in-flight runs at their cap (the refusal check's); `heldUsd` = what in-progress runs set aside beyond what they have spent (`reservedUsd - usd`), over `heldRuns` runs
237
236
  */
238
237
  export function monthSpend(opts = {}) {
239
238
  const nowDate = new Date((opts.now ?? Date.now)());
@@ -241,17 +240,20 @@ export function monthSpend(opts = {}) {
241
240
  let reservedUsd = 0;
242
241
  let atLeast = false;
243
242
  let runs = 0;
244
- for (const leg of readLegs({ home: opts.home, now: opts.now, aboveRunid: opts.aboveRunid })) {
243
+ let heldUsd = 0;
244
+ let heldRuns = 0;
245
+ for (const leg of readLegs({ home: opts.home, now: opts.now, aboveRunid: opts.aboveRunid, thisMonthOnly: true })) {
245
246
  // an unreadable date could belong to this month — unknown, never dropped
246
247
  if (Number.isNaN(leg.at.getTime())) { atLeast = true; continue; }
247
248
  if (!sameLocalMonth(leg.at, nowDate)) continue;
248
249
  runs += 1;
249
- if (leg.unreadable) { atLeast = true; reservedUsd += leg.reservedUsd; continue; }
250
+ if (leg.unreadable) { atLeast = true; reservedUsd += leg.reservedUsd; if (leg.reservedUsd > 0) { heldUsd += leg.reservedUsd; heldRuns += 1; } continue; }
250
251
  usd += leg.usd;
251
252
  reservedUsd += leg.reservedUsd;
253
+ if (leg.reservedUsd > leg.usd) { heldUsd += leg.reservedUsd - leg.usd; heldRuns += 1; }
252
254
  if (!leg.complete) atLeast = true;
253
255
  }
254
- return { usd, atLeast, runs, reservedUsd };
256
+ return { usd, atLeast, runs, reservedUsd, heldUsd, heldRuns };
255
257
  }
256
258
 
257
259
  /**
@@ -323,6 +325,8 @@ export function spendSummary(opts = {}) {
323
325
  * @property {number|null} leftUsd what is left this month (null = no limit set)
324
326
  * @property {number|null} limitUsd the person's monthly limit (null = none)
325
327
  * @property {boolean} atLeast the month's spend is a floor, so `leftUsd` is a ceiling
328
+ * @property {number} [heldUsd] what run(s) in progress have set aside beyond their spend so far (present when a limit is set)
329
+ * @property {number} [heldRuns] how many runs hold it
326
330
  */
327
331
 
328
332
  /**
@@ -358,18 +362,21 @@ export function checkMonthlyRoom({ capUsd, home, now, aboveRunid }) {
358
362
  }
359
363
  const spent = monthSpend({ home, now, aboveRunid });
360
364
  const leftCents = Math.max(0, Math.floor((limit - spent.reservedUsd) * 100 + 1e-6));
361
- const capCents = Math.ceil((Number.isFinite(capUsd) ? capUsd : 0) * 100 - 1e-6);
362
- return { ok: capCents <= leftCents, leftUsd: leftCents / 100, limitUsd: limit, atLeast: spent.atLeast };
365
+ const capCents = Math.ceil(capUsd * 100 - 1e-6); // a non-finite cap never fits (NaN/Infinity compare false) — never read as $0
366
+ return { ok: capCents <= leftCents, leftUsd: leftCents / 100, limitUsd: limit, atLeast: spent.atLeast, heldUsd: spent.heldUsd, heldRuns: spent.heldRuns };
363
367
  }
364
368
 
365
369
  /**
366
- * The ONE refusal text, everywhere (CLI, panel cap note, panel Sign refusal).
370
+ * The ONE refusal text, everywhere (CLI, panel cap note, panel Sign refusal). When run(s) in progress hold
371
+ * part of the limit, the same line says how much.
367
372
  * @param {MonthlyRoom} room
368
373
  * @returns {string|null} null when the run fits
369
374
  */
370
375
  export function monthlyRefusalText(room) {
371
376
  if (room.ok || room.leftUsd === null) return null;
372
- return `Max $${room.leftUsd.toFixed(2)} (monthly limit)`;
377
+ const max = `Max $${room.leftUsd.toFixed(2)}`;
378
+ if (!room.heldRuns || !room.heldUsd || room.heldUsd <= 0) return `${max} (monthly limit)`;
379
+ return `${max} (monthly limit, $${room.heldUsd.toFixed(2)} held by ${room.heldRuns > 1 ? 'runs' : 'a run'} in progress)`;
373
380
  }
374
381
 
375
382
  /**
@@ -34,6 +34,17 @@ export function mintToken() {
34
34
  return randomBytes(24).toString('hex');
35
35
  }
36
36
 
37
+ /**
38
+ * The ONE Host check: the request names this panel's own bind address. A page on another
39
+ * origin reaching the panel through a rebound DNS name sends that name as Host, so it fails here.
40
+ * @param {import('node:http').IncomingMessage} req
41
+ * @param {{port: number}} o
42
+ * @returns {{ok: true}|{ok: false, reason: string}}
43
+ */
44
+ export function checkHostGuard(req, { port }) {
45
+ return req.headers.host === `127.0.0.1:${port}` ? { ok: true } : { ok: false, reason: 'wrong Host' };
46
+ }
47
+
37
48
  /**
38
49
  * `true` when a request may reach ANY route in this file — the token header
39
50
  * plus an Origin/Host that names this exact bind address. `req.headers`
@@ -48,10 +59,9 @@ export function checkHumanGuard(req, { token, port }) {
48
59
  if (got !== token) return { ok: false, reason: 'missing or wrong token' };
49
60
  const want = `127.0.0.1:${port}`;
50
61
  const origin = req.headers.origin;
51
- const host = req.headers.host;
52
62
  if (typeof origin === 'string' && origin.length > 0) {
53
63
  if (origin !== `http://${want}` && origin !== `https://${want}`) return { ok: false, reason: 'wrong Origin' };
54
- } else if (host !== want) {
64
+ } else if (!checkHostGuard(req, { port }).ok) {
55
65
  return { ok: false, reason: 'wrong Host (and no Origin header)' };
56
66
  }
57
67
  return { ok: true };
@@ -25,12 +25,12 @@
25
25
  // for every cap/threshold: a shell never widens what it was asked to do).
26
26
 
27
27
  import { createServer } from 'node:http';
28
- import { readFileSync, existsSync, statSync } from 'node:fs';
28
+ import { readFileSync, existsSync } from 'node:fs';
29
29
  import {
30
30
  dirname, join, basename, relative, isAbsolute, sep,
31
31
  } from 'node:path';
32
32
  import { fileURLToPath } from 'node:url';
33
- import { readRunList, DIED_MTIME_MS } from '../runlist.js';
33
+ import { readRunList, DIED_MTIME_MS, runIsAlive } from '../runlist.js';
34
34
  import {
35
35
  replayOne, parseJsonl, resolveSiblings, isSidecarByName,
36
36
  } from '../replayio.js';
@@ -39,7 +39,7 @@ import { runBehaviour } from '../behaviour.js';
39
39
  import { SPEND_RECORD_TYPES, floorsFromRecords } from '../ledger.js';
40
40
  import { jobSpecHash } from '../job.js';
41
41
  import { confirmProtections } from '../authorflow.js';
42
- import { createAuthorRoutes, mintToken } from './authorroutes.js';
42
+ import { createAuthorRoutes, mintToken, checkHostGuard } from './authorroutes.js';
43
43
  import { createSettingsRoutes } from './settingsroutes.js';
44
44
 
45
45
  const HERE = dirname(fileURLToPath(import.meta.url));
@@ -137,9 +137,9 @@ export function checkTypeTitle(verdictType, atIso) {
137
137
  return null;
138
138
  }
139
139
 
140
- // DIED (hamr's ruling B, 2026-09-25): a run with no `job-end` is `died` [?], never [✗], once its
141
- // spine file's mtime is older than DIED_MTIME_MS — the constant and its full comment live in
142
- // src/runlist.js (the monthly limit reads the same rule); re-exported here for the panel's callers.
140
+ // DIED (hamr's ruling B, 2026-09-25): a run with no `job-end` is `died` [?], never [✗], once nothing is
141
+ // running it — `runIsAlive` (src/runlist.js: the row's pid first, the spine-mtime rule DIED_MTIME_MS only
142
+ // for a row with no pid; the monthly limit reads the same rule); the constant is re-exported here for the panel's callers.
143
143
  export { DIED_MTIME_MS };
144
144
 
145
145
  /**
@@ -231,12 +231,12 @@ export function formatTimestamp(ts) {
231
231
  * fields, which assume a `job-end` exists) — the ONE owner of this
232
232
  * derivation, used by every caller below (`/api/runs`, `/api/runs/:id`) so
233
233
  * the two never drift apart.
234
- * @param {string} spinePath
234
+ * @param {import('../runlist.js').RunRow} row the run's listed row (its pid, else its spine's mtime, says whether it is still running)
235
235
  * @param {any[]} records raw parsed spine records (already read once by the caller)
236
236
  * @param {string|null} outcome `replayRun`'s own `summary.outcome`
237
237
  * @returns {{died: boolean, why: string|null, spendFloorUsd: number|null, wallFloorMs: number|null}}
238
238
  */
239
- function deriveDeath(spinePath, records, outcome) {
239
+ function deriveDeath(row, records, outcome) {
240
240
  const notDied = {
241
241
  died: false, why: null, spendFloorUsd: null, wallFloorMs: null,
242
242
  };
@@ -245,9 +245,7 @@ function deriveDeath(spinePath, records, outcome) {
245
245
  // out to be a died run or one that is genuinely still running (build item
246
246
  // 6): computed here, once, before the died/still-running branch below.
247
247
  const floors = floorsFromRecords(records);
248
- let mtimeMs;
249
- try { mtimeMs = statSync(spinePath).mtimeMs; } catch { return { ...notDied, ...floors }; }
250
- if (Date.now() - mtimeMs <= DIED_MTIME_MS) return { ...notDied, ...floors }; // still fresh — genuinely `running`, not died
248
+ if (runIsAlive(row)) return { ...notDied, ...floors }; // something is still running it — genuinely `running`, not died
251
249
 
252
250
  const withTs = records.filter((r) => r && typeof r === 'object' && typeof r.ts === 'string');
253
251
  const last = withTs.length ? withTs[withTs.length - 1] : null;
@@ -300,7 +298,7 @@ function summarizeRow(row) {
300
298
  };
301
299
  }
302
300
  const line = summarizeForAllLine(summary);
303
- const death = deriveDeath(row.spine, rawRecords, summary.outcome);
301
+ const death = deriveDeath(row, rawRecords, summary.outcome);
304
302
  return {
305
303
  runid: row.runid,
306
304
  job: row.job,
@@ -395,7 +393,7 @@ export function getRunDetail(runid, opts = {}) {
395
393
  const auditPath = resolveAuditPathForRow(row, rawSpineRecords);
396
394
  const summary = replayOne(row.spine, { auditPathOverride: auditPath });
397
395
  const timelineKind = summary.timelineKind;
398
- const death = deriveDeath(row.spine, rawSpineRecords, summary.outcome);
396
+ const death = deriveDeath(row, rawSpineRecords, summary.outcome);
399
397
  // judgeModel (Summary box "judge:" line): the FIRST `judge-round`'s own
400
398
  // `model` field (src/planrun.js:1704's `onJudgeCost` emit) — a soft-green
401
399
  // close's own paid judge seam, distinct from the worker `model` above.
@@ -1746,6 +1744,13 @@ export function handleRequest(req, res, opts) {
1746
1744
  }
1747
1745
  const { pathname } = url;
1748
1746
 
1747
+ // every read names the panel's own address as Host — no token needed to read, but another origin's
1748
+ // page (a rebound DNS name) must not be able to fetch the run list
1749
+ if ((method === 'GET' || method === 'HEAD') && !checkHostGuard(req, { port: opts.port }).ok) {
1750
+ sendText(res, 403, 'wrong Host — this panel answers only at its own 127.0.0.1 address');
1751
+ return;
1752
+ }
1753
+
1749
1754
  if (method !== 'GET' && method !== 'HEAD') {
1750
1755
  const routes = routesFor(opts, pathname);
1751
1756
  if (routes) {
package/src/planrun.js CHANGED
@@ -1708,11 +1708,9 @@ export async function runPlan(job, { workdir, provider, nativeProvider, provider
1708
1708
  stage: c.stage, path: c.path, attempt: c.attempt, label: c.label, model: c.model,
1709
1709
  costUsd: c.costUsd, unpricedRounds: c.unpricedRounds,
1710
1710
  // BA-21 provenance, through the SAME helper the worker rounds use — this record
1711
- // is on `spendProvenance`'s read list (src/ledger.js), so a judge-round that
1712
- // never carried the field would read UNKNOWN forever inside the readout built to
1713
- // expose exactly this seam. NAMED, not papered over: the judge cost payload
1714
- // (src/kinds.js `onJudgeCost({...})`) does not carry `rateSource` today, so it
1715
- // still reads UNKNOWN — correctly — until that payload forwards it too.
1711
+ // is on `spendProvenance`'s read list (src/ledger.js). The judge cost payload
1712
+ // (src/kinds.js `onJudgeCost`) forwards the field when the judge Loop carried it
1713
+ // (`defaultJudgeLoop`); a loop that metered no rate leaves the key absent (UNKNOWN).
1716
1714
  ...rateSourceFields(c),
1717
1715
  }),
1718
1716
  })
@@ -236,12 +236,14 @@ function perMoney(n) {
236
236
 
237
237
  /**
238
238
  * The one readout line for a resolved customer price, preview and run tail alike:
239
- * `yours: in $0.006 / out $1.20 per 1M tokens (DEEPSEEK_API_KEY row)`.
239
+ * `yours: in $0.006 / out $1.20 per 1M tokens (DEEPSEEK_API_KEY row)`. A 0 / 0 price is accepted (the price
240
+ * is the customer's) but the line says so: every round then books $0, so spend and limits read $0.
240
241
  * @param {{ inPerM: number, outPerM: number, envName: string }} price a {@link ratesFor} result
241
242
  * @returns {string}
242
243
  */
243
244
  export function priceReadout(price) {
244
- return `yours: in ${perMoney(price.inPerM)} / out ${perMoney(price.outPerM)} per 1M tokens (${price.envName} row)`;
245
+ const zero = price.inPerM === 0 && price.outPerM === 0 ? ' — WARNING: this price is $0, so spend and limits read $0' : '';
246
+ return `yours: in ${perMoney(price.inPerM)} / out ${perMoney(price.outPerM)} per 1M tokens (${price.envName} row)${zero}`;
245
247
  }
246
248
 
247
249
  /**
package/src/providers.js CHANGED
@@ -2,8 +2,9 @@
2
2
  // `provider` name to the bare-agent constructor it drives, the env var
3
3
  // carrying its key, and the per-tier model table — including any
4
4
  // provider-specific request-key gating. Before this module existed, both
5
- // runners constructed `AnthropicProvider` BY NAME (`src/cli.js`'s
6
- // `buildProviders`, `scripts/run-u.mjs`'s top-level wiring) and any second
5
+ // runners constructed `AnthropicProvider` BY NAME (the old `buildProviders` in
6
+ // `src/cli.js` and `scripts/run-u.mjs`'s top-level wiring, both since replaced
7
+ // by `buildRunnerProviders` below) and any second
7
8
  // provider would have meant scattering `if (provider === …)` across both.
8
9
  //
9
10
  // hamr's ruling (PRD 30.7, 2026-09-09): `deepseek-chat` is THE secondary
@@ -265,8 +266,7 @@ export function makeProvider(providerName, { apiKey, model, baseUrl } = {}) {
265
266
 
266
267
  /**
267
268
  * Build the `{provider, providerFor, judgeProvider}` triple both runners
268
- * need (`src/cli.js`'s `buildProviders`, `scripts/run-u.mjs`'s top-level
269
- * wiring) — ONE seam instead of two copies of the same construction and
269
+ * need (`src/authorrun.js` and `src/panel/authorsession.js` call it) — ONE seam instead of two copies of the same construction and
270
270
  * tier-memoization logic. `model`/`tierModels` are already-RESOLVED inputs
271
271
  * (the caller runs `resolveWorkerModel` itself, exactly as both runners did
272
272
  * before this factory existed — that decision stays where each runner's own
package/src/runlist.js CHANGED
@@ -213,6 +213,21 @@ function runidForSpine(spinePath) {
213
213
  // the panel) because the monthly limit reads the same rule (src/monthly.js).
214
214
  export const DIED_MTIME_MS = 10 * 60 * 1000;
215
215
 
216
+ /**
217
+ * The ONE "is this run alive" rule, for the panel's glyph ([▶] vs [?]) and the monthly limit's hold. A
218
+ * row that carries its runner's `pid` is alive exactly while that pid is a live bareloop runner
219
+ * ({@link isLiveRunner}) however quiet its spine; only a row with NO pid (an older row) falls back to
220
+ * the spine-mtime rule above. A run that has ENDED (a `job-end` on its spine) is a different fact the
221
+ * callers read off the spine itself — this answers "is a process still running it", nothing more.
222
+ * @param {{ pid?: number, spine: string }} row
223
+ * @param {number} [nowMs] the clock for the mtime fallback
224
+ * @returns {boolean}
225
+ */
226
+ export function runIsAlive(row, nowMs = Date.now()) {
227
+ if (Number.isInteger(row.pid)) return isLiveRunner(/** @type {number} */ (row.pid));
228
+ try { return nowMs - statSync(row.spine).mtimeMs <= DIED_MTIME_MS; } catch { return false; }
229
+ }
230
+
216
231
  // A backfill scan of a real patients directory can be arbitrarily deep
217
232
  // (person-path runs archive several levels down: `<dir>/<proj>/out/
218
233
  // source-<x>/<proj>-bareloop/u-<id>.jsonl`, 4 levels under the dir a person
package/src/userrun.js CHANGED
@@ -72,7 +72,7 @@ import { answerReviewDoor, doorRecordOf, doorAgeGate } from './reviewdoor.js';
72
72
  // the cold reset, shared with the battery drivers so "cold" has one spelling
73
73
  import { coldReset, moveStaleGateAudit } from './u-patient.js';
74
74
  // PANEL-BUILD.md P1 — the one run list (`~/.config/bareloop/runs.jsonl`).
75
- import { appendRun, appendRunEvent, isLiveRunner } from './runlist.js';
75
+ import { appendRun, appendRunEvent, isLiveRunner, readRunList } from './runlist.js';
76
76
  import { keysForDoor } from './keysfile.js';
77
77
  import { parseJsonl } from './replayio.js';
78
78
  import { claimRun, monthlyRefusalText, legSpend } from './monthly.js';
@@ -362,6 +362,13 @@ async function execute(ctx) {
362
362
  // Set on the no-limit path too, once the row is appended there (the same "listed, no spine yet" state).
363
363
  /** @type {{ runid: string, spine: string, home: string|undefined }|null} */
364
364
  let claimedRow = null;
365
+ // Release the claimed row (a `released` entry folds it out of the list, like a monthly refusal): the run
366
+ // spent nothing and must not stay a ghost that keeps the month reading "at least". A failed release write
367
+ // never masks the original exit.
368
+ const releaseClaimedRow = () => {
369
+ if (!claimedRow) return;
370
+ try { appendRunEvent({ runid: claimedRow.runid, type: 'released', by: claimedRow.runid, reason: 'not started', at: new Date().toISOString() }, { home: claimedRow.home }); } catch { /* the exit stands; a stranded row of a dead process is closed by the next run */ }
371
+ };
365
372
  try {
366
373
  const spec = ctx.spec;
367
374
  const specPath = ctx.specPath;
@@ -684,7 +691,7 @@ async function execute(ctx) {
684
691
  });
685
692
  // is the dead run actually dead? Two processes on one patient is unrecoverable; a
686
693
  // false refusal costs one sentence. run-u's own pid is not on its spine, so the
687
- // watchdog's report is the pid there is — and its absence is not evidence of life.
694
+ // watchdog's report or the run-list row is the pid there is — and its absence is not evidence of life.
688
695
  const wdFile = `${deadSpineFile}.watchdog.json`;
689
696
  /** @type {any} */
690
697
  let watchdog = null;
@@ -692,8 +699,14 @@ async function execute(ctx) {
692
699
  // ONE owner of "is that pid a live bareloop runner" (src/runlist.js `isLiveRunner`, shared with the
693
700
  // monthly limit's holds): alive AND a bareloop runner refuses; a recycled pid that now runs some
694
701
  // other program does not. Where /proc is unreadable an existing pid is assumed to be ours.
695
- if (Number.isInteger(watchdog?.pid) && isLiveRunner(watchdog.pid)) {
696
- die(`--resume: pid ${watchdog.pid} from ${deadSpineFile} is still alive (a bareloop runner). Two processes on one patient is unrecoverable — stop it first.`);
702
+ // The pid is the watchdog's kill record's, or the predecessor's OWN run-list row's (every row carries
703
+ // its runner's pid): a run never killed by the watchdog has no record but still has its row.
704
+ /** @type {number|undefined} */
705
+ let rowPid;
706
+ if (cfgOn) { try { rowPid = readRunList({ home: cfgHome }).rows.findLast((r) => r.spine === deadSpineFile)?.pid; } catch { /* an unreadable list is not evidence of life */ } }
707
+ const alivePid = [watchdog?.pid, rowPid].find((p) => Number.isInteger(p) && isLiveRunner(p));
708
+ if (alivePid !== undefined) {
709
+ die(`--resume: pid ${alivePid} from ${deadSpineFile} is still alive (a bareloop runner). Two processes on one patient is unrecoverable — stop it first.`);
697
710
  }
698
711
  // WHEN did the dead leg stop? The watchdog's kill record is later, better evidence
699
712
  // than the last spine event for a run that was KILLED — and worse evidence for one
@@ -1704,6 +1717,7 @@ async function execute(ctx) {
1704
1717
  emit('destination-refused', { code: sourceManifest.code, detail: sourceManifest.stop });
1705
1718
  emit('run-end', { outcome: 'escalated' });
1706
1719
  err(`SOURCE-MANIFEST-RED — ${sourceManifest.stop}. See ${spineFile}`);
1720
+ releaseClaimedRow(); // a $0 refusal after the spine exists: no ghost row, no "at least" month
1707
1721
  throw new ExitSignal(1);
1708
1722
  }
1709
1723
  /** the front door's per-run instance, or null when there is none (no
@@ -1722,6 +1736,7 @@ async function execute(ctx) {
1722
1736
  emit('destination-refused', { code: dp.code, detail: dp.stop });
1723
1737
  emit('run-end', { outcome: 'escalated' });
1724
1738
  err(`DESTINATION-RED — ${dp.stop}. See ${spineFile}`);
1739
+ releaseClaimedRow(); // a $0 refusal after the spine exists: no ghost row, no "at least" month
1725
1740
  throw new ExitSignal(1);
1726
1741
  }
1727
1742
  }
@@ -1740,6 +1755,7 @@ async function execute(ctx) {
1740
1755
  });
1741
1756
  emit('run-end', { outcome: 'escalated' });
1742
1757
  err(`CLOSE-TIMING-RED — stage(s) never finished the timing preflight: ${names}. See ${spineFile}`);
1758
+ releaseClaimedRow(); // a $0 refusal after the spine exists: no ghost row, no "at least" month
1743
1759
  throw new ExitSignal(1);
1744
1760
  }
1745
1761
  const RESOLVED_CLOSE_TIMEOUT_MS = /** @type {number} */ (closeTimingResolved.closeTimeoutMs);
@@ -1991,10 +2007,16 @@ async function execute(ctx) {
1991
2007
  : mh.trend === 'flat' ? 'more money is unlikely to help; this bundle\'s goal is signed and cannot be revised here'
1992
2008
  : 'read the last close output before deciding whether more money is worth it';
1993
2009
  out(` lever ${lever}`);
1994
- out(` · choose a larger --budget on the resume line below (the room left is the signed $${signed}; it cannot be raised past that)`);
1995
- out(' · abandon the task');
1996
- out(` resume ${ctx.bundle.invoke}${WALL_FLAG} --resume ${runid}${SHIM_TAIL}${SCOUT_TAIL}${DRAFT_TAIL}${REGISTRY_TAIL} --budget <more than ${spentTxt}, at most $${signed}> --approve ${PRINT_APPROVE}`);
1997
- out(' (type the --budget number yourself — nothing here fills it in, so this line does not run as printed; nothing in the run may widen its own budget)');
2010
+ if (spentUsd !== null && spentUsd >= signed) {
2011
+ // the whole signed ceiling is spent: "more than spent, at most signed" is unsatisfiable, so no resume line is printed
2012
+ out(` · no room left under the signed ceiling: this run spent all of the signed $${signed}, and a bundle's ceiling cannot be raised`);
2013
+ out(' · abandon the task');
2014
+ } else {
2015
+ out(` · choose a larger --budget on the resume line below (the room left is the signed $${signed}; it cannot be raised past that)`);
2016
+ out(' · abandon the task');
2017
+ out(` resume ${ctx.bundle.invoke}${WALL_FLAG} --resume ${runid}${SHIM_TAIL}${SCOUT_TAIL}${DRAFT_TAIL}${REGISTRY_TAIL} --budget <more than ${spentTxt}, at most $${signed}> --approve ${PRINT_APPROVE}`);
2018
+ out(' (type the --budget number yourself — nothing here fills it in, so this line does not run as printed; nothing in the run may widen its own budget)');
2019
+ }
1998
2020
  } else {
1999
2021
  out(` lever ${mh.lever}`);
2000
2022
  for (const o of mh.options ?? []) out(` · ${o}`);
@@ -2270,9 +2292,7 @@ async function execute(ctx) {
2270
2292
  // owns no spine, so its row is released (folded out of the list, like a monthly refusal) rather than
2271
2293
  // left as a ghost that keeps the month reading "at least". Once the spine exists the run is a real,
2272
2294
  // listed run and ends by its own job-end. A failed release write never masks the original exit.
2273
- if (claimedRow && !existsSync(claimedRow.spine)) {
2274
- try { appendRunEvent({ runid: claimedRow.runid, type: 'released', by: claimedRow.runid, reason: 'not started', at: new Date().toISOString() }, { home: claimedRow.home }); } catch { /* the exit stands; a stranded row of a dead process is closed by the next run */ }
2275
- }
2295
+ if (claimedRow && !existsSync(claimedRow.spine)) releaseClaimedRow();
2276
2296
  if (e instanceof ExitSignal) return e.code;
2277
2297
  throw e;
2278
2298
  }
package/types/judged.d.ts CHANGED
@@ -229,7 +229,7 @@ export function locatePrompt(card: any): string;
229
229
  * On every route out, red or clean, `onCost` fires exactly once with the honest
230
230
  * read: a paid call that leaves no meter record is F12 wearing a judge's coat.
231
231
  * @param {{artifactText: string, card: any, loopFactory: (o: {provider?: any, system: string}) => any,
232
- * maxTokens?: number, attempt?: number, onCost?: (c: {costUsd: number|null, unpricedRounds: number}) => void,
232
+ * maxTokens?: number, attempt?: number, onCost?: (c: {costUsd: number|null, unpricedRounds: number, rateSource?: string|null}) => void,
233
233
  * callBounds?: {timeoutMs?: number, deadlineMs?: number}}} o
234
234
  * @returns {Promise<{ok: boolean, facts: any|null, red: {axis: string, detail: string}|null,
235
235
  * costUsd: number|null, unpricedRounds: number, truncated: boolean, parseError: boolean,
@@ -247,6 +247,7 @@ export function runLocate({ artifactText, card, loopFactory, maxTokens, attempt,
247
247
  onCost?: (c: {
248
248
  costUsd: number | null;
249
249
  unpricedRounds: number;
250
+ rateSource?: string | null;
250
251
  }) => void;
251
252
  callBounds?: {
252
253
  timeoutMs?: number;
@@ -389,7 +390,9 @@ export function defaultJudgeLoop({ provider, system, rates }: {
389
390
  in: number;
390
391
  out: number;
391
392
  } | null;
392
- }): import("bare-agent").Loop;
393
+ }): {
394
+ run: (msgs: any[], tools: any[], opts?: any) => Promise<any>;
395
+ };
393
396
  export type RuleRed = {
394
397
  fn: string;
395
398
  why: string;
@@ -29,17 +29,18 @@ export function legTokens(records: any[]): number;
29
29
  /**
30
30
  * Every listed run as one {@link Leg}. The ONE reader the month total, the all-time total
31
31
  * and the per-provider figures share.
32
- * @param {{ home?: string, now?: () => number, aboveRunid?: string }} [opts] `now` = the clock for the no-pid in-flight test; `aboveRunid` = read only the runs listed EARLIER in the file than that run (a claim yields to the claims above it, never below)
32
+ * @param {{ home?: string, now?: () => number, aboveRunid?: string, thisMonthOnly?: boolean }} [opts] `now` = the clock for the no-pid in-flight test; `aboveRunid` = read only the runs listed EARLIER in the file than that run (a claim yields to the claims above it, never below); `thisMonthOnly` = do not parse the spine of a run listed in another local month (the refusal check drops it anyway) unless its claim is still held
33
33
  * @returns {Leg[]}
34
34
  */
35
35
  export function readLegs(opts?: {
36
36
  home?: string;
37
37
  now?: () => number;
38
38
  aboveRunid?: string;
39
+ thisMonthOnly?: boolean;
39
40
  }): Leg[];
40
41
  /**
41
42
  * @param {{ home?: string, now?: () => number, aboveRunid?: string }} [opts] `aboveRunid`: see {@link readLegs}
42
- * @returns {{ usd: number, atLeast: boolean, runs: number, reservedUsd: number }} `usd` = real spend (the Money tab's figure); `reservedUsd` = the same with in-flight runs at their cap (the refusal check's)
43
+ * @returns {{ usd: number, atLeast: boolean, runs: number, reservedUsd: number, heldUsd: number, heldRuns: number }} `usd` = real spend (the Money tab's figure); `reservedUsd` = the same with in-flight runs at their cap (the refusal check's); `heldUsd` = what in-progress runs set aside beyond what they have spent (`reservedUsd - usd`), over `heldRuns` runs
43
44
  */
44
45
  export function monthSpend(opts?: {
45
46
  home?: string;
@@ -50,6 +51,8 @@ export function monthSpend(opts?: {
50
51
  atLeast: boolean;
51
52
  runs: number;
52
53
  reservedUsd: number;
54
+ heldUsd: number;
55
+ heldRuns: number;
53
56
  };
54
57
  /**
55
58
  * The Money tab's figures: all-time and this-month spend, and per-provider breakdown
@@ -97,6 +100,8 @@ export function spendSummary(opts?: {
97
100
  * @property {number|null} leftUsd what is left this month (null = no limit set)
98
101
  * @property {number|null} limitUsd the person's monthly limit (null = none)
99
102
  * @property {boolean} atLeast the month's spend is a floor, so `leftUsd` is a ceiling
103
+ * @property {number} [heldUsd] what run(s) in progress have set aside beyond their spend so far (present when a limit is set)
104
+ * @property {number} [heldRuns] how many runs hold it
100
105
  */
101
106
  /**
102
107
  * The person's monthly limit off a parsed config.json: key absent (or null) = no limit (null);
@@ -122,7 +127,8 @@ export function checkMonthlyRoom({ capUsd, home, now, aboveRunid }: {
122
127
  aboveRunid?: string;
123
128
  }): MonthlyRoom;
124
129
  /**
125
- * The ONE refusal text, everywhere (CLI, panel cap note, panel Sign refusal).
130
+ * The ONE refusal text, everywhere (CLI, panel cap note, panel Sign refusal). When run(s) in progress hold
131
+ * part of the limit, the same line says how much.
126
132
  * @param {MonthlyRoom} room
127
133
  * @returns {string|null} null when the run fits
128
134
  */
@@ -160,7 +166,6 @@ export function claimRun({ row, capUsd, home, now }: {
160
166
  room: MonthlyRoom;
161
167
  claimed: boolean;
162
168
  };
163
- export { isLiveRunner };
164
169
  export type Leg = {
165
170
  /**
166
171
  * when the run was listed
@@ -223,5 +228,12 @@ export type MonthlyRoom = {
223
228
  * the month's spend is a floor, so `leftUsd` is a ceiling
224
229
  */
225
230
  atLeast: boolean;
231
+ /**
232
+ * what run(s) in progress have set aside beyond their spend so far (present when a limit is set)
233
+ */
234
+ heldUsd?: number | undefined;
235
+ /**
236
+ * how many runs hold it
237
+ */
238
+ heldRuns?: number | undefined;
226
239
  };
227
- import { isLiveRunner } from './runlist.js';
@@ -1,5 +1,20 @@
1
1
  /** @returns {string} a fresh per-process token — never persisted, never logged */
2
2
  export function mintToken(): string;
3
+ /**
4
+ * The ONE Host check: the request names this panel's own bind address. A page on another
5
+ * origin reaching the panel through a rebound DNS name sends that name as Host, so it fails here.
6
+ * @param {import('node:http').IncomingMessage} req
7
+ * @param {{port: number}} o
8
+ * @returns {{ok: true}|{ok: false, reason: string}}
9
+ */
10
+ export function checkHostGuard(req: import("node:http").IncomingMessage, { port }: {
11
+ port: number;
12
+ }): {
13
+ ok: true;
14
+ } | {
15
+ ok: false;
16
+ reason: string;
17
+ };
3
18
  /**
4
19
  * `true` when a request may reach ANY route in this file — the token header
5
20
  * plus an Origin/Host that names this exact bind address. `req.headers`
@@ -127,7 +127,8 @@ export function keyNameFor(provider: string | null | undefined, baseUrl: string
127
127
  export function applyConfiguredKey(env: Record<string, string | undefined>, provider: string | null | undefined, baseUrl: string | null | undefined, rows: readonly KeyRow[], model?: string | null | undefined): Record<string, string | undefined>;
128
128
  /**
129
129
  * The one readout line for a resolved customer price, preview and run tail alike:
130
- * `yours: in $0.006 / out $1.20 per 1M tokens (DEEPSEEK_API_KEY row)`.
130
+ * `yours: in $0.006 / out $1.20 per 1M tokens (DEEPSEEK_API_KEY row)`. A 0 / 0 price is accepted (the price
131
+ * is the customer's) but the line says so: every round then books $0, so spend and limits read $0.
131
132
  * @param {{ inPerM: number, outPerM: number, envName: string }} price a {@link ratesFor} result
132
133
  * @returns {string}
133
134
  */
@@ -52,8 +52,7 @@ export function makeProvider(providerName: string, { apiKey, model, baseUrl }?:
52
52
  }): any;
53
53
  /**
54
54
  * Build the `{provider, providerFor, judgeProvider}` triple both runners
55
- * need (`src/cli.js`'s `buildProviders`, `scripts/run-u.mjs`'s top-level
56
- * wiring) — ONE seam instead of two copies of the same construction and
55
+ * need (`src/authorrun.js` and `src/panel/authorsession.js` call it) — ONE seam instead of two copies of the same construction and
57
56
  * tier-memoization logic. `model`/`tierModels` are already-RESOLVED inputs
58
57
  * (the caller runs `resolveWorkerModel` itself, exactly as both runners did
59
58
  * before this factory existed — that decision stays where each runner's own
@@ -95,6 +95,20 @@ export function readRunList(opts?: {
95
95
  * @returns {boolean}
96
96
  */
97
97
  export function isLiveRunner(pid: number): boolean;
98
+ /**
99
+ * The ONE "is this run alive" rule, for the panel's glyph ([▶] vs [?]) and the monthly limit's hold. A
100
+ * row that carries its runner's `pid` is alive exactly while that pid is a live bareloop runner
101
+ * ({@link isLiveRunner}) however quiet its spine; only a row with NO pid (an older row) falls back to
102
+ * the spine-mtime rule above. A run that has ENDED (a `job-end` on its spine) is a different fact the
103
+ * callers read off the spine itself — this answers "is a process still running it", nothing more.
104
+ * @param {{ pid?: number, spine: string }} row
105
+ * @param {number} [nowMs] the clock for the mtime fallback
106
+ * @returns {boolean}
107
+ */
108
+ export function runIsAlive(row: {
109
+ pid?: number;
110
+ spine: string;
111
+ }, nowMs?: number): boolean;
98
112
  /**
99
113
  * `bareloop runs backfill <dir>` — recursively scan `dir` (bounded to
100
114
  * {@link MAX_BACKFILL_DEPTH} levels, never into `node_modules`/`.git`,