bareloop 0.31.0 → 0.33.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.
Files changed (54) hide show
  1. package/CHANGELOG.md +151 -0
  2. package/README.md +4 -1
  3. package/bareloop.context.md +232 -85
  4. package/bin/bareloop.mjs +0 -1
  5. package/package.json +1 -1
  6. package/src/authorflow.js +5 -3
  7. package/src/authorjob.js +3 -1
  8. package/src/authorrun.js +49 -11
  9. package/src/authorscout.js +10 -6
  10. package/src/bundlerun.js +266 -0
  11. package/src/cli.js +35 -357
  12. package/src/config.js +135 -0
  13. package/src/interviewrun.js +13 -4
  14. package/src/invoke.js +22 -0
  15. package/src/judged.js +5 -3
  16. package/src/keysfile.js +156 -0
  17. package/src/ledger.js +26 -0
  18. package/src/monthly.js +422 -0
  19. package/src/panel/authorroutes.js +61 -16
  20. package/src/panel/authorsession.js +46 -28
  21. package/src/panel/index.html +287 -13
  22. package/src/panel/server.js +58 -51
  23. package/src/panel/settingsroutes.js +230 -0
  24. package/src/planrun.js +7 -3
  25. package/src/providerrows.js +301 -0
  26. package/src/providers.js +3 -1
  27. package/src/run.js +7 -2
  28. package/src/runlist.js +92 -7
  29. package/src/source.js +8 -3
  30. package/src/u-watchdog.mjs +296 -0
  31. package/src/userrun.js +230 -54
  32. package/types/authorflow.d.ts +8 -2
  33. package/types/authorjob.d.ts +6 -1
  34. package/types/authorrun.d.ts +5 -0
  35. package/types/authorscout.d.ts +11 -2
  36. package/types/bundlerun.d.ts +26 -0
  37. package/types/cli.d.ts +2 -1
  38. package/types/config.d.ts +48 -0
  39. package/types/interviewrun.d.ts +5 -0
  40. package/types/invoke.d.ts +8 -0
  41. package/types/judged.d.ts +5 -1
  42. package/types/keysfile.d.ts +90 -0
  43. package/types/ledger.d.ts +14 -0
  44. package/types/monthly.d.ts +227 -0
  45. package/types/panel/authorroutes.d.ts +4 -2
  46. package/types/panel/authorsession.d.ts +7 -15
  47. package/types/panel/server.d.ts +10 -3
  48. package/types/panel/settingsroutes.d.ts +14 -0
  49. package/types/planrun.d.ts +13 -1
  50. package/types/providerrows.d.ts +229 -0
  51. package/types/run.d.ts +14 -1
  52. package/types/runlist.d.ts +66 -3
  53. package/types/source.d.ts +8 -3
  54. package/types/userrun.d.ts +54 -0
