bareloop 0.32.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/src/monthly.js CHANGED
@@ -8,21 +8,22 @@
8
8
  // The limit REFUSES, never warns; the page and the chat can never raise it (a person
9
9
  // edits it by hand in Settings — src/config.js).
10
10
  //
11
- // A run still IN FLIGHT (a spine with `job-start`, no `job-end`, and a spine file written to
12
- // within DIED_MTIME_MS — the panel's own died rule) is counted at its full leg cap, not its
13
- // spend so far: `checkMonthlyRoom` reserves what the run may still spend, so two runs cannot
14
- // both start against a limit only one of them fits. A died run (stale spine) counts its floor.
15
- // The one exception is the spine a RESUME continues (`resumingSpine`): it counts its real spend
16
- // only, because the resume's own leg cap is the remainder and already covers its unspent cap.
17
- // Only the refusal check reserves; the Money tab keeps showing real spend (`usd`).
11
+ // HOLD UNTIL DONE (hamr 2026-09-30): a run CLAIMS its whole leg cap when it starts (`claimRun`: append
12
+ // the run's row FIRST, carrying its `pid` and `capUsd`, then read the list — only the claims ABOVE its row
13
+ // count, so whoever runs first holds) and holds it until it is done. While the row's pid is a live bareloop runner the
14
+ // run is counted at max(spend so far, capUsd) however quiet its spine; a run with a `job-end` counts
15
+ // its real spend; a claim whose process is gone counts its floor and is settled — with an attributed
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) — `runIsAlive`, src/runlist.js, the
18
+ // one "is this run alive" rule the panel's glyph shares. Only the refusal check
19
+ // reserves; the Money tab keeps showing real spend (`usd`).
18
20
  //
19
21
  // Honesty: a run whose spend is not fully known (a died/still-running spine, an unpriced
20
22
  // round, a missing spine file) makes the month total an "at least" figure — never a clean
21
23
  // number. The refusal text stays exactly `Max $X (monthly limit)`; `atLeast` travels beside it.
22
- import { existsSync, statSync } from 'node:fs';
23
- import { resolve } from 'node:path';
24
+ import { existsSync } from 'node:fs';
24
25
  import { readConfig, configPath, ConfigError } from './config.js';
25
- import { readRunList, DIED_MTIME_MS } from './runlist.js';
26
+ import { readRunList, appendRun, appendRunEvent, isLiveRunner, runIsAlive } from './runlist.js';
26
27
  import { parseJsonl } from './replayio.js';
27
28
  import { SPEND_RECORD_TYPES, spendProvenance, floorsFromRecords } from './ledger.js';
28
29
  import { findRow } from './providerrows.js';
