bareloop 0.33.0 → 0.35.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 (56) hide show
  1. package/CHANGELOG.md +47 -0
  2. package/bareloop.context.md +263 -27
  3. package/package.json +1 -1
  4. package/src/bundlerun.js +13 -4
  5. package/src/draftspend.js +82 -0
  6. package/src/exits.js +10 -1
  7. package/src/interviewrun.js +10 -9
  8. package/src/job.js +39 -2
  9. package/src/judged.js +25 -5
  10. package/src/kinds.js +2 -0
  11. package/src/ledger.js +104 -9
  12. package/src/legs.js +171 -0
  13. package/src/monthly.js +133 -87
  14. package/src/panel/authorroutes.js +95 -15
  15. package/src/panel/authorsession.js +408 -113
  16. package/src/panel/importroutes.js +359 -0
  17. package/src/panel/importrun.js +203 -0
  18. package/src/panel/index.html +1071 -184
  19. package/src/panel/runroutes.js +219 -0
  20. package/src/panel/server.js +855 -81
  21. package/src/panel/status.js +51 -0
  22. package/src/planrun.js +63 -31
  23. package/src/providerrows.js +21 -2
  24. package/src/providers.js +4 -4
  25. package/src/ralph.js +3 -0
  26. package/src/replay.js +139 -19
  27. package/src/replayio.js +11 -4
  28. package/src/reuse.js +20 -3
  29. package/src/run.js +6 -3
  30. package/src/runlist.js +90 -7
  31. package/src/text.js +20 -0
  32. package/src/u-readout.js +9 -1
  33. package/src/u-watchdog.mjs +4 -2
  34. package/src/userrun.js +166 -65
  35. package/types/draftspend.d.ts +53 -0
  36. package/types/job.d.ts +25 -0
  37. package/types/judged.d.ts +5 -2
  38. package/types/ledger.d.ts +44 -0
  39. package/types/legs.d.ts +103 -0
  40. package/types/monthly.d.ts +39 -22
  41. package/types/panel/authorroutes.d.ts +38 -1
  42. package/types/panel/authorsession.d.ts +109 -2
  43. package/types/panel/importroutes.d.ts +129 -0
  44. package/types/panel/importrun.d.ts +77 -0
  45. package/types/panel/runroutes.d.ts +32 -0
  46. package/types/panel/server.d.ts +255 -2
  47. package/types/panel/status.d.ts +51 -0
  48. package/types/planrun.d.ts +13 -21
  49. package/types/providerrows.d.ts +14 -1
  50. package/types/providers.d.ts +1 -2
  51. package/types/replay.d.ts +25 -1
  52. package/types/replayio.d.ts +2 -2
  53. package/types/reuse.d.ts +5 -0
  54. package/types/run.d.ts +6 -2
  55. package/types/runlist.d.ts +66 -2
  56. package/types/text.d.ts +19 -0
package/src/monthly.js CHANGED
@@ -14,19 +14,24 @@
14
14
  // run is counted at max(spend so far, capUsd) however quiet its spine; a run with a `job-end` counts
15
15
  // its real spend; a claim whose process is gone counts its floor and is settled — with an attributed
16
16
  // `settled` entry — by whichever later run finds it (the first note wins). An older row with no `pid` keeps the spine
17
- // mtime rule (a fresh spine, written to within DIED_MTIME_MS, is in flight). Only the refusal check
17
+ // mtime rule (a fresh spine, written to within DIED_MTIME_MS, is in flight) — `runIsAlive`, src/runlist.js, the
18
+ // one "is this run alive" rule the panel's glyph shares. Only the refusal check
18
19
  // reserves; the Money tab keeps showing real spend (`usd`).
19
20
  //
20
21
  // Honesty: a run whose spend is not fully known (a died/still-running spine, an unpriced
21
22
  // round, a missing spine file) makes the month total an "at least" figure — never a clean
22
- // number. The refusal text stays exactly `Max $X (monthly limit)`; `atLeast` travels beside it.
23
- import { existsSync, statSync } from 'node:fs';
23
+ // number. The refusal text leads with `Max $X (monthly limit`; when runs hold money it adds
24
+ // `, $Y held by a run in progress` before the closing paren (`monthlyRefusalText`). `atLeast` travels beside it.
25
+ import { existsSync } from 'node:fs';
24
26
  import { readConfig, configPath, ConfigError } from './config.js';
25
- import { readRunList, appendRun, appendRunEvent, isLiveRunner, DIED_MTIME_MS } from './runlist.js';
26
- export { isLiveRunner };
27
+ import { readRunList, appendRun, appendLegStart, appendRunEvent, isLiveRunner, runIsAlive, isSettled } from './runlist.js';
28
+ import { legsOf } from './legs.js';
27
29
  import { parseJsonl } from './replayio.js';
28
- import { SPEND_RECORD_TYPES, spendProvenance, floorsFromRecords } from './ledger.js';
30
+ import { SPEND_RECORD_TYPES, spendProvenance, floorsFromRecords, legSpend, runSpend, chainSpend } from './ledger.js';
29
31
  import { findRow } from './providerrows.js';
32
+ import { readOrphanDraftSpends } from './draftspend.js';
33
+ // the per-leg / per-run spend readers live with the ledger (one home, no import cycle through the replay reader)
34
+ export { legSpend, runSpend, chainSpend };
30
35
 
31
36
  // The Money tab's per-provider breakdown groups a run by the endpoint it ran on and names