package/src/monthly.js ADDED
@@ -0,0 +1,422 @@
1
+ // PANEL-BUILD.md P4a item 2 — the monthly $ limit (R1, R2, R3).
2
+ //
3
+ // `monthSpend` sums what this local calendar month's runs spent, read off each run's own
4
+ // spine (the run list `~/.config/bareloop/runs.jsonl` carries no money). `checkMonthlyRoom`
5
+ // asks whether a run's $ cap fits in what is left. It is called at the ONE run-start seam
6
+ // both the CLI (`bareloop run-u`) and the panel (which spawns `run-u`) pass through
7
+ // (`execute` in src/userrun.js), before any token spends, and by the panel's cap note.
8
+ // The limit REFUSES, never warns; the page and the chat can never raise it (a person
9
+ // edits it by hand in Settings — src/config.js).
10
+ //
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). Only the refusal check
18
+ // reserves; the Money tab keeps showing real spend (`usd`).
19
+ //
20
+ // Honesty: a run whose spend is not fully known (a died/still-running spine, an unpriced
21
+ // round, a missing spine file) makes the month total an "at least" figure — never a clean
22
+ // number. The refusal text stays exactly `Max $X (monthly limit)`; `atLeast` travels beside it.
23
+ import { existsSync, statSync } from 'node:fs';
24
+ import { readConfig, configPath, ConfigError } from './config.js';
25
+ import { readRunList, appendRun, appendRunEvent, isLiveRunner, DIED_MTIME_MS } from './runlist.js';
26
+ export { isLiveRunner };
27
+ import { parseJsonl } from './replayio.js';
28
+ import { SPEND_RECORD_TYPES, spendProvenance, floorsFromRecords } from './ledger.js';
29
+ import { findRow } from './providerrows.js';
30
+
31
+ // The Money tab's per-provider breakdown groups a run by the endpoint it ran on and names
32
+ // the groups it knows the vendor of. This is HISTORY labelling for spend already recorded
33
+ // (an archived run keeps its provider whatever Settings holds today) — not the Providers
34
+ // rows, which come from the keys file (src/providerrows.js).
35
+ /** @type {readonly {id: string, name: string}[]} */
36
+ const MONEY_LABELS = Object.freeze([
37
+ { id: 'anthropic', name: 'Anthropic' }, { id: 'openai', name: 'OpenAI' },
38
+ { id: 'gemini', name: 'Gemini' }, { id: 'deepseek', name: 'DeepSeek' },
39
+ ]);
40
+
41
+ /**
42
+ * Which known vendor a run's `(provider, baseUrl)` belongs to. `openai-api` with no override
43
+ * is OpenAI; with DeepSeek's host it is DeepSeek; with any OTHER override it is none (null —
44
+ * never silently pooled into OpenAI's figures).
45
+ * @param {string|null|undefined} provider
46
+ * @param {string|null|undefined} baseUrl
47
+ * @returns {string|null} a MONEY_LABELS id, or null
48
+ */
49
+ function rowIdFor(provider, baseUrl) {
50
+ if (provider === 'anthropic-api') return 'anthropic';
51
+ if (provider === 'gemini-api') return 'gemini';
52
+ if (provider === 'openai-api') {
53
+ if (!baseUrl) return 'openai';
54
+ try {
55
+ const host = new URL(baseUrl).hostname;
56
+ if (host === 'api.deepseek.com') return 'deepseek';
57
+ } catch { /* an unparseable override is no known vendor */ }
58
+ return null;
59
+ }
60
+ return null;
61
+ }
62
+
63
+ /**
64
+ * One run's own spend, off its spine records: this LEG's figure (never the chain fold),
65
+ * plus the drafting spend the run was signed with (counted once — on the first leg only).
66
+ * @param {any[]} records
67
+ * @returns {{ usd: number, complete: boolean }}
68
+ */
69
+ export function legSpend(records) {
70
+ const jobStart = records.find((r) => r && r.type === 'job-start') ?? null;
71
+ const jobEnd = records.findLast((r) => r && r.type === 'job-end') ?? null;
72
+ const rounds = records.filter((r) => r && SPEND_RECORD_TYPES.includes(r.type));
73
+ const pricedSum = rounds.reduce((a, r) => (typeof r.costUsd === 'number' && Number.isFinite(r.costUsd) ? a + r.costUsd : a), 0);
74
+ const unpriced = rounds.filter((r) => !(typeof r.costUsd === 'number' && Number.isFinite(r.costUsd))).length;
75
+ let usd = pricedSum;
76
+ let complete = false;
77
+ if (jobEnd) {
78
+ const eng = jobEnd.engagementSpentUsd;
79
+ usd = typeof eng === 'number' && Number.isFinite(eng) ? eng : pricedSum;
80
+ complete = jobEnd.spendComplete === true && unpriced === 0;
81
+ }
82
+ // drafting spend: a RESUMED / door-rerun leg carries the flag again but the first leg already counted it
83
+ const resumed = !!jobStart && 'priorSpentUsd' in jobStart;
84
+ if (jobStart && !resumed && typeof jobStart.draftSpentUsd === 'number' && Number.isFinite(jobStart.draftSpentUsd) && jobStart.draftSpentUsd > 0) {
85
+ usd += jobStart.draftSpentUsd;
86
+ if (jobStart.draftSpendComplete === false) complete = false;
87
+ }
88
+ return { usd, complete };
89
+ }
90
+
91
+ /**
92
+ * One run's wall time off its spine: job-start -> job-end when the run ended (exact), else the
93
+ * first -> last record floor (the SAME floor a died / still-running run's "at least" wall uses,
94
+ * `floorsFromRecords`). `ms` null = unknown — never 0.
95
+ * @param {any[]} records
96
+ * @returns {{ ms: number|null, complete: boolean }}
97
+ */
98
+ export function legWall(records) {
99
+ const jobStart = records.find((r) => r && r.type === 'job-start') ?? null;
100
+ const jobEnd = records.findLast((r) => r && r.type === 'job-end') ?? null;
101
+ const a = Date.parse(jobStart?.ts);
102
+ const b = Date.parse(jobEnd?.ts);
103
+ if (jobEnd && Number.isFinite(a) && Number.isFinite(b) && b >= a) return { ms: b - a, complete: true };
104
+ return { ms: floorsFromRecords(records).wallFloorMs, complete: false };
105
+ }
106
+
107
+ /**
108
+ * Tokens a spine's rounds processed (input + output + cache read + cache write — all four,
109
+ * so a cache-heavy run is not under-reported), off the real `usage` field names.
110
+ * @param {any[]} records
111
+ * @returns {number}
112
+ */
113
+ export function legTokens(records) {
114
+ let n = 0;
115
+ for (const r of records) {
116
+ if (!r || !SPEND_RECORD_TYPES.includes(r.type) || !r.usage) continue;
117
+ for (const k of ['inputTokens', 'outputTokens', 'cacheReadTokens', 'cacheCreationTokens']) {
118
+ const v = r.usage[k];
119
+ if (typeof v === 'number' && Number.isFinite(v)) n += v;
120
+ }
121
+ }
122
+ return n;
123
+ }
124
+
125
+ /**
126
+ * @typedef {object} Leg
127
+ * @property {Date} at when the run was listed
128
+ * @property {string|null} provider job-start's provider (null = an older spine that carried none)
129
+ * @property {string|null} baseUrl
130
+ * @property {number} usd this leg's spend (a floor when `!complete`)
131
+ * @property {boolean} complete every figure behind `usd` is known
132
+ * @property {number} tokens
133
+ * @property {string|null} model job-start's model (older spines carry a model but no provider)
134
+ * @property {number|null} wallMs this leg's wall time (null = unknown; a floor when `!wallComplete`)
135
+ * @property {boolean} wallComplete the wall is the exact job-start -> job-end span
136
+ * @property {number} vouchedRounds rounds priced by a rate somebody vouched for (`provider`/`caller`)
137
+ * @property {number} otherRounds every other round: a built-in guess, unpriced, or no provenance on record
138
+ * @property {number} reservedUsd what the refusal check counts: `usd`, or the leg's cap when the run is in flight
139
+ * @property {boolean} unreadable the spine was missing/unreadable or the listed date was bad (spend unknown)
140
+ */
141
+
142
+ /**
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
145
+ * spend and the leg's own cap — the signed `budgetUsd` on the spine's `job-start` less the fold
146
+ * a resumed leg inherited (`priorSpentUsd`), the same figure the run-start seam computes
147
+ * (`legCapUsd`, src/userrun.js). A spine that records no `budgetUsd` reserves nothing extra.
148
+ * @param {any[]} records
149
+ * @param {string} spinePath
150
+ * @param {number} nowMs
151
+ * @param {number} usd the leg's spend so far
152
+ * @returns {number}
153
+ */
154
+ function inFlightCapUsd(records, spinePath, nowMs, usd) {
155
+ if (records.some((r) => r && r.type === 'job-end')) return usd;
156
+ const start = records.find((r) => r && r.type === 'job-start') ?? null;
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
161
+ const fold = typeof start.priorSpentUsd === 'number' && Number.isFinite(start.priorSpentUsd) ? start.priorSpentUsd : 0;
162
+ return Math.max(usd, Math.max(0, start.budgetUsd - fold));
163
+ }
164
+
165
+ /**
166
+ * The rows listed EARLIER in the run list than `runid`'s own row (file order is claim order). No
167
+ * `runid`, or a runid not in the list, = every row.
168
+ * @param {import('./runlist.js').RunRow[]} rows
169
+ * @param {string} [runid]
170
+ * @returns {import('./runlist.js').RunRow[]}
171
+ */
172
+ function rowsAbove(rows, runid) {
173
+ if (runid === undefined) return rows;
174
+ const i = rows.findIndex((r) => r.runid === runid);
175
+ return i < 0 ? rows : rows.slice(0, i);
176
+ }
177
+
178
+ /**
179
+ * Every listed run as one {@link Leg}. The ONE reader the month total, the all-time total
180
+ * 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)
182
+ * @returns {Leg[]}
183
+ */
184
+ export function readLegs(opts = {}) {
185
+ const nowMs = (opts.now ?? Date.now)();
186
+ const { rows: allRows, events } = readRunList({ home: opts.home });
187
+ const rows = rowsAbove(allRows, opts.aboveRunid);
188
+ const settled = new Set(events.filter((e) => e.type === 'settled').map((e) => e.runid));
189
+ /** @type {Leg[]} */
190
+ const legs = [];
191
+ for (const row of rows) {
192
+ const at = new Date(row.at);
193
+ 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 };
194
+ // Rule 2 (hold-until-done): a row that carries its runner's `pid` is HELD at its cap while that
195
+ // pid is a live bareloop runner, however quiet its spine — killed = process gone, not silence.
196
+ // An older row with no `pid` keeps the spine-mtime rule (`inFlightCapUsd`), never guessed alive.
197
+ 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;
199
+ if (Number.isNaN(at.getTime()) || !existsSync(row.spine)) { legs.push({ ...base, reservedUsd: heldCap ?? 0 }); continue; }
200
+ /** @type {any[]} */
201
+ let records;
202
+ try { records = parseJsonl(row.spine).records; } catch { legs.push({ ...base, reservedUsd: heldCap ?? 0 }); continue; }
203
+ const start = records.find((r) => r && r.type === 'job-start') ?? null;
204
+ const leg = legSpend(records);
205
+ const prov = spendProvenance(records);
206
+ const wall = legWall(records);
207
+ legs.push({
208
+ at,
209
+ model: typeof start?.model === 'string' ? start.model : null,
210
+ wallMs: wall.ms,
211
+ wallComplete: wall.complete,
212
+ provider: typeof start?.provider === 'string' ? start.provider : null,
213
+ baseUrl: typeof start?.baseUrl === 'string' ? start.baseUrl : null,
214
+ usd: leg.usd,
215
+ complete: leg.complete,
216
+ tokens: legTokens(records),
217
+ vouchedRounds: prov.vouched.rounds,
218
+ otherRounds: prov.guessed.rounds + prov.unpriced.rounds + prov.unknown.rounds,
219
+ 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),
221
+ unreadable: false,
222
+ });
223
+ }
224
+ return legs;
225
+ }
226
+
227
+ /**
228
+ * @param {Date} at
229
+ * @param {Date} now
230
+ * @returns {boolean} same LOCAL calendar month
231
+ */
232
+ const sameLocalMonth = (at, now) => at.getFullYear() === now.getFullYear() && at.getMonth() === now.getMonth();
233
+
234
+ /**
235
+ * @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)
237
+ */
238
+ export function monthSpend(opts = {}) {
239
+ const nowDate = new Date((opts.now ?? Date.now)());
240
+ let usd = 0;
241
+ let reservedUsd = 0;
242
+ let atLeast = false;
243
+ let runs = 0;
244
+ for (const leg of readLegs({ home: opts.home, now: opts.now, aboveRunid: opts.aboveRunid })) {
245
+ // an unreadable date could belong to this month — unknown, never dropped
246
+ if (Number.isNaN(leg.at.getTime())) { atLeast = true; continue; }
247
+ if (!sameLocalMonth(leg.at, nowDate)) continue;
248
+ runs += 1;
249
+ if (leg.unreadable) { atLeast = true; reservedUsd += leg.reservedUsd; continue; }
250
+ usd += leg.usd;
251
+ reservedUsd += leg.reservedUsd;
252
+ if (!leg.complete) atLeast = true;
253
+ }
254
+ return { usd, atLeast, runs, reservedUsd };
255
+ }
256
+
257
+ /**
258
+ * The Money tab's figures: all-time and this-month spend, and per-provider breakdown
259
+ * (rows from `rowIdFor`; a run on no known row lands under its own provider name, never
260
+ * pooled into another row). Any leg with unknown spend makes the figure it belongs to
261
+ * an "at least".
262
+ * @param {{ home?: string, now?: () => number, rows?: readonly import('./providerrows.js').KeyRow[] }} [opts] `rows` = the Providers rows: when given, `tokensByRow` sums each row's tokens
263
+ * @returns {{ tokensByRow: Record<string, number>, total: {usd: number, atLeast: boolean, tokens: number}, month: {usd: number, atLeast: boolean, tokens: number},
264
+ * byProvider: Record<string, {label: string, monthUsd: number, monthAtLeast: boolean, totalUsd: number, totalAtLeast: boolean, tokens: number, vouchedRounds: number, otherRounds: number, monthWallMs: number|null, monthWallAtLeast: boolean, totalWallMs: number|null, totalWallAtLeast: boolean}> }}
265
+ */
266
+ export function spendSummary(opts = {}) {
267
+ const nowDate = new Date((opts.now ?? Date.now)());
268
+ const total = { usd: 0, atLeast: false, tokens: 0 };
269
+ const month = { usd: 0, atLeast: false, tokens: 0 };
270
+ /** @type {Record<string, {label: string, monthUsd: number, monthAtLeast: boolean, totalUsd: number, totalAtLeast: boolean, tokens: number, vouchedRounds: number, otherRounds: number, monthWallMs: number|null, monthWallAtLeast: boolean, totalWallMs: number|null, totalWallAtLeast: boolean}>} */
271
+ const byProvider = {};
272
+ /** runs per provider in each scope, and how many had a known wall — unknown is never 0
273
+ * @type {Record<string, {monthRuns: number, monthKnown: number, totalKnown: number}>} */
274
+ const wallSeen = {};
275
+ /** total tokens of the runs each Providers row served (matched by shape + endpoint + model) @type {Record<string, number>} */
276
+ const tokensByRow = {};
277
+ for (const leg of readLegs({ home: opts.home })) {
278
+ let served = opts.rows ? findRow(opts.rows, { provider: leg.provider, baseUrl: leg.baseUrl, model: leg.model }) : null;
279
+ // an older spine records a model but no provider: it counts toward the row whose Name EXACTLY
280
+ // equals that model. Two rows sharing the Name make it ambiguous, so it counts toward neither.
281
+ if (opts.rows && leg.provider === null && leg.model !== null) {
282
+ const named = opts.rows.filter((r) => r.name === leg.model);
283
+ served = named.length === 1 ? named[0] : null;
284
+ }
285
+ if (served) tokensByRow[served.envName] = (tokensByRow[served.envName] ?? 0) + leg.tokens;
286
+ const inMonth = Number.isNaN(leg.at.getTime()) ? true : sameLocalMonth(leg.at, nowDate);
287
+ const id = rowIdFor(leg.provider, leg.baseUrl);
288
+ // an older spine carries a model but no provider: say so honestly, one row per model —
289
+ // never a provider guessed from the model's name
290
+ const notRecorded = id === null && leg.provider === null;
291
+ const key = id ?? (notRecorded ? `not-recorded:${leg.model ?? ''}` : `other:${leg.provider}${leg.baseUrl ? ` @ ${leg.baseUrl}` : ''}`);
292
+ const label = id ? (MONEY_LABELS.find((r) => r.id === id)?.name ?? id)
293
+ : (notRecorded ? (leg.model ? `not recorded (model ${leg.model})` : 'not recorded') : key.slice('other:'.length));
294
+ const p = (byProvider[key] ??= { label, monthUsd: 0, monthAtLeast: false, totalUsd: 0, totalAtLeast: false, tokens: 0, vouchedRounds: 0, otherRounds: 0, monthWallMs: 0, monthWallAtLeast: false, totalWallMs: 0, totalWallAtLeast: false });
295
+ const w = (wallSeen[key] ??= { monthRuns: 0, monthKnown: 0, totalKnown: 0 });
296
+ const unknown = leg.unreadable || !leg.complete;
297
+ const wallKnown = !leg.unreadable && leg.wallMs !== null;
298
+ const wallAtLeast = !wallKnown || !leg.wallComplete;
299
+ total.usd += leg.usd; total.tokens += leg.tokens; if (unknown) total.atLeast = true;
300
+ p.totalUsd += leg.usd; p.tokens += leg.tokens; p.vouchedRounds += leg.vouchedRounds; p.otherRounds += leg.otherRounds; if (unknown) p.totalAtLeast = true;
301
+ if (wallKnown) { p.totalWallMs = /** @type {number} */ (p.totalWallMs) + /** @type {number} */ (leg.wallMs); w.totalKnown += 1; }
302
+ if (wallAtLeast) p.totalWallAtLeast = true;
303
+ if (inMonth) {
304
+ month.usd += leg.usd; month.tokens += leg.tokens; if (unknown) month.atLeast = true;
305
+ p.monthUsd += leg.usd; if (unknown) p.monthAtLeast = true;
306
+ w.monthRuns += 1;
307
+ if (wallKnown) { p.monthWallMs = /** @type {number} */ (p.monthWallMs) + /** @type {number} */ (leg.wallMs); w.monthKnown += 1; }
308
+ if (wallAtLeast) p.monthWallAtLeast = true;
309
+ }
310
+ }
311
+ // runs in scope but not one with a known wall = unknown (null), never a clean 0
312
+ for (const [key, p] of Object.entries(byProvider)) {
313
+ const w = wallSeen[key];
314
+ if (w.totalKnown === 0) p.totalWallMs = null;
315
+ if (w.monthRuns > 0 && w.monthKnown === 0) p.monthWallMs = null;
316
+ }
317
+ return { tokensByRow, total, month, byProvider };
318
+ }
319
+
320
+ /**
321
+ * @typedef {object} MonthlyRoom
322
+ * @property {boolean} ok
323
+ * @property {number|null} leftUsd what is left this month (null = no limit set)
324
+ * @property {number|null} limitUsd the person's monthly limit (null = none)
325
+ * @property {boolean} atLeast the month's spend is a floor, so `leftUsd` is a ceiling
326
+ */
327
+
328
+ /**
329
+ * The person's monthly limit off a parsed config.json: key absent (or null) = no limit (null);
330
+ * a number above 0 = the limit; ANY other value present is a broken setting and throws
331
+ * `ConfigError` — a limit the file names but this code cannot read never reads as "no limit".
332
+ * @param {Record<string, any>} config
333
+ * @param {string} [home]
334
+ * @returns {number|null}
335
+ */
336
+ export function monthlyLimitOf(config, home) {
337
+ const limit = config.monthlyLimitUsd;
338
+ if (limit === undefined || limit === null) return null;
339
+ if (typeof limit === 'number' && Number.isFinite(limit) && limit > 0) return limit;
340
+ const shown = typeof limit === 'number' ? String(limit) : JSON.stringify(limit);
341
+ throw new ConfigError(`${configPath(home)} has monthlyLimitUsd = ${shown}, which is not a number above 0 — fix it or remove the line`);
342
+ }
343
+
344
+ /**
345
+ * 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
346
+ * number above 0 throws `ConfigError`.
347
+ * Compared in whole cents. Also throws `ConfigError` when config.json is unreadable — a gate
348
+ * whose own instrument is broken refuses; it never silently runs with no limit.
349
+ * @param {{ capUsd: number, home?: string, now?: () => number, aboveRunid?: string }} args `aboveRunid`: count only the claims listed above that run (a claim in progress)
350
+ * @returns {MonthlyRoom}
351
+ */
352
+ export function checkMonthlyRoom({ capUsd, home, now, aboveRunid }) {
353
+ const cfg = readConfig({ home });
354
+ if (cfg.problem) throw new ConfigError(cfg.problem);
355
+ const limit = monthlyLimitOf(cfg.config, home);
356
+ if (limit === null) {
357
+ return { ok: true, leftUsd: null, limitUsd: null, atLeast: false };
358
+ }
359
+ const spent = monthSpend({ home, now, aboveRunid });
360
+ 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 };
363
+ }
364
+
365
+ /**
366
+ * The ONE refusal text, everywhere (CLI, panel cap note, panel Sign refusal).
367
+ * @param {MonthlyRoom} room
368
+ * @returns {string|null} null when the run fits
369
+ */
370
+ export function monthlyRefusalText(room) {
371
+ if (room.ok || room.leftUsd === null) return null;
372
+ return `Max $${room.leftUsd.toFixed(2)} (monthly limit)`;
373
+ }
374
+
375
+ /**
376
+ * Close every claim ABOVE `aboveRunid` (file order) whose process is gone: a pid-bearing run row with no
377
+ * `settled` entry whose pid is not a live bareloop runner gets `{ type: 'settled', by: <closer>, reason:
378
+ * 'process gone', spentUsd: <its spine's floor>, spendComplete: false }` appended — so every claim gets a
379
+ * write-back, attributed. Two runs may both close the same dead claim; readers take the FIRST note.
380
+ * @param {{ home?: string, by: string, aboveRunid?: string }} args `by` = the run doing the closing
381
+ * @returns {number} how many notes were appended
382
+ */
383
+ export function settleDeadClaims({ home, by, aboveRunid }) {
384
+ const { rows, events } = readRunList({ home });
385
+ const settled = new Set(events.filter((e) => e.type === 'settled').map((e) => e.runid));
386
+ let n = 0;
387
+ for (const row of rowsAbove(rows, aboveRunid)) {
388
+ if (!Number.isInteger(row.pid) || settled.has(row.runid) || isLiveRunner(/** @type {number} */ (row.pid))) continue;
389
+ let spentUsd = 0;
390
+ try { spentUsd = legSpend(parseJsonl(row.spine).records).usd; } catch { /* no spine: nothing was recorded spent */ }
391
+ appendRunEvent({ runid: row.runid, type: 'settled', by, reason: 'process gone', spentUsd, spendComplete: false, at: new Date().toISOString() }, { home });
392
+ n += 1;
393
+ }
394
+ return n;
395
+ }
396
+
397
+ /**
398
+ * The CLAIM (hamr 2026-09-30: whoever runs first claims the limit and holds it; no lock file, records
399
+ * only): the run APPENDS its row first (`row` carries its `pid` and `capUsd`), then reads the run list.
400
+ * Only the claims ABOVE its own row count — a live one is held at its cap (a dead one is closed with an
401
+ * attributed note and counts its real spend); rows below yield to it. If `capUsd` fits, it runs; if not
402
+ * it appends `{type:'released', reason:'refused'}` and reports the refusal, nothing spent. No limit set =
403
+ * no claim (`claimed: false`; the caller lists the run as it always did). An unreadable config, or a row
404
+ * that cannot be written, throws `ConfigError` — the gate refuses, never reads "no limit".
405
+ * @param {{ row: import('./runlist.js').RunRow, capUsd: number, home?: string, now?: () => number }} args
406
+ * @returns {{ room: MonthlyRoom, claimed: boolean }}
407
+ */
408
+ export function claimRun({ row, capUsd, home, now }) {
409
+ const cfg = readConfig({ home });
410
+ if (cfg.problem) throw new ConfigError(cfg.problem);
411
+ if (monthlyLimitOf(cfg.config, home) === null) return { room: { ok: true, leftUsd: null, limitUsd: null, atLeast: false }, claimed: false };
412
+ try { appendRun(row, { home }); } catch (/** @type {any} */ e) {
413
+ throw new ConfigError(`cannot write this run's claim to the run list (${e.message})`);
414
+ }
415
+ settleDeadClaims({ home, by: row.runid, aboveRunid: row.runid });
416
+ const room = checkMonthlyRoom({ capUsd, home, now, aboveRunid: row.runid });
417
+ if (!room.ok) {
418
+ 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 */ }
419
+ return { room, claimed: false };
420
+ }
421
+ return { room, claimed: true };
422
+ }
@@ -21,8 +21,13 @@ import { randomBytes } from 'node:crypto';
21
21
  import { spawn as realSpawn } from 'node:child_process';