@@ -140,46 +141,64 @@ export function legTokens(records) {
140
141
 
141
142
  /**
142
143
  * What the refusal check counts for one run: its spend, unless it is IN FLIGHT (`job-start`, no
143
- * `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
144
145
  * spend and the leg's own cap — the signed `budgetUsd` on the spine's `job-start` less the fold
145
146
  * a resumed leg inherited (`priorSpentUsd`), the same figure the run-start seam computes
146
147
  * (`legCapUsd`, src/userrun.js). A spine that records no `budgetUsd` reserves nothing extra.
147
148
  * @param {any[]} records
148
- * @param {string} spinePath
149
+ * @param {import('./runlist.js').RunRow} row
149
150
  * @param {number} nowMs
150
151
  * @param {number} usd the leg's spend so far
151
152
  * @returns {number}
152
153
  */
153
- function inFlightCapUsd(records, spinePath, nowMs, usd) {
154
+ function inFlightCapUsd(records, row, nowMs, usd) {
154
155
  if (records.some((r) => r && r.type === 'job-end')) return usd;
155
156
  const start = records.find((r) => r && r.type === 'job-start') ?? null;
156
157
  if (!start || !(typeof start.budgetUsd === 'number' && Number.isFinite(start.budgetUsd))) return usd;
157
- let mtimeMs;
158
- try { mtimeMs = statSync(spinePath).mtimeMs; } catch { return usd; }
159
- 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
160
159
  const fold = typeof start.priorSpentUsd === 'number' && Number.isFinite(start.priorSpentUsd) ? start.priorSpentUsd : 0;
161
160
  return Math.max(usd, Math.max(0, start.budgetUsd - fold));
162
161
  }
163
162
 
163
+ /**
164
+ * The rows listed EARLIER in the run list than `runid`'s own row (file order is claim order). No
165
+ * `runid`, or a runid not in the list, = every row.
166
+ * @param {import('./runlist.js').RunRow[]} rows
167
+ * @param {string} [runid]
168
+ * @returns {import('./runlist.js').RunRow[]}
169
+ */
170
+ function rowsAbove(rows, runid) {
171
+ if (runid === undefined) return rows;
172
+ const i = rows.findIndex((r) => r.runid === runid);
173
+ return i < 0 ? rows : rows.slice(0, i);
174
+ }
175
+
164
176
  /**
165
177
  * Every listed run as one {@link Leg}. The ONE reader the month total, the all-time total
166
178
  * and the per-provider figures share.
167
- * @param {{ home?: string, now?: () => number, resumingSpine?: string|null }} [opts] `now` = the clock for the in-flight test; `resumingSpine` = the spine a resume continues: that leg reserves its spend only, never its unspent cap (the resume's own leg cap is that remainder)
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
168
180
  * @returns {Leg[]}
169
181
  */
170
182
  export function readLegs(opts = {}) {
171
183
  const nowMs = (opts.now ?? Date.now)();
172
- const { rows } = readRunList({ home: opts.home });
173
- const resuming = typeof opts.resumingSpine === 'string' ? resolve(opts.resumingSpine) : null;
184
+ const { rows: allRows, events } = readRunList({ home: opts.home });
185
+ const rows = rowsAbove(allRows, opts.aboveRunid);
186
+ const settled = new Set(events.filter((e) => e.type === 'settled').map((e) => e.runid));
174
187
  /** @type {Leg[]} */
175
188
  const legs = [];
176
189
  for (const row of rows) {
177
190
  const at = new Date(row.at);
178
191
  const base = { at, provider: null, baseUrl: null, model: null, wallMs: null, wallComplete: false, usd: 0, complete: false, tokens: 0, vouchedRounds: 0, otherRounds: 0, reservedUsd: 0, unreadable: true };
179
- if (Number.isNaN(at.getTime()) || !existsSync(row.spine)) { legs.push(base); continue; }
192
+ // Rule 2 (hold-until-done): a row that carries its runner's `pid` is HELD at its cap while that
193
+ // pid is a live bareloop runner, however quiet its spine — killed = process gone, not silence.
194
+ // An older row with no `pid` keeps the spine-mtime rule (`inFlightCapUsd`), never guessed alive.
195
+ const hasPid = Number.isInteger(row.pid);
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;
198
+ if (Number.isNaN(at.getTime()) || !existsSync(row.spine)) { legs.push({ ...base, reservedUsd: heldCap ?? 0 }); continue; }
180
199
  /** @type {any[]} */
181
200
  let records;
182
- try { records = parseJsonl(row.spine).records; } catch { legs.push(base); continue; }
201
+ try { records = parseJsonl(row.spine).records; } catch { legs.push({ ...base, reservedUsd: heldCap ?? 0 }); continue; }
183
202
  const start = records.find((r) => r && r.type === 'job-start') ?? null;
184
203
  const leg = legSpend(records);
185
204
  const prov = spendProvenance(records);
@@ -196,7 +215,8 @@ export function readLegs(opts = {}) {
196
215
  tokens: legTokens(records),
197
216
  vouchedRounds: prov.vouched.rounds,
198
217
  otherRounds: prov.guessed.rounds + prov.unpriced.rounds + prov.unknown.rounds,
199
- reservedUsd: resuming !== null && resolve(row.spine) === resuming ? leg.usd : inFlightCapUsd(records, row.spine, nowMs, leg.usd),
218
+ reservedUsd: hasPid ? (records.some((r) => r && r.type === 'job-end') || heldCap === null ? leg.usd : Math.max(leg.usd, heldCap))
219
+ : inFlightCapUsd(records, row, nowMs, leg.usd),
200
220
  unreadable: false,
201
221
  });
202
222
  }
@@ -211,8 +231,8 @@ export function readLegs(opts = {}) {
211
231
  const sameLocalMonth = (at, now) => at.getFullYear() === now.getFullYear() && at.getMonth() === now.getMonth();
212
232
 
213
233
  /**
214
- * @param {{ home?: string, now?: () => number, resumingSpine?: string|null }} [opts] `resumingSpine`: see {@link readLegs}
215
- * @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)
234
+ * @param {{ home?: string, now?: () => number, aboveRunid?: string }} [opts] `aboveRunid`: see {@link readLegs}
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
216
236
  */
217
237
  export function monthSpend(opts = {}) {
218
238
  const nowDate = new Date((opts.now ?? Date.now)());
@@ -220,17 +240,20 @@ export function monthSpend(opts = {}) {
220
240
  let reservedUsd = 0;
221
241
  let atLeast = false;
222
242
  let runs = 0;
223
- for (const leg of readLegs({ home: opts.home, now: opts.now, resumingSpine: opts.resumingSpine })) {
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 })) {
224
246
  // an unreadable date could belong to this month — unknown, never dropped
225
247
  if (Number.isNaN(leg.at.getTime())) { atLeast = true; continue; }
226
248
  if (!sameLocalMonth(leg.at, nowDate)) continue;
227
249
  runs += 1;
228
- if (leg.unreadable) { atLeast = true; continue; }
250
+ if (leg.unreadable) { atLeast = true; reservedUsd += leg.reservedUsd; if (leg.reservedUsd > 0) { heldUsd += leg.reservedUsd; heldRuns += 1; } continue; }
229
251
  usd += leg.usd;
230
252
  reservedUsd += leg.reservedUsd;
253
+ if (leg.reservedUsd > leg.usd) { heldUsd += leg.reservedUsd - leg.usd; heldRuns += 1; }
231
254
  if (!leg.complete) atLeast = true;
232
255
  }
233
- return { usd, atLeast, runs, reservedUsd };
256
+ return { usd, atLeast, runs, reservedUsd, heldUsd, heldRuns };
234
257
  }
235
258
 
236
259
  /**
@@ -302,6 +325,8 @@ export function spendSummary(opts = {}) {
302
325
  * @property {number|null} leftUsd what is left this month (null = no limit set)
303
326
  * @property {number|null} limitUsd the person's monthly limit (null = none)
304
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
305
330
  */
306
331
 
307
332
  /**
@@ -321,32 +346,84 @@ export function monthlyLimitOf(config, home) {
321
346
  }
322
347
 
323
348
  /**
324
- * Does a run with this $ cap fit in what is left this month (running jobs counted at their full cap, except the spine a resume continues — `resumingSpine` — which counts its real spend)? No limit set (key absent or null) = ok, no check; a limit that is present but not a
349
+ * Does a run with this $ cap fit in what is left this month (a run still held counts at its full cap)? No limit set (key absent or null) = ok, no check; a limit that is present but not a
325
350
  * number above 0 throws `ConfigError`.
326
351
  * Compared in whole cents. Also throws `ConfigError` when config.json is unreadable — a gate
327
352
  * whose own instrument is broken refuses; it never silently runs with no limit.
328
- * @param {{ capUsd: number, home?: string, now?: () => number, resumingSpine?: string|null }} args
353
+ * @param {{ capUsd: number, home?: string, now?: () => number, aboveRunid?: string }} args `aboveRunid`: count only the claims listed above that run (a claim in progress)
329
354
  * @returns {MonthlyRoom}
330
355
  */
331
- export function checkMonthlyRoom({ capUsd, home, now, resumingSpine }) {
356
+ export function checkMonthlyRoom({ capUsd, home, now, aboveRunid }) {
332
357
  const cfg = readConfig({ home });
333
358
  if (cfg.problem) throw new ConfigError(cfg.problem);
334
359
  const limit = monthlyLimitOf(cfg.config, home);
335
360
  if (limit === null) {
336
361
  return { ok: true, leftUsd: null, limitUsd: null, atLeast: false };
337
362
  }
338
- const spent = monthSpend({ home, now, resumingSpine });
363
+ const spent = monthSpend({ home, now, aboveRunid });
339
364
  const leftCents = Math.max(0, Math.floor((limit - spent.reservedUsd) * 100 + 1e-6));
340
- const capCents = Math.ceil((Number.isFinite(capUsd) ? capUsd : 0) * 100 - 1e-6);
341
- 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 };
342
367
  }
343
368
 
344
369
  /**
345
- * 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.
346
372
  * @param {MonthlyRoom} room
347
373
  * @returns {string|null} null when the run fits
348
374
  */
349
375
  export function monthlyRefusalText(room) {
350
376
  if (room.ok || room.leftUsd === null) return null;
351
- 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)`;
380
+ }
381
+
382
+ /**
383
+ * Close every claim ABOVE `aboveRunid` (file order) whose process is gone: a pid-bearing run row with no
384
+ * `settled` entry whose pid is not a live bareloop runner gets `{ type: 'settled', by: <closer>, reason:
385
+ * 'process gone', spentUsd: <its spine's floor>, spendComplete: false }` appended — so every claim gets a
386
+ * write-back, attributed. Two runs may both close the same dead claim; readers take the FIRST note.
387
+ * @param {{ home?: string, by: string, aboveRunid?: string }} args `by` = the run doing the closing
388
+ * @returns {number} how many notes were appended
389
+ */
390
+ export function settleDeadClaims({ home, by, aboveRunid }) {
391
+ const { rows, events } = readRunList({ home });
392
+ const settled = new Set(events.filter((e) => e.type === 'settled').map((e) => e.runid));
393
+ let n = 0;
394
+ for (const row of rowsAbove(rows, aboveRunid)) {
395
+ if (!Number.isInteger(row.pid) || settled.has(row.runid) || isLiveRunner(/** @type {number} */ (row.pid))) continue;
396
+ let spentUsd = 0;
397
+ try { spentUsd = legSpend(parseJsonl(row.spine).records).usd; } catch { /* no spine: nothing was recorded spent */ }
398
+ appendRunEvent({ runid: row.runid, type: 'settled', by, reason: 'process gone', spentUsd, spendComplete: false, at: new Date().toISOString() }, { home });
399
+ n += 1;
400
+ }
401
+ return n;
402
+ }
403
+
404
+ /**
405
+ * The CLAIM (hamr 2026-09-30: whoever runs first claims the limit and holds it; no lock file, records
406
+ * only): the run APPENDS its row first (`row` carries its `pid` and `capUsd`), then reads the run list.
407
+ * Only the claims ABOVE its own row count — a live one is held at its cap (a dead one is closed with an
408
+ * attributed note and counts its real spend); rows below yield to it. If `capUsd` fits, it runs; if not
409
+ * it appends `{type:'released', reason:'refused'}` and reports the refusal, nothing spent. No limit set =
410
+ * no claim (`claimed: false`; the caller lists the run as it always did). An unreadable config, or a row
411
+ * that cannot be written, throws `ConfigError` — the gate refuses, never reads "no limit".
412
+ * @param {{ row: import('./runlist.js').RunRow, capUsd: number, home?: string, now?: () => number }} args
413
+ * @returns {{ room: MonthlyRoom, claimed: boolean }}
414
+ */
415
+ export function claimRun({ row, capUsd, home, now }) {
416
+ const cfg = readConfig({ home });
417
+ if (cfg.problem) throw new ConfigError(cfg.problem);
418
+ if (monthlyLimitOf(cfg.config, home) === null) return { room: { ok: true, leftUsd: null, limitUsd: null, atLeast: false }, claimed: false };
419
+ try { appendRun(row, { home }); } catch (/** @type {any} */ e) {
420
+ throw new ConfigError(`cannot write this run's claim to the run list (${e.message})`);
421
+ }
422
+ settleDeadClaims({ home, by: row.runid, aboveRunid: row.runid });
423
+ const room = checkMonthlyRoom({ capUsd, home, now, aboveRunid: row.runid });
424
+ if (!room.ok) {
425
+ try { appendRunEvent({ runid: row.runid, type: 'released', by: row.runid, reason: 'refused', at: new Date().toISOString() }, { home }); } catch { /* the refusal stands; a stranded row of a dead process is closed by the next run */ }
426
+ return { room, claimed: false };
427
+ }
428
+ return { room, claimed: true };
352
429
  }
@@ -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 };
@@ -20,7 +20,8 @@
20
20
  // `src/panel/server.js`'s POST routes). Nothing here changes what a step
21
21
  // means or what an answer does — only where the answer comes from.
22
22
 
23
- import { applyConfiguredKey, keyNameFor, modelChoiceFor, rowsForHome } from '../providerrows.js';
23
+ import { applyConfiguredKey, judgeRatesFor, keyNameFor, modelChoiceFor, ratesFor, rowsForHome } from '../providerrows.js';
24
+ import { ConfigError } from '../config.js';
24
25
  import {
25
26
  mkdirSync, existsSync, writeFileSync, readdirSync,
26
27
  } from 'node:fs';
@@ -310,6 +311,15 @@ export function createSession(card, deps = {}) {
310
311
  const draftKeyName = keyNameFor(modelChoice.provider, modelChoice.baseUrl, keyCfg, modelChoice.name).name;
311
312
  const apiKey = draftEnv[providerEntry.envKey];
312
313
  if (!apiKey) { refuse(`${draftKeyName} not set. Stopped — nothing spent.`); return; }
314
+ // the customer's own price on this row (USD per 1M in config.json), resolved once here at $0 and
315
+ // handed to every model call this session makes; a bad price refuses, never falls back to the guess
316
+ /** @type {ReturnType<typeof ratesFor>} */
317
+ let draftPrice = null;
318
+ try { draftPrice = ratesFor(modelChoice.provider, modelChoice.baseUrl, keyCfg, modelChoice.name); } catch (e) {
319
+ if (!(e instanceof ConfigError)) throw e;
320
+ refuse(`${e.message}. Stopped — nothing spent.`);
321
+ return;
322
+ }
313
323
  const keyProblem = apiKeyProblem(apiKey);
314
324
  if (keyProblem) { refuse(`${draftKeyName} ${keyProblem}. Stopped — nothing spent.`); return; }
315
325
 
@@ -422,8 +432,8 @@ export function createSession(card, deps = {}) {
422
432
  // `provider` for the model boundary) and/or `scout` (bypassing the real
423
433
  // paid scout) so it can drive the REAL confirm turn/ask()/revise/hash
424
434
  // machinery below with a deterministic fake, never a live provider call.
425
- const generate = deps.generate ?? makeLoopGenerate(provider);
426
- const confirmGenerate = deps.confirmGenerate ?? makeLoopGenerate(provider, { system: CONFIRM_SYSTEM });
435
+ const generate = deps.generate ?? makeLoopGenerate(provider, { rates: draftPrice?.rates ?? null });
436
+ const confirmGenerate = deps.confirmGenerate ?? makeLoopGenerate(provider, { system: CONFIRM_SYSTEM, rates: draftPrice?.rates ?? null });
427
437
 
428
438
  state.phase = 'drafting';
429
439
  state.progressLabel = 'drafting';
@@ -437,6 +447,7 @@ export function createSession(card, deps = {}) {
437
447
  questions: questionsFor(verdictType),
438
448
  writeScope, provider, generate,
439
449
  ceilingUsd: card.capUsd,
450
+ rates: draftPrice?.rates ?? null,
440
451
  onPhase, onCall,
441
452
  ask, confirmGenerate, isRepo: true, langResult,
442
453
  ...(deps.scout ? { scout: deps.scout } : {}),
@@ -470,7 +481,16 @@ export function createSession(card, deps = {}) {
470
481
  const judges = closeJudges(spec.closeDecl);
471
482
  const judge = judges ? resolveJobJudge(spec, modelChoice.provider, resolveWorkerModel) : null;
472
483
  let judgeProvider = null;
484
+ /** @type {ReturnType<typeof ratesFor>} */
485
+ let judgePrice = null;
473
486
  if (judge) {
487
+ try {
488
+ judgePrice = judgeRatesFor(judge, modelChoice.provider, modelChoice.baseUrl, draftPrice, keyCfg);
489
+ } catch (e) {
490
+ if (!(e instanceof ConfigError)) throw e;
491
+ refuse(`${e.message} — refusing before gate 4 spends anything`);
492
+ return;
493
+ }
474
494
  const judgeKeyValue = env.JUDGE_API_KEY ?? (judge.provider === modelChoice.provider ? draftEnv : applyConfiguredKey(env, judge.provider, undefined, keyCfg, judge.model))[resolveProvider(judge.provider).envKey];
475
495
  const judgeKeyProblem = judgeKeyValue ? apiKeyProblem(judgeKeyValue) : null;
476
496
  if (!judgeKeyValue || judgeKeyProblem) {
@@ -486,7 +506,7 @@ export function createSession(card, deps = {}) {
486
506
  const signing = await prepareSigningFn({
487
507
  spec, workdir: prep.tree, seedRef: authored.seedRef, timeoutMs,
488
508
  shellCapUsd: spec.budgetUsd, ceilingUsd: card.capUsd, priorCalls: [...metered],
489
- judgeLoop: judgeProvider ? (o) => defaultJudgeLoop({ provider: judgeProvider, system: o.system }) : null,
509
+ judgeLoop: judgeProvider ? (o) => defaultJudgeLoop({ provider: judgeProvider, system: o.system, rates: judgePrice?.rates ?? null }) : null,
490
510
  judgeModel: judge?.model ?? null,
491
511
  onJudgeCost: (c) => onCall({ label: `${c.label}:${c.id}`, costUsd: c.costUsd, unpricedRounds: c.unpricedRounds }),
492
512
  });
@@ -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
@@ -762,6 +762,10 @@ ${scoutBlob || '(no scout notes)'}`;
762
762
  * `false` → the drafter has no tools, so a native session would report NO cost — return a
763
763
  * metered claude-json TEXT provider (`--output-format json`, `parse:'claude-json'`) instead,
764
764
  * so its spend is never invisible. The Loop path (`anthropic-api`) never touches this.
765
+ * @param {{in: number, out: number}|null} [opts.rates] the customer's own price for the worker's key row (USD per
766
+ * 1K tokens, `ratesFor`) — handed to the one Loop that drives worker, scout and planner rounds, so they read
767
+ * `rateSource:'caller'`; null = bare-agent's built-in guess. Not applied to a native (CLI) session, which reports its own cost.
768
+ * @param {{in: number, out: number}|null} [opts.judgeRates] the same, for the judge's Loop
765
769
  * @param {string|null} [opts.judgeModel] softgreen — WHICH model that judge provider drives
766
770
  * (PRD item 32.1). Resolved by the CALLER (`resolveJudge`, src/judged.js: the spec's signed
767
771
  * `judge` override, else the job's own worker model), never pinned in this library. It
@@ -983,7 +987,7 @@ ${scoutBlob || '(no scout notes)'}`;
983
987
  * 'branch-red' | 'cap-halt' | 'wall-halt' | 'provider-red' | 'interpreter-red' |
984
988
  * 'step-stalled' | 'hitl-pause' | 'hitl-decision-red' | `step-red:<id>`
985
989
  */
986
- export async function runPlan(job, { workdir, provider, nativeProvider, providerFor, judgeProvider = null, judgeModel = null, emit, remainingUsd, isUnpriced = () => false, spendComplete = () => true, capRuns = 3, strikeLimit = STRIKE_LIMIT, closeTimeoutMs, closeDir = null, maxStepRounds = 40, layerRoot = false, readShim = false, scout = true, scoutRounds = SCOUT_ROUNDS, bridge = null, now, priorWallMs = 0, resumeSeed = null, resumeGrades = [], resumeReplans = null, resumeBranch = null, humanRuling = null, heldRuling = null, priorSpentUsd = 0, reviewDoor = null, doorRerun = null, resumable = true }) {
990
+ export async function runPlan(job, { workdir, provider, nativeProvider, providerFor, judgeProvider = null, judgeModel = null, rates = null, judgeRates = null, emit, remainingUsd, isUnpriced = () => false, spendComplete = () => true, capRuns = 3, strikeLimit = STRIKE_LIMIT, closeTimeoutMs, closeDir = null, maxStepRounds = 40, layerRoot = false, readShim = false, scout = true, scoutRounds = SCOUT_ROUNDS, bridge = null, now, priorWallMs = 0, resumeSeed = null, resumeGrades = [], resumeReplans = null, resumeBranch = null, humanRuling = null, heldRuling = null, priorSpentUsd = 0, reviewDoor = null, doorRerun = null, resumable = true }) {
987
991
  // MEMORY-CACHE: what the read shim (src/readshim.js) saved THIS run, summed across
988
992
  // every mkWorker's own shim instance (scout, drafter, each step's worker, the fix
989
993
  // worker) — one accumulator closed over by all of them, because the shim's ledger
@@ -1687,7 +1691,7 @@ export async function runPlan(job, { workdir, provider, nativeProvider, provider
1687
1691
  // ride, so `runJob`'s ONE ledger accounts a close's spend exactly as it accounts
1688
1692
  // an attempt's (a budget funds the attempt PLUS its close), and a null cost trips
1689
1693
  // the same F6 pricing halt rather than being laundered into $0.
1690
- judgeLoop: judgeProvider ? (/** @type {{system: string}} */ o) => defaultJudgeLoop({ provider: judgeProvider, system: o.system }) : null,
1694
+ judgeLoop: judgeProvider ? (/** @type {{system: string}} */ o) => defaultJudgeLoop({ provider: judgeProvider, system: o.system, rates: judgeRates }) : null,
1691
1695
  // the seam's other half (PRD item 32.1) — WHICH model that loop drives. Passed
1692
1696
  // through untouched: this module resolves no judge, exactly as it constructs no
1693
1697
  // provider. `runJudgedFloor` stops as a wiring gap when it is absent rather than
@@ -1704,11 +1708,9 @@ export async function runPlan(job, { workdir, provider, nativeProvider, provider
1704
1708
  stage: c.stage, path: c.path, attempt: c.attempt, label: c.label, model: c.model,
1705
1709
  costUsd: c.costUsd, unpricedRounds: c.unpricedRounds,
1706
1710
  // BA-21 provenance, through the SAME helper the worker rounds use — this record
1707
- // is on `spendProvenance`'s read list (src/ledger.js), so a judge-round that
1708
- // never carried the field would read UNKNOWN forever inside the readout built to
1709
- // expose exactly this seam. NAMED, not papered over: the judge cost payload
1710
- // (src/kinds.js `onJudgeCost({...})`) does not carry `rateSource` today, so it
1711
- // 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).
1712
1714
  ...rateSourceFields(c),
1713
1715
  }),
1714
1716
  })
@@ -2750,7 +2752,7 @@ export async function runPlan(job, { workdir, provider, nativeProvider, provider
2750
2752
  }
2751
2753
  return onLlmResult(arg);
2752
2754
  };
2753
- self = new Loop({ provider: loopProvider, system, policy, onLlmResult: metered, onToolResult: onToolOutcome });
2755
+ self = new Loop({ provider: loopProvider, system, policy, onLlmResult: metered, onToolResult: onToolOutcome, ...(rates ? { rates } : {}) });
2754
2756
  return self;
2755
2757
  };
2756
2758
  /** @param {string} prompt @param {typeof toolDefs} [defs] */
@@ -10,7 +10,7 @@
10
10
  // `applyConfiguredKey` / `modelChoiceFor` below — no second lookup exists. Key VALUES
11
11
  // never pass through here — only names.
12
12
  import { resolveProvider } from './providers.js';
13
- import { readConfig } from './config.js';
13
+ import { readConfig, ConfigError } from './config.js';
14
14
  import { filledKeyNames } from './keysfile.js';
15
15
 
16
16
  /** DeepSeek's OpenAI-shaped endpoint (the same string the panel's Model menu used). */
@@ -73,6 +73,9 @@ export function defaultsFor(envName) {
73
73
  * @property {string} name the model id Chat uses ('' = not offered in Chat)
74
74
  * @property {string} provider the `src/providers.js` table entry (the API shape's id)
75
75
  * @property {string} baseUrl the saved Base URL ('' = the shape's default host)
76
+ * @property {unknown} [priceInPerM] the customer's own price, USD per 1M input tokens — carried RAW
77
+ * (present only when the config row names it); {@link ratesFor} is the one place that validates it
78
+ * @property {unknown} [priceOutPerM] the same, for output tokens
76
79
  */
77
80
 
78
81
  /**
@@ -90,6 +93,8 @@ export function keyRows({ filled, config }) {
90
93
  name: typeof s.name === 'string' ? s.name : d.name,
91
94
  provider: SHAPES.some((x) => x.id === s.shape) ? s.shape : d.shape,
92
95
  baseUrl: typeof s.baseUrl === 'string' ? s.baseUrl : d.baseUrl,
96
+ ...(Object.hasOwn(s, 'priceInPerM') ? { priceInPerM: s.priceInPerM } : {}),
97
+ ...(Object.hasOwn(s, 'priceOutPerM') ? { priceOutPerM: s.priceOutPerM } : {}),
93
98
  };
94
99
  });
95
100
  }
@@ -215,3 +220,84 @@ export function applyConfiguredKey(env, provider, baseUrl, rows, model) {
215
220
  if (!k.chosen && value !== 'null') return env;
216
221
  return { ...env, [k.builtIn]: usableKey(value) };
217
222
  }
223
+
224
+ /**
225
+ * A per-1M price for a readout: two decimals at least ($1.20), more when the number needs them so a
226
+ * tiny price never rounds to $0.00 or $0.01 ($0.006, $0.0004).
227
+ * @param {number} n USD per 1M tokens
228
+ * @returns {string}
229
+ */
230
+ function perMoney(n) {
231
+ const shown = String(n);
232
+ if (!shown.includes('e') && (shown.split('.')[1] ?? '').length >= 2) return `$${shown}`;
233
+ if (shown.includes('e')) return `$${n.toFixed(12).replace(/0+$/, '').replace(/\.$/, '')}`;
234
+ return `$${n.toFixed(2)}`;
235
+ }
236
+
237
+ /**
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)`. 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.
241
+ * @param {{ inPerM: number, outPerM: number, envName: string }} price a {@link ratesFor} result
242
+ * @returns {string}
243
+ */
244
+ export function priceReadout(price) {
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}`;
247
+ }
248
+
249
+ /**
250
+ * The customer's own price for the row a `(provider, baseUrl[, model])` identity belongs to —
251
+ * the SAME row match {@link keyNameFor} uses (`findRow`), so the price and the key always come
252
+ * from one row. `null` = no price set (the run keeps bare-agent's built-in guess, byte-identical
253
+ * to before). Both fields or neither, each a finite number >= 0, USD per 1M tokens; anything
254
+ * else throws `ConfigError` naming the row and field — a bad price never silently falls back to
255
+ * the guess. `rates` is bare-agent's `Loop({ rates })` shape (USD per 1K); `inPerM`/`outPerM`
256
+ * are what a readout prints.
257
+ * @param {string|null|undefined} provider
258
+ * @param {string|null|undefined} baseUrl
259
+ * @param {readonly KeyRow[]} rows
260
+ * @param {string|null|undefined} [model]
261
+ * @returns {{ rates: { in: number, out: number }, inPerM: number, outPerM: number, envName: string }|null}
262
+ */
263
+ export function ratesFor(provider, baseUrl, rows, model) {
264
+ const row = findRow(rows, { provider, baseUrl, model });
265
+ if (!row) return null;
266
+ const hasIn = row.priceInPerM !== undefined;
267
+ const hasOut = row.priceOutPerM !== undefined;
268
+ if (!hasIn && !hasOut) return null;
269
+ if (hasIn !== hasOut) {
270
+ throw new ConfigError(`keys.${row.envName} sets ${hasIn ? 'priceInPerM' : 'priceOutPerM'} but not ${hasIn ? 'priceOutPerM' : 'priceInPerM'} — set both prices (USD per 1M tokens) or neither`);
271
+ }
272
+ for (const field of /** @type {const} */ (['priceInPerM', 'priceOutPerM'])) {
273
+ const v = row[field];
274
+ if (!(typeof v === 'number' && Number.isFinite(v) && v >= 0)) {
275
+ throw new ConfigError(`keys.${row.envName}.${field} must be a number, 0 or more (USD per 1M tokens) — fix it in config.json or remove both price lines`);
276
+ }
277
+ }
278
+ const inPerM = /** @type {number} */ (row.priceInPerM);
279
+ const outPerM = /** @type {number} */ (row.priceOutPerM);
280
+ return { rates: { in: inPerM / 1000, out: outPerM / 1000 }, inPerM, outPerM, envName: row.envName };
281
+ }
282
+
283
+ /**
284
+ * The price a judge's rounds are booked at. A price belongs to the MODEL's row: when the judge is
285
+ * the worker's own provider it calls the worker's endpoint, so a row on that endpoint NAMED for the
286
+ * judge's model prices it ({@link ratesFor}); a judge model that is absent or names no row there
287
+ * keeps the worker's price (the worker's row, or none = the guess). A different provider resolves
288
+ * on its own default endpoint, as before. A bad price on the resolved row throws like `ratesFor`.
289
+ * The judge's KEY is chosen elsewhere (`applyConfiguredKey`, by the worker's model), so the two
290
+ * can come from different rows — same provider + endpoint, judge model naming another row.
291
+ * @param {{ provider: string, model?: string|null }} judge
292
+ * @param {string|null|undefined} workerProvider
293
+ * @param {string|null|undefined} workerBaseUrl
294
+ * @param {ReturnType<typeof ratesFor>} workerPrice what the worker's own rounds are booked at
295
+ * @param {readonly KeyRow[]} rows
296
+ * @returns {ReturnType<typeof ratesFor>}
297
+ */
298
+ export function judgeRatesFor(judge, workerProvider, workerBaseUrl, workerPrice, rows) {
299
+ if (judge.provider !== workerProvider) return ratesFor(judge.provider, undefined, rows, judge.model);
300
+ const want = endpointOf(judge.provider, workerBaseUrl);
301
+ const named = judge.model ? rows.some((r) => r.provider === judge.provider && endpointOf(r.provider, r.baseUrl) === want && r.name === judge.model) : false;
302
+ return named ? ratesFor(judge.provider, workerBaseUrl, rows, judge.model) : workerPrice;
303
+ }