32
37
  // the groups it knows the vendor of. This is HISTORY labelling for spend already recorded
@@ -61,41 +66,35 @@ function rowIdFor(provider, baseUrl) {
61
66
  }
62
67
 
63
68
  /**
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 }}
69
+ * One run's wall time off its spine: job-start -> job-end when the run ended (exact), else the
70
+ * first -> last record floor (the SAME floor a died / still-running run's "at least" wall uses,
71
+ * `floorsFromRecords`). `ms` null = unknown — never 0.
72
+ * @param {any[]} records one spine's records (one leg or several)
73
+ * @returns {{ ms: number|null, complete: boolean }}
68
74
  */
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;
75
+ export function legWall(records) {
76
+ const legs = legsOf(records);
77
+ if (legs.length > 1) {
78
+ // P5-R: the sum of each leg's own working window, the gap between legs excluded (hamr 2026-10-02, 2A)
79
+ let ms = 0;
80
+ let complete = true;
81
+ for (const l of legs) {
82
+ const w = legWallOne(l.records);
83
+ if (w.ms === null) { complete = false; continue; }
84
+ ms += w.ms;
85
+ if (!w.complete) complete = false;
86
+ }
87
+ return { ms: legs.every((l) => legWallOne(l.records).ms === null) ? null : ms, complete };
87
88
  }
88
- return { usd, complete };
89
+ return legWallOne(records);
89
90
  }
90
91
 
91
92
  /**
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.
93
+ * One LEG's wall (the single-leg rule {@link legWall} sums).
95
94
  * @param {any[]} records
96
95
  * @returns {{ ms: number|null, complete: boolean }}
97
96
  */
