bareloop 0.30.0 → 0.32.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 (46) hide show
  1. package/CHANGELOG.md +154 -0
  2. package/bareloop.context.md +205 -10
  3. package/bin/bareloop.mjs +0 -1
  4. package/package.json +1 -1
  5. package/src/authorflow.js +22 -42
  6. package/src/authorjob.js +5 -8
  7. package/src/authorrun.js +42 -16
  8. package/src/cli.js +12 -6
  9. package/src/config.js +135 -0
  10. package/src/interviewrun.js +10 -2
  11. package/src/keysfile.js +156 -0
  12. package/src/ledger.js +26 -0
  13. package/src/monthly.js +352 -0
  14. package/src/panel/authorroutes.js +328 -0
  15. package/src/panel/authorsession.js +555 -0
  16. package/src/panel/index.html +1247 -50
  17. package/src/panel/server.js +449 -51
  18. package/src/panel/settingsroutes.js +230 -0
  19. package/src/planrun.js +61 -5
  20. package/src/promptregisters.js +3 -0
  21. package/src/providerrows.js +217 -0
  22. package/src/providers.js +84 -0
  23. package/src/replay.js +80 -2
  24. package/src/replayio.js +14 -3
  25. package/src/run.js +48 -2
  26. package/src/runlist.js +13 -0
  27. package/src/userrun.js +134 -13
  28. package/types/authorflow.d.ts +4 -21
  29. package/types/authorrun.d.ts +5 -0
  30. package/types/cli.d.ts +2 -1
  31. package/types/config.d.ts +48 -0
  32. package/types/interviewrun.d.ts +5 -0
  33. package/types/keysfile.d.ts +90 -0
  34. package/types/ledger.d.ts +14 -0
  35. package/types/monthly.d.ts +192 -0
  36. package/types/panel/authorroutes.d.ts +67 -0
  37. package/types/panel/authorsession.d.ts +53 -0
  38. package/types/panel/server.d.ts +42 -5
  39. package/types/panel/settingsroutes.d.ts +14 -0
  40. package/types/providerrows.d.ts +164 -0
  41. package/types/providers.d.ts +34 -0
  42. package/types/replay.d.ts +31 -0
  43. package/types/replayio.d.ts +12 -1
  44. package/types/run.d.ts +26 -1
  45. package/types/runlist.d.ts +1 -0
  46. package/types/userrun.d.ts +22 -0