22
22
  import { openSync } from 'node:fs';
23
23
  import { join } from 'node:path';
24
- import { createSession, validateJobCard, MODEL_OPTIONS } from './authorsession.js';
25
- import { resolveProvider, apiKeyProblem, checkProviderReachable } from '../providers.js';
24
+ import { readFileSync } from 'node:fs';
25
+ import { createSession, validateJobCard } from './authorsession.js';
26
+ import { checkMonthlyRoom, monthlyRefusalText } from '../monthly.js';
27
+ import { ConfigError } from '../config.js';
28
+ import { keysForDoor, keysHome } from '../keysfile.js';
29
+ import { keyNameFor, modelChoiceFor, rowsForHome, chatModels, usableKey } from '../providerrows.js';
30
+ import { apiKeyProblem, checkProviderReachable } from '../providers.js';
26
31
 
27
32
  /** @returns {string} a fresh per-process token — never persisted, never logged */
28
33
  export function mintToken() {
@@ -68,10 +73,12 @@ const TERMINAL_PHASES = new Set(['refused', 'abandoned', 'error', 'signed', 'sig
68
73
  * deliberately has none of).
69
74
  * @param {{ port: number, token: string, env?: Record<string,string|undefined>,
70
75
  * sessionsRoot?: string, spawnFn?: typeof realSpawn, bareloopBin?: string,
71
- * jobsDir?: string, fetchImpl?: typeof fetch }} opts
76
+ * jobsDir?: string, fetchImpl?: typeof fetch, home?: string }} opts
72
77
  */