98
- export function legWall(records) {
97
+ function legWallOne(records) {
99
98
  const jobStart = records.find((r) => r && r.type === 'job-start') ?? null;
100
99
  const jobEnd = records.findLast((r) => r && r.type === 'job-end') ?? null;
101
100
  const a = Date.parse(jobStart?.ts);
@@ -137,27 +136,26 @@ export function legTokens(records) {
137
136
  * @property {number} otherRounds every other round: a built-in guess, unpriced, or no provenance on record
138
137
  * @property {number} reservedUsd what the refusal check counts: `usd`, or the leg's cap when the run is in flight
139
138
  * @property {boolean} unreadable the spine was missing/unreadable or the listed date was bad (spend unknown)
139
+ * @property {boolean} [draft] drafting spend of a panel session that never became a run (src/draftspend.js) — not a run: no wall, not counted in `runs`
140
140
  */
141
141
 
142
142
  /**
143
143
  * What the refusal check counts for one run: its spend, unless it is IN FLIGHT (`job-start`, no
144
- * `job-end`, spine written to within DIED_MTIME_MS of `nowMs`), when it is the larger of that
144
+ * `job-end`, `runIsAlive` for a row with no pid — the pid-bearing rows are held by `readLegs`), when it is the larger of that
145
145
  * spend and the leg's own cap — the signed `budgetUsd` on the spine's `job-start` less the fold
146
146
  * a resumed leg inherited (`priorSpentUsd`), the same figure the run-start seam computes
147
147
  * (`legCapUsd`, src/userrun.js). A spine that records no `budgetUsd` reserves nothing extra.
148
- * @param {any[]} records
149
- * @param {string} spinePath
148
+ * @param {any[]} records the LATEST leg's records
149
+ * @param {import('./runlist.js').RunRow} row
150
150
  * @param {number} nowMs
151
151
  * @param {number} usd the leg's spend so far
152
152
  * @returns {number}
153
153
  */
154
- function inFlightCapUsd(records, spinePath, nowMs, usd) {
154
+ function inFlightCapUsd(records, row, nowMs, usd) {
155
155
  if (records.some((r) => r && r.type === 'job-end')) return usd;
156
156
  const start = records.find((r) => r && r.type === 'job-start') ?? null;
157
157
  if (!start || !(typeof start.budgetUsd === 'number' && Number.isFinite(start.budgetUsd))) return usd;
158
- let mtimeMs;
159
- try { mtimeMs = statSync(spinePath).mtimeMs; } catch { return usd; }
160
- if (nowMs - mtimeMs > DIED_MTIME_MS) return usd; // stale — died, counts its floor
158
+ if (!runIsAlive(row, nowMs)) return usd; // stale — died, counts its floor
161
159
  const fold = typeof start.priorSpentUsd === 'number' && Number.isFinite(start.priorSpentUsd) ? start.priorSpentUsd : 0;
162
160
  return Math.max(usd, Math.max(0, start.budgetUsd - fold));
163
161
  }
@@ -172,20 +170,22 @@ function inFlightCapUsd(records, spinePath, nowMs, usd) {
172
170
  function rowsAbove(rows, runid) {
173
171
  if (runid === undefined) return rows;
174
172
  const i = rows.findIndex((r) => r.runid === runid);
175
- return i < 0 ? rows : rows.slice(0, i);
173
+ if (i < 0) return rows;
174
+ // a RESUMED run's earlier legs are already spent money in this month: its own row stays in (its spend
175
+ // counts, its claim does not — `readLegs`' `ownClaim`). A first-leg claim has no earlier legs to count.
176
+ return rows[i].leg !== undefined && rows[i].leg > 1 ? rows.slice(0, i + 1) : rows.slice(0, i);
176
177
  }
177
178
 
178
179
  /**
179
180
  * Every listed run as one {@link Leg}. The ONE reader the month total, the all-time total
180
181
  * 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
+ * @param {{ home?: string, sessionsRoot?: string, now?: () => number, aboveRunid?: string, thisMonthOnly?: boolean }} [opts] `sessionsRoot` = where the panel's sessions live (default `<home>/panel-sessions`); `now` = the clock for the no-pid in-flight test; `aboveRunid` = read only the runs listed EARLIER in the file than that run (a claim yields to the claims above it, never below); `thisMonthOnly` = do not parse the spine of a run listed in another local month (the refusal check drops it anyway) unless its claim is still held
182
183
  * @returns {Leg[]}
183
184
  */
184
185
  export function readLegs(opts = {}) {
185
186
  const nowMs = (opts.now ?? Date.now)();
186
187
  const { rows: allRows, events } = readRunList({ home: opts.home });
187
188
  const rows = rowsAbove(allRows, opts.aboveRunid);
188
- const settled = new Set(events.filter((e) => e.type === 'settled').map((e) => e.runid));
189
189
  /** @type {Leg[]} */
190
190
  const legs = [];
191
191
  for (const row of rows) {
@@ -195,30 +195,60 @@ export function readLegs(opts = {}) {
195
195
  // pid is a live bareloop runner, however quiet its spine — killed = process gone, not silence.
196
196
  // An older row with no `pid` keeps the spine-mtime rule (`inFlightCapUsd`), never guessed alive.
197
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;
198
+ // a resume's OWN claim never holds against itself: its earlier legs' spend counts, its cap is what is being checked
199
+ const ownClaim = opts.aboveRunid !== undefined && row.runid === opts.aboveRunid;
200
+ const heldCap = !ownClaim && hasPid && !isSettled(events, row) && typeof row.capUsd === 'number' && Number.isFinite(row.capUsd) && runIsAlive(row, nowMs) ? row.capUsd : null;
201
+ // a RESUMED run (`row.leg`) may hold a leg of this month under a start date of another: read its spine
202
+ if (opts.thisMonthOnly && heldCap === null && row.leg === undefined && !Number.isNaN(at.getTime()) && !sameLocalMonth(at, new Date(nowMs))) continue;
199
203
  if (Number.isNaN(at.getTime()) || !existsSync(row.spine)) { legs.push({ ...base, reservedUsd: heldCap ?? 0 }); continue; }
200
204
  /** @type {any[]} */
201
205
  let records;
202
206
  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
+ // P5-R: a resumed run is ONE spine of several legs and ONE listed row. The month and the Money tab still
208
+ // count each leg on its own date with its own spend (the figures they always read), so a leg resumed in a
209
+ // later month is that month's, never silently folded back into the month the run began in. The claim (the
210
+ // hold) belongs to the LATEST leg only.
211
+ const found = legsOf(records);
212
+ const spineLegs = found.length > 0 ? found : [{ leg: 1, records, start: null, jobStart: null }];
213
+ const lastLegNo = spineLegs.length;
214
+ const first = records.find((r) => r && r.type === 'job-start') ?? null;
215
+ for (const sl of spineLegs) {
216
+ const lstart = sl.jobStart ?? first;
217
+ const leg = legSpend(sl.records);
218
+ const prov = spendProvenance(sl.records);
219
+ const wall = legWall(sl.records);
220
+ const isLast = sl.leg === lastLegNo;
221
+ const legAt = sl.leg === 1 ? at : new Date(sl.start?.ts ?? sl.start?.at ?? row.at);
222
+ const ended = sl.records.some((r) => r && r.type === 'job-end');
223
+ legs.push({
224
+ at: Number.isNaN(legAt.getTime()) ? at : legAt,
225
+ model: typeof lstart?.model === 'string' ? lstart.model : null,
226
+ wallMs: wall.ms,
227
+ wallComplete: wall.complete,
228
+ provider: typeof lstart?.provider === 'string' ? lstart.provider : null,
229
+ baseUrl: typeof lstart?.baseUrl === 'string' ? lstart.baseUrl : null,
230
+ usd: leg.usd,
231
+ complete: leg.complete,
232
+ tokens: legTokens(sl.records),
233
+ vouchedRounds: prov.vouched.rounds,
234
+ otherRounds: prov.guessed.rounds + prov.unpriced.rounds + prov.unknown.rounds,
235
+ reservedUsd: !isLast || ownClaim ? leg.usd
236
+ : hasPid ? (ended || heldCap === null ? leg.usd : Math.max(leg.usd, heldCap))
237
+ : inFlightCapUsd(sl.records, row, nowMs, leg.usd),
238
+ unreadable: false,
239
+ });
240
+ }
241
+ // a leg that CLAIMED (its `leg-start` is on the list) but has not yet written its marker: its claim still
242
+ // holds, exactly as a first leg's row does before its spine exists
243
+ if (row.leg !== undefined && row.leg > lastLegNo && heldCap !== null) legs.push({ ...base, reservedUsd: heldCap });
244
+ }
245
+ // drafting spend of panel sessions that never became a run (abandoned / refused / panel restarted): money
246
+ // already spent, counted by the SAME readers as a run's leg (src/draftspend.js — a signed run carries its own)
247
+ for (const d of readOrphanDraftSpends({ home: opts.home, sessionsRoot: opts.sessionsRoot })) {
207
248
  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,
249
+ at: new Date(d.startedAt), provider: d.provider, baseUrl: d.baseUrl, model: d.model, wallMs: null, wallComplete: false,
250
+ usd: d.spentUsd, complete: d.spendComplete, tokens: 0, vouchedRounds: 0, otherRounds: 0, reservedUsd: d.spentUsd,
251
+ unreadable: false, draft: true,
222
252
  });
223
253
  }
224
254
  return legs;
@@ -232,8 +262,8 @@ export function readLegs(opts = {}) {
232
262
  const sameLocalMonth = (at, now) => at.getFullYear() === now.getFullYear() && at.getMonth() === now.getMonth();
233
263
 
234
264
  /**
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)
265
+ * @param {{ home?: string, sessionsRoot?: string, now?: () => number, aboveRunid?: string }} [opts] `aboveRunid`, `sessionsRoot`: see {@link readLegs}
266
+ * @returns {{ usd: number, atLeast: boolean, runs: number, reservedUsd: number, heldUsd: number, heldRuns: number }} `usd` = real spend (the Money tab's figure); `reservedUsd` = the same with in-flight runs at their cap (the refusal check's); `heldUsd` = what in-progress runs set aside beyond what they have spent (`reservedUsd - usd`), over `heldRuns` runs
237
267
  */
238
268
  export function monthSpend(opts = {}) {
239
269
  const nowDate = new Date((opts.now ?? Date.now)());
@@ -241,17 +271,20 @@ export function monthSpend(opts = {}) {
241
271
  let reservedUsd = 0;
242
272
  let atLeast = false;
243
273
  let runs = 0;
244
- for (const leg of readLegs({ home: opts.home, now: opts.now, aboveRunid: opts.aboveRunid })) {
274
+ let heldUsd = 0;
275
+ let heldRuns = 0;
276
+ for (const leg of readLegs({ home: opts.home, sessionsRoot: opts.sessionsRoot, now: opts.now, aboveRunid: opts.aboveRunid, thisMonthOnly: true })) {
245
277
  // an unreadable date could belong to this month — unknown, never dropped
246
278
  if (Number.isNaN(leg.at.getTime())) { atLeast = true; continue; }
247
279
  if (!sameLocalMonth(leg.at, nowDate)) continue;
248
- runs += 1;
249
- if (leg.unreadable) { atLeast = true; reservedUsd += leg.reservedUsd; continue; }
280
+ if (!leg.draft) runs += 1;
281
+ if (leg.unreadable) { atLeast = true; reservedUsd += leg.reservedUsd; if (leg.reservedUsd > 0) { heldUsd += leg.reservedUsd; heldRuns += 1; } continue; }
250
282
  usd += leg.usd;
251
283
  reservedUsd += leg.reservedUsd;
284
+ if (leg.reservedUsd > leg.usd) { heldUsd += leg.reservedUsd - leg.usd; heldRuns += 1; }
252
285
  if (!leg.complete) atLeast = true;
253
286
  }
254
- return { usd, atLeast, runs, reservedUsd };
287
+ return { usd, atLeast, runs, reservedUsd, heldUsd, heldRuns };
255
288
  }
256
289
 
257
290
  /**
@@ -259,7 +292,7 @@ export function monthSpend(opts = {}) {
259
292
  * (rows from `rowIdFor`; a run on no known row lands under its own provider name, never
260
293
  * pooled into another row). Any leg with unknown spend makes the figure it belongs to
261
294
  * 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
295
+ * @param {{ home?: string, sessionsRoot?: string, now?: () => number, rows?: readonly import('./providerrows.js').KeyRow[] }} [opts] `rows` = the Providers rows: when given, `tokensByRow` sums each row's tokens
263
296
  * @returns {{ tokensByRow: Record<string, number>, total: {usd: number, atLeast: boolean, tokens: number}, month: {usd: number, atLeast: boolean, tokens: number},
264
297
  * 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
298
  */
@@ -274,7 +307,7 @@ export function spendSummary(opts = {}) {
274
307
  const wallSeen = {};
275
308
  /** total tokens of the runs each Providers row served (matched by shape + endpoint + model) @type {Record<string, number>} */
276
309
  const tokensByRow = {};
277
- for (const leg of readLegs({ home: opts.home })) {
310
+ for (const leg of readLegs({ home: opts.home, sessionsRoot: opts.sessionsRoot })) {
278
311
  let served = opts.rows ? findRow(opts.rows, { provider: leg.provider, baseUrl: leg.baseUrl, model: leg.model }) : null;
279
312
  // an older spine records a model but no provider: it counts toward the row whose Name EXACTLY
280
313
  // equals that model. Two rows sharing the Name make it ambiguous, so it counts toward neither.
@@ -293,9 +326,11 @@ export function spendSummary(opts = {}) {
293
326
  : (notRecorded ? (leg.model ? `not recorded (model ${leg.model})` : 'not recorded') : key.slice('other:'.length));
294
327
  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
328
  const w = (wallSeen[key] ??= { monthRuns: 0, monthKnown: 0, totalKnown: 0 });
329
+ // drafting spend has no wall (it is not a run): it never turns a provider's wall into an "at least" or an unknown
330
+ const noWall = leg.draft === true;
296
331
  const unknown = leg.unreadable || !leg.complete;
297
332
  const wallKnown = !leg.unreadable && leg.wallMs !== null;
298
- const wallAtLeast = !wallKnown || !leg.wallComplete;
333
+ const wallAtLeast = !noWall && (!wallKnown || !leg.wallComplete);
299
334
  total.usd += leg.usd; total.tokens += leg.tokens; if (unknown) total.atLeast = true;
300
335
  p.totalUsd += leg.usd; p.tokens += leg.tokens; p.vouchedRounds += leg.vouchedRounds; p.otherRounds += leg.otherRounds; if (unknown) p.totalAtLeast = true;
301
336
  if (wallKnown) { p.totalWallMs = /** @type {number} */ (p.totalWallMs) + /** @type {number} */ (leg.wallMs); w.totalKnown += 1; }
@@ -303,7 +338,7 @@ export function spendSummary(opts = {}) {
303
338
  if (inMonth) {
304
339
  month.usd += leg.usd; month.tokens += leg.tokens; if (unknown) month.atLeast = true;
305
340
  p.monthUsd += leg.usd; if (unknown) p.monthAtLeast = true;
306
- w.monthRuns += 1;
341
+ if (!noWall) w.monthRuns += 1;
307
342
  if (wallKnown) { p.monthWallMs = /** @type {number} */ (p.monthWallMs) + /** @type {number} */ (leg.wallMs); w.monthKnown += 1; }
308
343
  if (wallAtLeast) p.monthWallAtLeast = true;
309
344
  }
@@ -323,6 +358,8 @@ export function spendSummary(opts = {}) {
323
358
  * @property {number|null} leftUsd what is left this month (null = no limit set)
324
359
  * @property {number|null} limitUsd the person's monthly limit (null = none)
325
360
  * @property {boolean} atLeast the month's spend is a floor, so `leftUsd` is a ceiling
361
+ * @property {number} [heldUsd] what run(s) in progress have set aside beyond their spend so far (present when a limit is set)
362
+ * @property {number} [heldRuns] how many runs hold it
326
363
  */
327
364
 
328
365
  /**
@@ -346,30 +383,33 @@ export function monthlyLimitOf(config, home) {
346
383
  * number above 0 throws `ConfigError`.
347
384
  * Compared in whole cents. Also throws `ConfigError` when config.json is unreadable — a gate
348
385
  * 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)
386
+ * @param {{ capUsd: number, home?: string, sessionsRoot?: string, now?: () => number, aboveRunid?: string }} args `aboveRunid`: count only the claims listed above that run (a claim in progress)
350
387
  * @returns {MonthlyRoom}
351
388
  */
352
- export function checkMonthlyRoom({ capUsd, home, now, aboveRunid }) {
389
+ export function checkMonthlyRoom({ capUsd, home, now, aboveRunid, sessionsRoot }) {
353
390
  const cfg = readConfig({ home });
354
391
  if (cfg.problem) throw new ConfigError(cfg.problem);
355
392
  const limit = monthlyLimitOf(cfg.config, home);
356
393
  if (limit === null) {
357
394
  return { ok: true, leftUsd: null, limitUsd: null, atLeast: false };
358
395
  }
359
- const spent = monthSpend({ home, now, aboveRunid });
396
+ const spent = monthSpend({ home, sessionsRoot, now, aboveRunid });
360
397
  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 };
398
+ const capCents = Math.ceil(capUsd * 100 - 1e-6); // a non-finite cap never fits (NaN/Infinity compare false) — never read as $0
399
+ return { ok: capCents <= leftCents, leftUsd: leftCents / 100, limitUsd: limit, atLeast: spent.atLeast, heldUsd: spent.heldUsd, heldRuns: spent.heldRuns };
363
400
  }
364
401
 
365
402
  /**
366
- * The ONE refusal text, everywhere (CLI, panel cap note, panel Sign refusal).
403
+ * The ONE refusal text, everywhere (CLI, panel cap note, panel Sign refusal). When run(s) in progress hold
404
+ * part of the limit, the same line says how much.
367
405
  * @param {MonthlyRoom} room
368
406
  * @returns {string|null} null when the run fits
369
407
  */
370
408
  export function monthlyRefusalText(room) {
371
409
  if (room.ok || room.leftUsd === null) return null;
372
- return `Max $${room.leftUsd.toFixed(2)} (monthly limit)`;
410
+ const max = `Max $${room.leftUsd.toFixed(2)}`;
411
+ if (!room.heldRuns || !room.heldUsd || room.heldUsd <= 0) return `${max} (monthly limit)`;
412
+ return `${max} (monthly limit, $${room.heldUsd.toFixed(2)} held by ${room.heldRuns > 1 ? 'runs' : 'a run'} in progress)`;
373
413
  }
374
414
 
375
415
  /**
@@ -382,13 +422,14 @@ export function monthlyRefusalText(room) {
382
422
  */
383
423
  export function settleDeadClaims({ home, by, aboveRunid }) {
384
424
  const { rows, events } = readRunList({ home });
385
- const settled = new Set(events.filter((e) => e.type === 'settled').map((e) => e.runid));
386
425
  let n = 0;
387
426
  for (const row of rowsAbove(rows, aboveRunid)) {
388
- if (!Number.isInteger(row.pid) || settled.has(row.runid) || isLiveRunner(/** @type {number} */ (row.pid))) continue;
427
+ // the run's own claim is never closed by itself (a resume's own row stays in `rowsAbove` for its SPEND)
428
+ if (aboveRunid !== undefined && row.runid === aboveRunid) continue;
429
+ if (!Number.isInteger(row.pid) || isSettled(events, row) || isLiveRunner(/** @type {number} */ (row.pid))) continue;
389
430
  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 });
431
+ try { spentUsd = legSpend(legsOf(parseJsonl(row.spine).records).at(-1)?.records ?? []).usd; } catch { /* no spine: nothing was recorded spent */ }
432
+ appendRunEvent({ runid: row.runid, type: 'settled', by, reason: 'process gone', spentUsd, spendComplete: false, at: new Date().toISOString(), ...(row.leg === undefined ? {} : { leg: row.leg }) }, { home });
392
433
  n += 1;
393
434
  }
394
435
  return n;
@@ -402,20 +443,25 @@ export function settleDeadClaims({ home, by, aboveRunid }) {
402
443
  * it appends `{type:'released', reason:'refused'}` and reports the refusal, nothing spent. No limit set =
403
444
  * no claim (`claimed: false`; the caller lists the run as it always did). An unreadable config, or a row
404
445
  * 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
446
+ * A RESUMED leg (`leg` 2+, P5-R) claims the same way but appends a `leg-start` beside the run's one row
447
+ * (the row itself is added only when the list has none), and a refusal gives back THAT leg's claim.
448
+ * @param {{ row: import('./runlist.js').RunRow, capUsd: number, home?: string, now?: () => number, leg?: number }} args
406
449
  * @returns {{ room: MonthlyRoom, claimed: boolean }}
407
450
  */
408
- export function claimRun({ row, capUsd, home, now }) {
451
+ export function claimRun({ row, capUsd, home, now, leg = 1 }) {
409
452
  const cfg = readConfig({ home });
410
453
  if (cfg.problem) throw new ConfigError(cfg.problem);
411
454
  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) {
455
+ try {
456
+ appendRun(row, { home });
457
+ if (leg > 1) appendLegStart({ type: 'leg-start', runid: row.runid, leg, pid: row.pid, capUsd: row.capUsd, at: new Date().toISOString() }, { home });
458
+ } catch (/** @type {any} */ e) {
413
459
  throw new ConfigError(`cannot write this run's claim to the run list (${e.message})`);
414
460
  }
415
461
  settleDeadClaims({ home, by: row.runid, aboveRunid: row.runid });
416
462
  const room = checkMonthlyRoom({ capUsd, home, now, aboveRunid: row.runid });
417
463
  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 */ }
464
+ try { appendRunEvent({ runid: row.runid, type: 'released', by: row.runid, reason: 'refused', at: new Date().toISOString(), ...(leg > 1 ? { leg } : {}) }, { home }); } catch { /* the refusal stands; a stranded row of a dead process is closed by the next run */ }
419
465
  return { room, claimed: false };
420
466
  }
421
467
  return { room, claimed: true };
@@ -22,7 +22,8 @@ import { spawn as realSpawn } from 'node:child_process';
22
22
  import { openSync } from 'node:fs';
23
23
  import { join } from 'node:path';
24
24
  import { readFileSync } from 'node:fs';
25
- import { createSession, validateJobCard } from './authorsession.js';
25
+ import { createSession, validateJobCard, validateReuseCard, lockedFieldChanged, buildReuseSpec, cardFields, STEP_LABELS } from './authorsession.js';
26
+ import { validateJob } from '../job.js';
26
27
  import { checkMonthlyRoom, monthlyRefusalText } from '../monthly.js';
27
28
  import { ConfigError } from '../config.js';
28
29
  import { keysForDoor, keysHome } from '../keysfile.js';
@@ -34,6 +35,23 @@ export function mintToken() {
34
35
  return randomBytes(24).toString('hex');
35
36
  }
36
37
 
38
+ /**
39
+ * The ONE spelling of this panel's own bind address (`host:port`), shared by the Host and Origin checks.
40
+ * @param {number} port
41
+ */
42
+ const bindAddress = (port) => `127.0.0.1:${port}`;
43
+
44
+ /**
45
+ * The ONE Host check: the request names this panel's own bind address. A page on another
46
+ * origin reaching the panel through a rebound DNS name sends that name as Host, so it fails here.
47
+ * @param {import('node:http').IncomingMessage} req
48
+ * @param {{port: number}} o
49
+ * @returns {{ok: true}|{ok: false, reason: string}}
50
+ */
51
+ export function checkHostGuard(req, { port }) {
52
+ return req.headers.host === bindAddress(port) ? { ok: true } : { ok: false, reason: 'wrong Host' };
53
+ }
54
+
37
55
  /**
38
56
  * `true` when a request may reach ANY route in this file — the token header
39
57
  * plus an Origin/Host that names this exact bind address. `req.headers`
@@ -46,12 +64,11 @@ export function mintToken() {
46
64
  export function checkHumanGuard(req, { token, port }) {
47
65
  const got = req.headers['x-bareloop-token'];
48
66
  if (got !== token) return { ok: false, reason: 'missing or wrong token' };
49
- const want = `127.0.0.1:${port}`;
67
+ const want = bindAddress(port);
50
68
  const origin = req.headers.origin;
51
- const host = req.headers.host;
52
69
  if (typeof origin === 'string' && origin.length > 0) {
53
70
  if (origin !== `http://${want}` && origin !== `https://${want}`) return { ok: false, reason: 'wrong Origin' };
54
- } else if (host !== want) {
71
+ } else if (!checkHostGuard(req, { port }).ok) {
55
72
  return { ok: false, reason: 'wrong Host (and no Origin header)' };
56
73
  }
57
74
  return { ok: true };
@@ -73,7 +90,8 @@ const TERMINAL_PHASES = new Set(['refused', 'abandoned', 'error', 'signed', 'sig
73
90
  * deliberately has none of).
74
91
  * @param {{ port: number, token: string, env?: Record<string,string|undefined>,
75
92
  * sessionsRoot?: string, spawnFn?: typeof realSpawn, bareloopBin?: string,
76
- * jobsDir?: string, fetchImpl?: typeof fetch, home?: string }} opts
93
+ * jobsDir?: string, fetchImpl?: typeof fetch, home?: string,
94
+ * startFrom?: {get: (runid: string, o?: {model?: string}) => any, getImport: (id: string, o?: {model?: string}) => any} }} opts
77
95
  */
78
96
  export function createAuthorRoutes(opts) {
79
97
  // the RAW env with the keys file re-merged on every use, so an edited file takes effect
@@ -170,7 +188,7 @@ export function createAuthorRoutes(opts) {
170
188
  const cap = Number(q.get('cap'));
171
189
  if (!Number.isFinite(cap) || cap <= 0) { send(200, { ok: true, refusal: null }); return true; }
172
190
  try {
173
- const room = checkMonthlyRoom({ capUsd: cap, home: opts.home });
191
+ const room = checkMonthlyRoom({ capUsd: cap, home: opts.home, sessionsRoot: opts.sessionsRoot });
174
192
  send(200, { ok: true, refusal: monthlyRefusalText(room), leftUsd: room.leftUsd, atLeast: room.atLeast });
175
193
  } catch (e) {
176
194
  if (!(e instanceof ConfigError)) throw e;
@@ -179,19 +197,74 @@ export function createAuthorRoutes(opts) {
179
197
  return true;
180
198
  }
181
199
 
200
+ // ── REUSE WORKFLOW (replaces P5 item 3's Start from this). `GET /api/author/start-from?runid=` (or `?import=`) —
201
+ // the prefill from the SIGNED job, which boxes are locked, the track record. $0, read-only. The route keeps its name.
202
+ if (pathname === '/api/author/start-from') {
203
+ if (!opts.startFrom) { send(404, { ok: false, error: 'not found' }); return true; }
204
+ if (req.method !== 'GET') { send(405, { ok: false, error: 'GET only' }); return true; }
205
+ const sp = new URL(/** @type {string} */ (req.url), 'http://127.0.0.1').searchParams;
206
+ const runid = sp.get('runid') ?? '';
207
+ // `model` = the card's Model box right now: the estimate line counts only runs on that worker
208
+ const asModel = sp.has('model') ? { model: sp.get('model') ?? '' } : {};
209
+ const pre = sp.has('import') ? opts.startFrom.getImport(sp.get('import') ?? '', asModel) : opts.startFrom.get(runid, asModel);
210
+ if (pre === null) { send(404, { ok: false, error: 'no such run' }); return true; }
211
+ if (!pre.ok) { send(409, pre); return true; }
212
+ send(200, {
213
+ ok: true, origin: pre.origin, card: pre.card, from: pre.from, note: pre.note,
214
+ locked: pre.locked, open: pre.open, specHash: pre.specHash, workflowKey: pre.workflowKey, trackRecord: pre.trackRecord,
215
+ line: pre.line,
216
+ });
217
+ return true;
218
+ }
219
+
220
+ // A refreshed page re-attaches to the one live (non-terminal) session: its id and state (card, messages, steps),
221
+ // or `null`. The server's session map is the only truth; nothing is stored page-side.
222
+ if (pathname === '/api/author/live') {
223
+ if (req.method !== 'GET') { send(405, { ok: false, error: 'GET only' }); return true; }
224
+ const live = [...sessions.values()].find((s) => !TERMINAL_PHASES.has(s.state.phase));
225
+ send(200, { ok: true, sessionId: live ? live.state.id : null, state: live ? live.state : null });
226
+ return true;
227
+ }
228
+
182
229
  if (pathname === '/api/author/start') {
183
230
  if (req.method !== 'POST') { send(405, { ok: false, error: 'POST only' }); return true; }
184
231
  if (hasLiveSession()) { send(409, { ok: false, error: 'an authoring session is already live — one at a time' }); return true; }
185
232
  const card = body ?? {};
186
- const v = validateJobCard(card, { jobsDir: opts.jobsDir, rows: rowsForHome(keysHome(opts.home)) });
187
- if (!v.ok) { send(400, { ok: false, error: v.error }); return true; }
188
- const session = createSession(card, { env: envNow(), sessionsRoot: opts.sessionsRoot, ...(opts.home !== undefined ? { home: opts.home } : {}) });
233
+ const rows = rowsForHome(keysHome(opts.home));
234
+ // Reuse workflow — started from a green run or an imported job: only Source, Destination and the two caps may
235
+ // differ from the signed workflow. The SERVER refuses any other change (the page greys those boxes, but is
236
+ // never trusted); the spec it signs is a copy of the origin's with just those four set. Nothing is drafted.
237
+ /** @type {{spec: any, workflowKey: string}|null} */
238
+ let reuse = null;
239
+ const from = card.startFrom;
240
+ const fromRunid = typeof from === 'string' ? from : (typeof from?.runid === 'string' ? from.runid : null);
241
+ const fromImport = typeof from?.importId === 'string' ? from.importId : null;
242
+ if (fromRunid !== null || fromImport !== null) {
243
+ const pre = opts.startFrom ? (fromImport !== null ? opts.startFrom.getImport(fromImport) : opts.startFrom.get(fromRunid)) : null;
244
+ if (pre === null || pre === undefined) { send(404, { ok: false, error: 'no such run to reuse' }); return true; }
245
+ if (!pre.ok) { send(409, pre); return true; }
246
+ const locked = lockedFieldChanged(card, pre.card);
247
+ if (locked !== null) { send(400, { ok: false, error: `${locked} is locked on a reused workflow — Clear the card to change it` }); return true; }
248
+ const rv = validateReuseCard(card, { rows });
249
+ if (!rv.ok) { send(400, { ok: false, error: rv.error }); return true; }
250
+ const spec = buildReuseSpec(pre.spec, card, rows);
251
+ const jv = validateJob(spec, { shellCapUsd: spec.budgetUsd });
252
+ if (!jv.ok) { send(400, { ok: false, error: `the spec this reuse would sign is not valid (${jv.reds.map((r) => `${r.code} at ${r.path}`).join('; ')})` }); return true; }
253
+ reuse = { spec, workflowKey: pre.workflowKey };
254
+ }
255
+ if (reuse === null) {
256
+ const v = validateJobCard(card, { jobsDir: opts.jobsDir, rows });
257
+ if (!v.ok) { send(400, { ok: false, error: v.error }); return true; }
258
+ }
259
+ const session = createSession(cardFields(card), {
260
+ env: envNow(), sessionsRoot: opts.sessionsRoot, ...(opts.home !== undefined ? { home: opts.home } : {}), ...(reuse ? { reuse } : {}),
261
+ });
189
262
  sessions.set(session.id, session);
190
- send(200, { ok: true, sessionId: session.id, state: session.state });
263
+ send(200, { ok: true, sessionId: session.id, reuse: reuse !== null, state: session.state });
191
264
  return true;
192
265
  }
193
266
 
194
- const m = /^\/api\/author\/([A-Za-z0-9]+)(\/(send|revise|sign-prepare|sign|check-deps))?$/.exec(pathname);
267
+ const m = /^\/api\/author\/([A-Za-z0-9]+)(\/(send|revise|sign-prepare|sign|check-deps|abandon))?$/.exec(pathname);
195
268
  if (!m) { send(404, { ok: false, error: 'not found' }); return true; }
196
269
  const session = sessions.get(m[1]);
197
270
  if (!session) { send(404, { ok: false, error: 'no such session' }); return true; }
@@ -213,6 +286,11 @@ export function createAuthorRoutes(opts) {
213
286
  session.revise(String(body?.text ?? '')).then((r) => send(r.ok ? 200 : 400, { ...r, state: session.state }));
214
287
  return true;
215
288
  }
289
+ if (sub === 'abandon') {
290
+ const r = session.abandon();
291
+ send(r.ok ? 200 : 400, { ...r, state: session.state });
292
+ return true;
293
+ }
216
294
  if (sub === 'check-deps') {
217
295
  const r = session.checkDeps();
218
296
  send(r.ok ? 200 : 400, { ...r, state: session.state });
@@ -248,7 +326,7 @@ export function createAuthorRoutes(opts) {
248
326
  * @param {number} n a non-negative dollar amount
249
327
  * @returns {string}
250
328
  */
251
- function panelMoney2(n) {
329
+ export function panelMoney2(n) {
252
330
  if (n > 0 && n < 0.01) return '<$0.01';
253
331
  // two-step rounding (matching index.html's own panelMoney): clean to
254
332
  // 6-decimal precision first (the same precision src/text.js's tallyCalls
@@ -280,7 +358,7 @@ export function signRun(session, claimedHash, o) {
280
358
  // lower nothing (the cap is signed in) but may raise the limit in Settings and sign again.
281
359
  try {
282
360
  const spec = JSON.parse(readFileSync(session.state.resolvedSpecPath, 'utf8'));
283
- const refusal = monthlyRefusalText(checkMonthlyRoom({ capUsd: Number(spec.budgetUsd), home: o.home }));
361
+ const refusal = monthlyRefusalText(checkMonthlyRoom({ capUsd: Number(spec.budgetUsd), home: o.home, sessionsRoot: o.sessionsRoot }));
284
362
  if (refusal !== null) return { ok: false, error: refusal };
285
363
  } catch (e) {
286
364
  if (e instanceof ConfigError) return { ok: false, error: `${e.message} — refusing rather than guess the monthly limit` };
@@ -320,9 +398,11 @@ export function signRun(session, claimedHash, o) {
320
398
  });
321
399
  if (typeof child?.unref === 'function') child.unref();
322
400
  session.state.phase = 'signed';
401
+ // hamr 2026-10-05: the sign shows as the last step of the one progress list, never as a thread bubble
402
+ // hamr 2026-10-05 (option A): the drafting-spend line is that step's detail — nothing when there was no drafting spend (a Reuse)
323
403
  const draftLine = draftSpentUsd !== null
324
- ? ` — drafting spent ${draftIncomplete ? 'at least ' : ''}${panelMoney2(draftSpentUsd)} (folds out of the run's own cap)`
404
+ ? `drafting spent ${draftIncomplete ? 'at least ' : ''}${panelMoney2(draftSpentUsd)} (folds out of the run's own cap)`
325
405
  : '';
326
- session.state.messages.push({ role: 'system', text: `signed — spec hash ${session.state.specHash}${draftLine} — run starting detached, own log at ${logFile}` });
406
+ if (Array.isArray(session.state.steps)) session.state.steps.push({ id: 'signed', label: STEP_LABELS.signed, status: 'done', detail: draftLine });
327
407
  return { ok: true, job: session.state.resolvedSpecPath };
328
408
  }