package/src/monthly.js ADDED
@@ -0,0 +1,352 @@
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
+ // 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`).
18
+ //
19
+ // Honesty: a run whose spend is not fully known (a died/still-running spine, an unpriced
20
+ // round, a missing spine file) makes the month total an "at least" figure — never a clean
21
+ // 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 { readConfig, configPath, ConfigError } from './config.js';
25
+ import { readRunList, DIED_MTIME_MS } from './runlist.js';
26
+ import { parseJsonl } from './replayio.js';
27
+ import { SPEND_RECORD_TYPES, spendProvenance, floorsFromRecords } from './ledger.js';
28
+ import { findRow } from './providerrows.js';
29
+
30
+ // The Money tab's per-provider breakdown groups a run by the endpoint it ran on and names
31
+ // the groups it knows the vendor of. This is HISTORY labelling for spend already recorded
32
+ // (an archived run keeps its provider whatever Settings holds today) — not the Providers
33
+ // rows, which come from the keys file (src/providerrows.js).
34
+ /** @type {readonly {id: string, name: string}[]} */
35
+ const MONEY_LABELS = Object.freeze([
36
+ { id: 'anthropic', name: 'Anthropic' }, { id: 'openai', name: 'OpenAI' },
37
+ { id: 'gemini', name: 'Gemini' }, { id: 'deepseek', name: 'DeepSeek' },
38
+ ]);
39
+
40
+ /**
41
+ * Which known vendor a run's `(provider, baseUrl)` belongs to. `openai-api` with no override
42
+ * is OpenAI; with DeepSeek's host it is DeepSeek; with any OTHER override it is none (null —
43
+ * never silently pooled into OpenAI's figures).
44
+ * @param {string|null|undefined} provider
45
+ * @param {string|null|undefined} baseUrl
46
+ * @returns {string|null} a MONEY_LABELS id, or null
47
+ */
48
+ function rowIdFor(provider, baseUrl) {
49
+ if (provider === 'anthropic-api') return 'anthropic';
50
+ if (provider === 'gemini-api') return 'gemini';
51
+ if (provider === 'openai-api') {
52
+ if (!baseUrl) return 'openai';
53
+ try {
54
+ const host = new URL(baseUrl).hostname;
55
+ if (host === 'api.deepseek.com') return 'deepseek';
56
+ } catch { /* an unparseable override is no known vendor */ }
57
+ return null;
58
+ }
59
+ return null;
60
+ }
61
+
62
+ /**
63
+ * One run's own spend, off its spine records: this LEG's figure (never the chain fold),
64
+ * plus the drafting spend the run was signed with (counted once — on the first leg only).
65
+ * @param {any[]} records
66
+ * @returns {{ usd: number, complete: boolean }}
67
+ */
68
+ export function legSpend(records) {
69
+ const jobStart = records.find((r) => r && r.type === 'job-start') ?? null;
70
+ const jobEnd = records.findLast((r) => r && r.type === 'job-end') ?? null;
71
+ const rounds = records.filter((r) => r && SPEND_RECORD_TYPES.includes(r.type));
72
+ const pricedSum = rounds.reduce((a, r) => (typeof r.costUsd === 'number' && Number.isFinite(r.costUsd) ? a + r.costUsd : a), 0);
73
+ const unpriced = rounds.filter((r) => !(typeof r.costUsd === 'number' && Number.isFinite(r.costUsd))).length;
74
+ let usd = pricedSum;
75
+ let complete = false;
76
+ if (jobEnd) {
77
+ const eng = jobEnd.engagementSpentUsd;
78
+ usd = typeof eng === 'number' && Number.isFinite(eng) ? eng : pricedSum;
79
+ complete = jobEnd.spendComplete === true && unpriced === 0;
80
+ }
81
+ // drafting spend: a RESUMED / door-rerun leg carries the flag again but the first leg already counted it
82
+ const resumed = !!jobStart && 'priorSpentUsd' in jobStart;
83
+ if (jobStart && !resumed && typeof jobStart.draftSpentUsd === 'number' && Number.isFinite(jobStart.draftSpentUsd) && jobStart.draftSpentUsd > 0) {
84
+ usd += jobStart.draftSpentUsd;
85
+ if (jobStart.draftSpendComplete === false) complete = false;
86
+ }
87
+ return { usd, complete };
88
+ }
89
+
90
+ /**
91
+ * One run's wall time off its spine: job-start -> job-end when the run ended (exact), else the
92
+ * first -> last record floor (the SAME floor a died / still-running run's "at least" wall uses,
93
+ * `floorsFromRecords`). `ms` null = unknown — never 0.
94
+ * @param {any[]} records
95
+ * @returns {{ ms: number|null, complete: boolean }}
96
+ */
97
+ export function legWall(records) {
98
+ const jobStart = records.find((r) => r && r.type === 'job-start') ?? null;
99
+ const jobEnd = records.findLast((r) => r && r.type === 'job-end') ?? null;
100
+ const a = Date.parse(jobStart?.ts);
101
+ const b = Date.parse(jobEnd?.ts);
102
+ if (jobEnd && Number.isFinite(a) && Number.isFinite(b) && b >= a) return { ms: b - a, complete: true };
103
+ return { ms: floorsFromRecords(records).wallFloorMs, complete: false };
104
+ }
105
+
106
+ /**
107
+ * Tokens a spine's rounds processed (input + output + cache read + cache write — all four,
108
+ * so a cache-heavy run is not under-reported), off the real `usage` field names.
109
+ * @param {any[]} records
110
+ * @returns {number}
111
+ */
112
+ export function legTokens(records) {
113
+ let n = 0;
114
+ for (const r of records) {
115
+ if (!r || !SPEND_RECORD_TYPES.includes(r.type) || !r.usage) continue;
116
+ for (const k of ['inputTokens', 'outputTokens', 'cacheReadTokens', 'cacheCreationTokens']) {
117
+ const v = r.usage[k];
118
+ if (typeof v === 'number' && Number.isFinite(v)) n += v;
119
+ }
120
+ }
121
+ return n;
122
+ }
123
+
124
+ /**
125
+ * @typedef {object} Leg
126
+ * @property {Date} at when the run was listed
127
+ * @property {string|null} provider job-start's provider (null = an older spine that carried none)
128
+ * @property {string|null} baseUrl
129
+ * @property {number} usd this leg's spend (a floor when `!complete`)
130
+ * @property {boolean} complete every figure behind `usd` is known
131
+ * @property {number} tokens
132
+ * @property {string|null} model job-start's model (older spines carry a model but no provider)
133
+ * @property {number|null} wallMs this leg's wall time (null = unknown; a floor when `!wallComplete`)
134
+ * @property {boolean} wallComplete the wall is the exact job-start -> job-end span
135
+ * @property {number} vouchedRounds rounds priced by a rate somebody vouched for (`provider`/`caller`)
136
+ * @property {number} otherRounds every other round: a built-in guess, unpriced, or no provenance on record
137
+ * @property {number} reservedUsd what the refusal check counts: `usd`, or the leg's cap when the run is in flight
138
+ * @property {boolean} unreadable the spine was missing/unreadable or the listed date was bad (spend unknown)
139
+ */
140
+
141
+ /**
142
+ * 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
+ * spend and the leg's own cap — the signed `budgetUsd` on the spine's `job-start` less the fold
145
+ * a resumed leg inherited (`priorSpentUsd`), the same figure the run-start seam computes
146
+ * (`legCapUsd`, src/userrun.js). A spine that records no `budgetUsd` reserves nothing extra.
147
+ * @param {any[]} records
148
+ * @param {string} spinePath
149
+ * @param {number} nowMs
150
+ * @param {number} usd the leg's spend so far
151
+ * @returns {number}
152
+ */
153
+ function inFlightCapUsd(records, spinePath, nowMs, usd) {
154
+ if (records.some((r) => r && r.type === 'job-end')) return usd;
155
+ const start = records.find((r) => r && r.type === 'job-start') ?? null;
156
+ 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
160
+ const fold = typeof start.priorSpentUsd === 'number' && Number.isFinite(start.priorSpentUsd) ? start.priorSpentUsd : 0;
161
+ return Math.max(usd, Math.max(0, start.budgetUsd - fold));
162
+ }
163
+
164
+ /**
165
+ * Every listed run as one {@link Leg}. The ONE reader the month total, the all-time total
166
+ * 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)
168
+ * @returns {Leg[]}
169
+ */
170
+ export function readLegs(opts = {}) {
171
+ const nowMs = (opts.now ?? Date.now)();
172
+ const { rows } = readRunList({ home: opts.home });
173
+ const resuming = typeof opts.resumingSpine === 'string' ? resolve(opts.resumingSpine) : null;
174
+ /** @type {Leg[]} */
175
+ const legs = [];
176
+ for (const row of rows) {
177
+ const at = new Date(row.at);
178
+ 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; }
180
+ /** @type {any[]} */
181
+ let records;
182
+ try { records = parseJsonl(row.spine).records; } catch { legs.push(base); continue; }
183
+ const start = records.find((r) => r && r.type === 'job-start') ?? null;
184
+ const leg = legSpend(records);
185
+ const prov = spendProvenance(records);
186
+ const wall = legWall(records);
187
+ legs.push({
188
+ at,
189
+ model: typeof start?.model === 'string' ? start.model : null,
190
+ wallMs: wall.ms,
191
+ wallComplete: wall.complete,
192
+ provider: typeof start?.provider === 'string' ? start.provider : null,
193
+ baseUrl: typeof start?.baseUrl === 'string' ? start.baseUrl : null,
194
+ usd: leg.usd,
195
+ complete: leg.complete,
196
+ tokens: legTokens(records),
197
+ vouchedRounds: prov.vouched.rounds,
198
+ 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),
200
+ unreadable: false,
201
+ });
202
+ }
203
+ return legs;
204
+ }
205
+
206
+ /**
207
+ * @param {Date} at
208
+ * @param {Date} now
209
+ * @returns {boolean} same LOCAL calendar month
210
+ */
211
+ const sameLocalMonth = (at, now) => at.getFullYear() === now.getFullYear() && at.getMonth() === now.getMonth();
212
+
213
+ /**
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)
216
+ */
217
+ export function monthSpend(opts = {}) {
218
+ const nowDate = new Date((opts.now ?? Date.now)());
219
+ let usd = 0;
220
+ let reservedUsd = 0;
221
+ let atLeast = false;
222
+ let runs = 0;
223
+ for (const leg of readLegs({ home: opts.home, now: opts.now, resumingSpine: opts.resumingSpine })) {
224
+ // an unreadable date could belong to this month — unknown, never dropped
225
+ if (Number.isNaN(leg.at.getTime())) { atLeast = true; continue; }
226
+ if (!sameLocalMonth(leg.at, nowDate)) continue;
227
+ runs += 1;
228
+ if (leg.unreadable) { atLeast = true; continue; }
229
+ usd += leg.usd;
230
+ reservedUsd += leg.reservedUsd;
231
+ if (!leg.complete) atLeast = true;
232
+ }
233
+ return { usd, atLeast, runs, reservedUsd };
234
+ }
235
+
236
+ /**
237
+ * The Money tab's figures: all-time and this-month spend, and per-provider breakdown
238
+ * (rows from `rowIdFor`; a run on no known row lands under its own provider name, never
239
+ * pooled into another row). Any leg with unknown spend makes the figure it belongs to
240
+ * an "at least".
241
+ * @param {{ home?: string, now?: () => number, rows?: readonly import('./providerrows.js').KeyRow[] }} [opts] `rows` = the Providers rows: when given, `tokensByRow` sums each row's tokens
242
+ * @returns {{ tokensByRow: Record<string, number>, total: {usd: number, atLeast: boolean, tokens: number}, month: {usd: number, atLeast: boolean, tokens: number},
243
+ * 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}> }}
244
+ */
245
+ export function spendSummary(opts = {}) {
246
+ const nowDate = new Date((opts.now ?? Date.now)());
247
+ const total = { usd: 0, atLeast: false, tokens: 0 };
248
+ const month = { usd: 0, atLeast: false, tokens: 0 };
249
+ /** @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}>} */
250
+ const byProvider = {};
251
+ /** runs per provider in each scope, and how many had a known wall — unknown is never 0
252
+ * @type {Record<string, {monthRuns: number, monthKnown: number, totalKnown: number}>} */
253
+ const wallSeen = {};
254
+ /** total tokens of the runs each Providers row served (matched by shape + endpoint + model) @type {Record<string, number>} */
255
+ const tokensByRow = {};
256
+ for (const leg of readLegs({ home: opts.home })) {
257
+ let served = opts.rows ? findRow(opts.rows, { provider: leg.provider, baseUrl: leg.baseUrl, model: leg.model }) : null;
258
+ // an older spine records a model but no provider: it counts toward the row whose Name EXACTLY
259
+ // equals that model. Two rows sharing the Name make it ambiguous, so it counts toward neither.
260
+ if (opts.rows && leg.provider === null && leg.model !== null) {
261
+ const named = opts.rows.filter((r) => r.name === leg.model);
262
+ served = named.length === 1 ? named[0] : null;
263
+ }
264
+ if (served) tokensByRow[served.envName] = (tokensByRow[served.envName] ?? 0) + leg.tokens;
265
+ const inMonth = Number.isNaN(leg.at.getTime()) ? true : sameLocalMonth(leg.at, nowDate);
266
+ const id = rowIdFor(leg.provider, leg.baseUrl);
267
+ // an older spine carries a model but no provider: say so honestly, one row per model —
268
+ // never a provider guessed from the model's name
269
+ const notRecorded = id === null && leg.provider === null;
270
+ const key = id ?? (notRecorded ? `not-recorded:${leg.model ?? ''}` : `other:${leg.provider}${leg.baseUrl ? ` @ ${leg.baseUrl}` : ''}`);
271
+ const label = id ? (MONEY_LABELS.find((r) => r.id === id)?.name ?? id)
272
+ : (notRecorded ? (leg.model ? `not recorded (model ${leg.model})` : 'not recorded') : key.slice('other:'.length));
273
+ 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 });
274
+ const w = (wallSeen[key] ??= { monthRuns: 0, monthKnown: 0, totalKnown: 0 });
275
+ const unknown = leg.unreadable || !leg.complete;
276
+ const wallKnown = !leg.unreadable && leg.wallMs !== null;
277
+ const wallAtLeast = !wallKnown || !leg.wallComplete;
278
+ total.usd += leg.usd; total.tokens += leg.tokens; if (unknown) total.atLeast = true;
279
+ p.totalUsd += leg.usd; p.tokens += leg.tokens; p.vouchedRounds += leg.vouchedRounds; p.otherRounds += leg.otherRounds; if (unknown) p.totalAtLeast = true;
280
+ if (wallKnown) { p.totalWallMs = /** @type {number} */ (p.totalWallMs) + /** @type {number} */ (leg.wallMs); w.totalKnown += 1; }
281
+ if (wallAtLeast) p.totalWallAtLeast = true;
282
+ if (inMonth) {
283
+ month.usd += leg.usd; month.tokens += leg.tokens; if (unknown) month.atLeast = true;
284
+ p.monthUsd += leg.usd; if (unknown) p.monthAtLeast = true;
285
+ w.monthRuns += 1;
286
+ if (wallKnown) { p.monthWallMs = /** @type {number} */ (p.monthWallMs) + /** @type {number} */ (leg.wallMs); w.monthKnown += 1; }
287
+ if (wallAtLeast) p.monthWallAtLeast = true;
288
+ }
289
+ }
290
+ // runs in scope but not one with a known wall = unknown (null), never a clean 0
291
+ for (const [key, p] of Object.entries(byProvider)) {
292
+ const w = wallSeen[key];
293
+ if (w.totalKnown === 0) p.totalWallMs = null;
294
+ if (w.monthRuns > 0 && w.monthKnown === 0) p.monthWallMs = null;
295
+ }
296
+ return { tokensByRow, total, month, byProvider };
297
+ }
298
+
299
+ /**
300
+ * @typedef {object} MonthlyRoom
301
+ * @property {boolean} ok
302
+ * @property {number|null} leftUsd what is left this month (null = no limit set)
303
+ * @property {number|null} limitUsd the person's monthly limit (null = none)
304
+ * @property {boolean} atLeast the month's spend is a floor, so `leftUsd` is a ceiling
305
+ */
306
+
307
+ /**
308
+ * The person's monthly limit off a parsed config.json: key absent (or null) = no limit (null);
309
+ * a number above 0 = the limit; ANY other value present is a broken setting and throws
310
+ * `ConfigError` — a limit the file names but this code cannot read never reads as "no limit".
311
+ * @param {Record<string, any>} config
312
+ * @param {string} [home]
313
+ * @returns {number|null}
314
+ */
315
+ export function monthlyLimitOf(config, home) {
316
+ const limit = config.monthlyLimitUsd;
317
+ if (limit === undefined || limit === null) return null;
318
+ if (typeof limit === 'number' && Number.isFinite(limit) && limit > 0) return limit;
319
+ const shown = typeof limit === 'number' ? String(limit) : JSON.stringify(limit);
320
+ throw new ConfigError(`${configPath(home)} has monthlyLimitUsd = ${shown}, which is not a number above 0 — fix it or remove the line`);
321
+ }
322
+
323
+ /**
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
325
+ * number above 0 throws `ConfigError`.
326
+ * Compared in whole cents. Also throws `ConfigError` when config.json is unreadable — a gate
327
+ * 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
329
+ * @returns {MonthlyRoom}
330
+ */
331
+ export function checkMonthlyRoom({ capUsd, home, now, resumingSpine }) {
332
+ const cfg = readConfig({ home });
333
+ if (cfg.problem) throw new ConfigError(cfg.problem);
334
+ const limit = monthlyLimitOf(cfg.config, home);
335
+ if (limit === null) {
336
+ return { ok: true, leftUsd: null, limitUsd: null, atLeast: false };
337
+ }
338
+ const spent = monthSpend({ home, now, resumingSpine });
339
+ 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 };
342
+ }
343
+
344
+ /**
345
+ * The ONE refusal text, everywhere (CLI, panel cap note, panel Sign refusal).
346
+ * @param {MonthlyRoom} room
347
+ * @returns {string|null} null when the run fits
348
+ */
349
+ export function monthlyRefusalText(room) {
350
+ if (room.ok || room.leftUsd === null) return null;
351
+ return `Max $${room.leftUsd.toFixed(2)} (monthly limit)`;
352
+ }