73
78
  export function createAuthorRoutes(opts) {
74
- const env = opts.env ?? process.env;
79
+ // the RAW env with the keys file re-merged on every use, so an edited file takes effect
80
+ // without a restart (never a start-time snapshot). An injected env with no `home` skips the file.
81
+ const envNow = () => keysForDoor({ env: opts.env, keysHome: opts.home }).env;
75
82
  const spawnFn = opts.spawnFn ?? realSpawn;
76
83
  const bareloopBin = opts.bareloopBin ?? new URL('../../bin/bareloop.mjs', import.meta.url).pathname;
77
84
  // TEST SEAM ONLY: a test injects a fake `fetch`-shaped function so the
@@ -111,17 +118,17 @@ export function createAuthorRoutes(opts) {
111
118
  if (req.method !== 'GET') { send(405, { ok: false, error: 'GET only' }); return true; }
112
119
  const q = new URL(/** @type {string} */ (req.url), 'http://127.0.0.1').searchParams;
113
120
  const modelId = q.get('model') ?? '';
114
- const choice = MODEL_OPTIONS[modelId];
121
+ const rows = rowsForHome(keysHome(opts.home));
122
+ const choice = modelChoiceFor(rows, modelId);
115
123
  if (!choice) { send(400, { ok: false, error: `unknown model "${modelId}"` }); return true; }
116
- /** @type {any} */
117
- let providerEntry;
118
- try { providerEntry = resolveProvider(choice.provider); } catch (e) { send(400, { ok: false, error: /** @type {Error} */ (e).message }); return true; }
119
- const raw = env[providerEntry.envKey];
124
+ // the key of the Settings row this Name belongs to
125
+ const keyName = keyNameFor(choice.provider, choice.baseUrl, rows, choice.name).name;
126
+ const raw = envNow()[keyName];
120
127
  const problem = raw ? apiKeyProblem(raw) : null;
121
128
  const keyStatus = !raw ? 'missing' : (problem ? 'bad-shape' : 'found');
122
129
  if (keyStatus !== 'found') {
123
130
  send(200, {
124
- ok: true, envKey: providerEntry.envKey, keyStatus, keyProblem: problem,
131
+ ok: true, envKey: keyName, keyStatus, keyProblem: problem,
125
132
  reachability: { checked: false, reachable: null, modelListed: null, status: null, note: 'no usable key to check reachability with' },
126
133
  });
127
134
  return true;
@@ -130,12 +137,12 @@ export function createAuthorRoutes(opts) {
130
137
  // `checkProviderReachable`'s own doc) — a short timeout so a flaky
131
138
  // endpoint never hangs the card.
132
139
  checkProviderReachable({
133
- providerName: choice.provider, apiKey: /** @type {string} */ (raw), model: providerEntry.tiers.sonnet,
140
+ providerName: choice.provider, apiKey: /** @type {string} */ (usableKey(raw)), model: choice.name,
134
141
  baseUrl: choice.baseUrl, fetchImpl, timeoutMs: 4000,
135
142
  }).then((r) => {
136
143
  send(200, {
137
144
  ok: true,
138
- envKey: providerEntry.envKey,
145
+ envKey: keyName,
139
146
  keyStatus,
140
147
  keyProblem: null,
141
148
  reachability: {
@@ -146,13 +153,39 @@ export function createAuthorRoutes(opts) {
146
153
  return true;
147
154
  }
148
155
 
156
+ // ── P4a: the monthly-limit note under the cap field. READ-ONLY, $0 — the same
157
+ // library check (`checkMonthlyRoom`, src/monthly.js) the run-start seam and the
158
+ // Sign refusal use, so the note the person sees is never a second opinion. The
159
+ // page is never the arbiter: Sign & run re-checks server-side regardless.
160
+ // ── P4b: Chat's Model menu IS the Settings rows (a blank Name is not offered). ──
161
+ if (pathname === '/api/author/models') {
162
+ if (req.method !== 'GET') { send(405, { ok: false, error: 'GET only' }); return true; }
163
+ send(200, { ok: true, models: chatModels(rowsForHome(keysHome(opts.home))) });
164
+ return true;
165
+ }
166
+
167
+ if (pathname === '/api/author/monthly-check') {
168
+ if (req.method !== 'GET') { send(405, { ok: false, error: 'GET only' }); return true; }
169
+ const q = new URL(/** @type {string} */ (req.url), 'http://127.0.0.1').searchParams;
170
+ const cap = Number(q.get('cap'));
171
+ if (!Number.isFinite(cap) || cap <= 0) { send(200, { ok: true, refusal: null }); return true; }
172
+ try {
173
+ const room = checkMonthlyRoom({ capUsd: cap, home: opts.home });
174
+ send(200, { ok: true, refusal: monthlyRefusalText(room), leftUsd: room.leftUsd, atLeast: room.atLeast });
175
+ } catch (e) {
176
+ if (!(e instanceof ConfigError)) throw e;
177
+ send(200, { ok: true, refusal: null, configProblem: e.message });
178
+ }
179
+ return true;
180
+ }
181
+
149
182
  if (pathname === '/api/author/start') {
150
183
  if (req.method !== 'POST') { send(405, { ok: false, error: 'POST only' }); return true; }
151
184
  if (hasLiveSession()) { send(409, { ok: false, error: 'an authoring session is already live — one at a time' }); return true; }
152
185
  const card = body ?? {};
153
- const v = validateJobCard(card);
186
+ const v = validateJobCard(card, { jobsDir: opts.jobsDir, rows: rowsForHome(keysHome(opts.home)) });
154
187
  if (!v.ok) { send(400, { ok: false, error: v.error }); return true; }
155
- const session = createSession(card, { env, sessionsRoot: opts.sessionsRoot });
188
+ const session = createSession(card, { env: envNow(), sessionsRoot: opts.sessionsRoot, ...(opts.home !== undefined ? { home: opts.home } : {}) });
156
189
  sessions.set(session.id, session);
157
190
  send(200, { ok: true, sessionId: session.id, state: session.state });
158
191
  return true;
@@ -192,7 +225,7 @@ export function createAuthorRoutes(opts) {
192
225
  }
193
226
  if (sub === 'sign') {
194
227
  const claimedHash = String(body?.specHash ?? '');
195
- const r = signRun(session, claimedHash, { env, spawnFn, bareloopBin, sessionsRoot: opts.sessionsRoot });
228
+ const r = signRun(session, claimedHash, { env: envNow(), spawnFn, bareloopBin, sessionsRoot: opts.sessionsRoot, home: opts.home });
196
229
  send(r.ok ? 200 : 400, r);
197
230
  return true;
198
231
  }
@@ -234,13 +267,25 @@ function panelMoney2(n) {
234
267
  * it to sign"), and this is where it is checked, not trusted.
235
268
  * @param {ReturnType<typeof createSession>} session
236
269
  * @param {string} claimedHash
237
- * @param {{env: any, spawnFn: typeof realSpawn, bareloopBin: string, sessionsRoot?: string}} o
270
+ * @param {{env: any, spawnFn: typeof realSpawn, bareloopBin: string, sessionsRoot?: string, home?: string}} o
238
271
  * @returns {{ok: boolean, error?: string, job?: string}}
239
272
  */
240
273
  export function signRun(session, claimedHash, o) {
241
274
  if (session.state.phase !== 'prepared') return { ok: false, error: `session is not prepared (phase=${session.state.phase}) — nothing to sign` };
242
275
  if (!session.state.specHash || claimedHash !== session.state.specHash) return { ok: false, error: 'spec hash mismatch — refusing to sign' };
243
276
  if (!session.state.resolvedSpecPath) return { ok: false, error: 'no resolved spec on disk — refusing to sign' };
277
+ // P4a — the monthly $ limit REFUSES here too, server-side, before anything is spawned
278
+ // (the page's note is a courtesy; this is the check). Same library call, same text as
279
+ // the run-start seam in src/userrun.js. The session stays `prepared`, so the person can
280
+ // lower nothing (the cap is signed in) but may raise the limit in Settings and sign again.
281
+ try {
282
+ const spec = JSON.parse(readFileSync(session.state.resolvedSpecPath, 'utf8'));
283
+ const refusal = monthlyRefusalText(checkMonthlyRoom({ capUsd: Number(spec.budgetUsd), home: o.home }));
284
+ if (refusal !== null) return { ok: false, error: refusal };
285
+ } catch (e) {
286
+ if (e instanceof ConfigError) return { ok: false, error: `${e.message} — refusing rather than guess the monthly limit` };
287
+ return { ok: false, error: 'could not read the signed spec to check the monthly limit — refusing to sign' };
288
+ }
244
289
  const logFile = join(session.state.outDir, 'run.log');
245
290
  // array argv, never a shell string — `--approve <hash>` is a literal
246
291
  // element, checkable byte-for-byte by a test stubbing `spawnFn`, and there