bareloop 0.34.0 → 0.36.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 (68) hide show
  1. package/CHANGELOG.md +58 -0
  2. package/bareloop.context.md +276 -20
  3. package/package.json +1 -1
  4. package/src/authorrun.js +3 -2
  5. package/src/bundlerun.js +17 -12
  6. package/src/draftspend.js +197 -0
  7. package/src/exits.js +10 -1
  8. package/src/index.js +3 -3
  9. package/src/interviewrun.js +7 -7
  10. package/src/job.js +37 -0
  11. package/src/judged.js +2 -2
  12. package/src/kinds.js +2 -1
  13. package/src/ledger.js +101 -5
  14. package/src/legs.js +171 -0
  15. package/src/monthly.js +111 -72
  16. package/src/movefile.js +22 -0
  17. package/src/npminstall.js +55 -0
  18. package/src/panel/authorroutes.js +84 -14
  19. package/src/panel/authorsession.js +490 -125
  20. package/src/panel/importroutes.js +359 -0
  21. package/src/panel/importrun.js +203 -0
  22. package/src/panel/index.html +1194 -213
  23. package/src/panel/runroutes.js +219 -0
  24. package/src/panel/server.js +964 -79
  25. package/src/panel/settingsroutes.js +52 -2
  26. package/src/panel/status.js +51 -0
  27. package/src/planrun.js +60 -26
  28. package/src/providerrows.js +17 -0
  29. package/src/ralph.js +4 -1
  30. package/src/replay.js +139 -19
  31. package/src/replayio.js +11 -4
  32. package/src/reuse.js +20 -3
  33. package/src/run.js +6 -3
  34. package/src/runlist.js +75 -7
  35. package/src/source.js +43 -4
  36. package/src/text.js +20 -0
  37. package/src/u-patient.js +3 -2
  38. package/src/u-readout.js +9 -1
  39. package/src/u-watchdog.mjs +4 -2
  40. package/src/userrun.js +192 -65
  41. package/src/worktree.js +120 -0
  42. package/types/draftspend.d.ts +141 -0
  43. package/types/job.d.ts +25 -0
  44. package/types/ledger.d.ts +44 -0
  45. package/types/legs.d.ts +103 -0
  46. package/types/monthly.d.ts +23 -18
  47. package/types/movefile.d.ts +9 -0
  48. package/types/npminstall.d.ts +30 -0
  49. package/types/panel/authorroutes.d.ts +23 -1
  50. package/types/panel/authorsession.d.ts +110 -2
  51. package/types/panel/importroutes.d.ts +129 -0
  52. package/types/panel/importrun.d.ts +77 -0
  53. package/types/panel/runroutes.d.ts +32 -0
  54. package/types/panel/server.d.ts +266 -2
  55. package/types/panel/settingsroutes.d.ts +6 -1
  56. package/types/panel/status.d.ts +51 -0
  57. package/types/planrun.d.ts +13 -21
  58. package/types/providerrows.d.ts +12 -0
  59. package/types/ralph.d.ts +19 -0
  60. package/types/replay.d.ts +25 -1
  61. package/types/replayio.d.ts +2 -2
  62. package/types/reuse.d.ts +5 -0
  63. package/types/run.d.ts +6 -2
  64. package/types/runlist.d.ts +52 -2
  65. package/types/source.d.ts +13 -5
  66. package/types/text.d.ts +19 -0
  67. package/types/userrun.d.ts +10 -0
  68. package/types/worktree.d.ts +48 -0
package/src/legs.js ADDED
@@ -0,0 +1,171 @@
1
+ // P5-R (hamr 2026-10-02: "one run, one id, one file ... never two") — a RESUMED run is the SAME run.
2
+ // Its spine is one append-only file; each resume appends a `leg-resume` marker and then the new
3
+ // leg's own records. This module is the ONE owner of "where does a leg start and end": every reader
4
+ // that needs a leg (the resume reader, replay, the monthly limit, the panel, the readout) asks
5
+ // `legsOf` and never re-derives a boundary on its own — two spellings of one boundary is how a
6
+ // money figure counts leg 1 twice.
7
+ //
8
+ // Pure: no imports, no file IO. The text-level torn-line rule lives in `parseSpineText` below, beside
9
+ // the boundary rule it depends on (a torn line is tolerated exactly where the writer leaves one).
10
+ //
11
+ // A spine with NO marker (every run written before P5-R, and every run nobody resumed) is ONE leg:
12
+ // `legsOf` over it returns that file's records, unchanged.
13
+
14
+ /** The marker record's `type`. */
15
+ export const LEG_RESUME = 'leg-resume';
16
+
17
+ /**
18
+ * @typedef {object} Leg
19
+ * @property {number} leg 1-based position in file order (the marker's own `leg` field is never trusted over the order)
20
+ * @property {any|null} start the leg's first record (the `leg-resume` marker for leg 2+; the file's first record for leg 1)
21
+ * @property {any|null} end the leg's last record
22
+ * @property {any[]} records every record of the leg, in file order, marker included
23
+ * @property {any|null} jobStart the leg's own `job-start` (null: it ended before runJob wrote one)
24
+ * @property {any|null} jobEnd the leg's own LAST `job-end` (null: it died, or it is still running)
25
+ * @property {string|null} outcome the leg's terminal: its `job-end` outcome, else a `run-end` outcome (a $0 refusal), else null
26
+ * @property {string|null} after leg 1: null. Leg 2+: how the PREVIOUS leg ended, as the marker declares it
27
+ * (else derived from that leg's own terminal), `'died'` when it recorded none
28
+ * @property {number|null} startMs `Date.parse` of the start record's `ts`
29
+ * @property {number|null} endMs `Date.parse` of the end record's `ts`
30
+ */
31
+
32
+ /** @param {any} r @returns {number|null} */
33
+ function tsOf(r) {
34
+ const t = typeof r?.ts === 'string' ? Date.parse(r.ts) : NaN;
35
+ return Number.isFinite(t) ? t : null;
36
+ }
37
+
38
+ /**
39
+ * Split one spine's parsed records into its legs. Pure.
40
+ * @param {any[]} events one spine's parsed records, in file order
41
+ * @returns {Leg[]} always at least one leg for a non-empty list; `[]` for an empty one
42
+ */
43
+ export function legsOf(events) {
44
+ const list = Array.isArray(events) ? events.filter((e) => e && typeof e === 'object') : [];
45
+ if (list.length === 0) return [];
46
+ /** @type {any[][]} */
47
+ const groups = [[]];
48
+ for (const e of list) {
49
+ // a marker opens a new leg — but never an EMPTY leg 1: a file whose first record IS a marker
50
+ // (a spine somebody truncated) still reads as leg 1 = the marker, not an invented empty leg
51
+ if (e.type === LEG_RESUME && groups[groups.length - 1].length > 0) groups.push([]);
52
+ groups[groups.length - 1].push(e);
53
+ }
54
+ /** @type {Leg[]} */
55
+ const legs = [];
56
+ groups.forEach((records, i) => {
57
+ const jobStart = records.find((r) => r.type === 'job-start') ?? null;
58
+ const jobEnd = records.findLast((r) => r.type === 'job-end') ?? null;
59
+ const runEnd = records.findLast((r) => r.type === 'run-end') ?? null;
60
+ const outcome = typeof jobEnd?.outcome === 'string' ? jobEnd.outcome
61
+ : (typeof runEnd?.outcome === 'string' ? runEnd.outcome : null);
62
+ const marker = records[0].type === LEG_RESUME ? records[0] : null;
63
+ const prev = i > 0 ? legs[i - 1] : null;
64
+ /** @type {string|null} */
65
+ let after = null;
66
+ if (i > 0) after = typeof marker?.after === 'string' && marker.after !== '' ? marker.after : (prev?.outcome ?? 'died');
67
+ legs.push({
68
+ leg: i + 1,
69
+ start: records[0],
70
+ end: records[records.length - 1],
71
+ records,
72
+ jobStart,
73
+ jobEnd,
74
+ outcome,
75
+ after,
76
+ startMs: tsOf(records[0]),
77
+ endMs: tsOf(records[records.length - 1]),
78
+ });
79
+ });
80
+ return legs;
81
+ }
82
+
83
+ /**
84
+ * The legs' WORKING time, gap excluded (hamr 2026-10-02, 2A: only working time charges the wall). Each leg
85
+ * counts first record → last record; the quiet time between one leg's end and the next leg's marker is
86
+ * nobody's. `null` = unknown (no leg carries two parseable stamps) — never 0. A leg whose stamps are
87
+ * unreadable adds nothing, so the figure is a FLOOR, `complete: false`.
88
+ * @param {Leg[]} legs
89
+ * @returns {{ ms: number|null, complete: boolean }}
90
+ */
91
+ export function legsWallMs(legs) {
92
+ let ms = 0;
93
+ let known = 0;
94
+ let complete = legs.length > 0;
95
+ for (const l of legs) {
96
+ if (l.startMs !== null && l.endMs !== null && l.endMs >= l.startMs) { ms += l.endMs - l.startMs; known += 1; } else complete = false;
97
+ }
98
+ return { ms: known === 0 ? null : ms, complete: complete && known > 0 };
99
+ }
100
+
101
+ /**
102
+ * Parse a spine's TEXT, tolerant exactly where the writer leaves damage. A kill mid-append leaves a torn
103
+ * line; a resume never edits it (the spine is append-only forever) — it writes one `\n` and starts a fresh
104
+ * line, so the torn bytes end up alone on their own line, immediately BEFORE a `leg-resume` marker. That
105
+ * line, and the file's own last two lines (the older tail rule: a kill mid-append of the file's final
106
+ * record), are tolerated and named. An unparseable line anywhere else is `corrupt`: a reconstruction from
107
+ * it would invent a history.
108
+ * @param {string} raw
109
+ * @returns {{ records: any[], tolerated: {line: number, why: 'tail'|'before-leg-resume'}[], corrupt: {line: number, message: string}|null }}
110
+ * `line` is 1-based. Only the FIRST corrupt line is reported.
111
+ */
112
+ export function parseSpineText(raw) {
113
+ const lines = String(raw).split('\n');
114
+ /** @type {any[]} */
115
+ const records = [];
116
+ /** @type {{line: number, why: 'tail'|'before-leg-resume'}[]} */
117
+ const tolerated = [];
118
+ /** @type {{line: number, message: string}|null} */
119
+ let corrupt = null;
120
+ const BAD = Symbol('torn');
121
+ /** @type {any[]} undefined = blank line, BAD = unparseable */
122
+ const parsed = lines.map((l) => {
123
+ if (!l.trim()) return undefined;
124
+ try { return JSON.parse(l); } catch { return BAD; }
125
+ });
126
+ /** the next non-blank line's parse result after index i, skipping blanks */
127
+ const nextOf = (/** @type {number} */ i) => {
128
+ for (let j = i + 1; j < lines.length; j += 1) if (parsed[j] !== undefined) return parsed[j];
129
+ return undefined;
130
+ };
131
+ lines.forEach((line, i) => {
132
+ const p = parsed[i];
133
+ if (p === undefined) return;
134
+ if (p !== BAD) { records.push(p); return; }
135
+ if (i >= lines.length - 2) { tolerated.push({ line: i + 1, why: 'tail' }); return; }
136
+ // EXACTLY one torn line directly before a marker: the shape a resume leaves. Two in a row is not that
137
+ // shape — the first of them has a corrupt line (not a marker) after it, and is reported.
138
+ const next = nextOf(i);
139
+ if (next && next.type === LEG_RESUME) { tolerated.push({ line: i + 1, why: 'before-leg-resume' }); return; }
140
+ if (corrupt === null) {
141
+ let message = 'unparseable';
142
+ try { JSON.parse(line); } catch (/** @type {any} */ e) { message = e.message; }
143
+ corrupt = { line: i + 1, message };
144
+ }
145
+ });
146
+ return { records, tolerated, corrupt };
147
+ }
148
+
149
+ /**
150
+ * Where leg `leg`'s watchdog kill note lives. Leg 1 keeps the name every run has always had
151
+ * (`<spine>.watchdog.json`); a resumed leg's note sits beside it (`<spine>.leg<N>.watchdog.json`) so one
152
+ * leg never overwrites another's evidence of how it died.
153
+ * @param {string} spineFile
154
+ * @param {number} leg 1-based
155
+ * @returns {string}
156
+ */
157
+ export function watchdogNotePath(spineFile, leg) {
158
+ return leg <= 1 ? `${spineFile}.watchdog.json` : `${spineFile}.leg${leg}.watchdog.json`;
159
+ }
160
+
161
+ /**
162
+ * P5 item 5 (Stop) — where a run's STOP REQUEST lives: a file beside its spine. The panel's stop route writes it;
163
+ * the engine reads it at the round seam of a step worker (src/planrun.js `metered`) and consumes it. One spelling, here, so the route
164
+ * and the engine can never name two files. A request belongs to ONE leg: the engine deletes it when it honours it,
165
+ * when the leg ends, and again at the start of the next leg (src/userrun.js).
166
+ * @param {string} spineFile
167
+ * @returns {string}
168
+ */
169
+ export function stopFilePath(spineFile) {
170
+ return `${spineFile}.stop`;
171
+ }
package/src/monthly.js CHANGED
@@ -20,13 +20,18 @@
20
20
  //
21
21
  // Honesty: a run whose spend is not fully known (a died/still-running spine, an unpriced
22
22
  // round, a missing spine file) makes the month total an "at least" figure — never a clean
23
- // number. The refusal text stays exactly `Max $X (monthly limit)`; `atLeast` travels beside it.
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.
24
25
  import { existsSync } from 'node:fs';
25
26
  import { readConfig, configPath, ConfigError } from './config.js';
26
- import { readRunList, appendRun, appendRunEvent, isLiveRunner, runIsAlive } from './runlist.js';
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,6 +136,7 @@ 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
  /**
@@ -145,7 +145,7 @@ export function legTokens(records) {
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
148
+ * @param {any[]} records the LATEST leg's records
149
149
  * @param {import('./runlist.js').RunRow} row
150
150
  * @param {number} nowMs
151
151
  * @param {number} usd the leg's spend so far
@@ -170,20 +170,22 @@ function inFlightCapUsd(records, row, nowMs, usd) {
170
170
  function rowsAbove(rows, runid) {
171
171
  if (runid === undefined) return rows;
172
172
  const i = rows.findIndex((r) => r.runid === runid);
173
- 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);
174
177
  }
175
178
 
176
179
  /**
177
180
  * Every listed run as one {@link Leg}. The ONE reader the month total, the all-time total
178
181
  * and the per-provider figures share.
179
- * @param {{ home?: string, now?: () => number, aboveRunid?: string, thisMonthOnly?: boolean }} [opts] `now` = the clock for the no-pid in-flight test; `aboveRunid` = read only the runs listed EARLIER in the file than that run (a claim yields to the claims above it, never below); `thisMonthOnly` = do not parse the spine of a run listed in another local month (the refusal check drops it anyway) unless its claim is still held
182
+ * @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
180
183
  * @returns {Leg[]}
181
184
  */
182
185
  export function readLegs(opts = {}) {
183
186
  const nowMs = (opts.now ?? Date.now)();
184
187
  const { rows: allRows, events } = readRunList({ home: opts.home });
185
188
  const rows = rowsAbove(allRows, opts.aboveRunid);
186
- const settled = new Set(events.filter((e) => e.type === 'settled').map((e) => e.runid));
187
189
  /** @type {Leg[]} */
188
190
  const legs = [];
189
191
  for (const row of rows) {
@@ -193,31 +195,60 @@ export function readLegs(opts = {}) {
193
195
  // pid is a live bareloop runner, however quiet its spine — killed = process gone, not silence.
194
196
  // An older row with no `pid` keeps the spine-mtime rule (`inFlightCapUsd`), never guessed alive.
195
197
  const hasPid = Number.isInteger(row.pid);
196
- const heldCap = hasPid && !settled.has(row.runid) && typeof row.capUsd === 'number' && Number.isFinite(row.capUsd) && runIsAlive(row, nowMs) ? row.capUsd : null;
197
- if (opts.thisMonthOnly && heldCap === null && !Number.isNaN(at.getTime()) && !sameLocalMonth(at, new Date(nowMs))) continue;
198
+ // 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;
198
203
  if (Number.isNaN(at.getTime()) || !existsSync(row.spine)) { legs.push({ ...base, reservedUsd: heldCap ?? 0 }); continue; }
199
204
  /** @type {any[]} */
200
205
  let records;
201
206
  try { records = parseJsonl(row.spine).records; } catch { legs.push({ ...base, reservedUsd: heldCap ?? 0 }); continue; }
202
- const start = records.find((r) => r && r.type === 'job-start') ?? null;
203
- const leg = legSpend(records);
204
- const prov = spendProvenance(records);
205
- 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 })) {
206
248
  legs.push({
207
- at,
208
- model: typeof start?.model === 'string' ? start.model : null,
209
- wallMs: wall.ms,
210
- wallComplete: wall.complete,
211
- provider: typeof start?.provider === 'string' ? start.provider : null,
212
- baseUrl: typeof start?.baseUrl === 'string' ? start.baseUrl : null,
213
- usd: leg.usd,
214
- complete: leg.complete,
215
- tokens: legTokens(records),
216
- vouchedRounds: prov.vouched.rounds,
217
- otherRounds: prov.guessed.rounds + prov.unpriced.rounds + prov.unknown.rounds,
218
- reservedUsd: hasPid ? (records.some((r) => r && r.type === 'job-end') || heldCap === null ? leg.usd : Math.max(leg.usd, heldCap))
219
- : inFlightCapUsd(records, row, nowMs, leg.usd),
220
- 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,
221
252
  });
222
253
  }
223
254
  return legs;
@@ -231,7 +262,7 @@ export function readLegs(opts = {}) {
231
262
  const sameLocalMonth = (at, now) => at.getFullYear() === now.getFullYear() && at.getMonth() === now.getMonth();
232
263
 
233
264
  /**
234
- * @param {{ home?: string, now?: () => number, aboveRunid?: string }} [opts] `aboveRunid`: see {@link readLegs}
265
+ * @param {{ home?: string, sessionsRoot?: string, now?: () => number, aboveRunid?: string }} [opts] `aboveRunid`, `sessionsRoot`: see {@link readLegs}
235
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
236
267
  */
237
268
  export function monthSpend(opts = {}) {
@@ -242,11 +273,11 @@ export function monthSpend(opts = {}) {
242
273
  let runs = 0;
243
274
  let heldUsd = 0;
244
275
  let heldRuns = 0;
245
- for (const leg of readLegs({ home: opts.home, now: opts.now, aboveRunid: opts.aboveRunid, thisMonthOnly: true })) {
276
+ for (const leg of readLegs({ home: opts.home, sessionsRoot: opts.sessionsRoot, now: opts.now, aboveRunid: opts.aboveRunid, thisMonthOnly: true })) {
246
277
  // an unreadable date could belong to this month — unknown, never dropped
247
278
  if (Number.isNaN(leg.at.getTime())) { atLeast = true; continue; }
248
279
  if (!sameLocalMonth(leg.at, nowDate)) continue;
249
- runs += 1;
280
+ if (!leg.draft) runs += 1;
250
281
  if (leg.unreadable) { atLeast = true; reservedUsd += leg.reservedUsd; if (leg.reservedUsd > 0) { heldUsd += leg.reservedUsd; heldRuns += 1; } continue; }
251
282
  usd += leg.usd;
252
283
  reservedUsd += leg.reservedUsd;
@@ -261,7 +292,7 @@ export function monthSpend(opts = {}) {
261
292
  * (rows from `rowIdFor`; a run on no known row lands under its own provider name, never
262
293
  * pooled into another row). Any leg with unknown spend makes the figure it belongs to
263
294
  * an "at least".
264
- * @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
265
296
  * @returns {{ tokensByRow: Record<string, number>, total: {usd: number, atLeast: boolean, tokens: number}, month: {usd: number, atLeast: boolean, tokens: number},
266
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}> }}
267
298
  */
@@ -276,7 +307,7 @@ export function spendSummary(opts = {}) {
276
307
  const wallSeen = {};
277
308
  /** total tokens of the runs each Providers row served (matched by shape + endpoint + model) @type {Record<string, number>} */
278
309
  const tokensByRow = {};
279
- for (const leg of readLegs({ home: opts.home })) {
310
+ for (const leg of readLegs({ home: opts.home, sessionsRoot: opts.sessionsRoot })) {
280
311
  let served = opts.rows ? findRow(opts.rows, { provider: leg.provider, baseUrl: leg.baseUrl, model: leg.model }) : null;
281
312
  // an older spine records a model but no provider: it counts toward the row whose Name EXACTLY
282
313
  // equals that model. Two rows sharing the Name make it ambiguous, so it counts toward neither.
@@ -295,9 +326,11 @@ export function spendSummary(opts = {}) {
295
326
  : (notRecorded ? (leg.model ? `not recorded (model ${leg.model})` : 'not recorded') : key.slice('other:'.length));
296
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 });
297
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;
298
331
  const unknown = leg.unreadable || !leg.complete;
299
332
  const wallKnown = !leg.unreadable && leg.wallMs !== null;
300
- const wallAtLeast = !wallKnown || !leg.wallComplete;
333
+ const wallAtLeast = !noWall && (!wallKnown || !leg.wallComplete);
301
334
  total.usd += leg.usd; total.tokens += leg.tokens; if (unknown) total.atLeast = true;
302
335
  p.totalUsd += leg.usd; p.tokens += leg.tokens; p.vouchedRounds += leg.vouchedRounds; p.otherRounds += leg.otherRounds; if (unknown) p.totalAtLeast = true;
303
336
  if (wallKnown) { p.totalWallMs = /** @type {number} */ (p.totalWallMs) + /** @type {number} */ (leg.wallMs); w.totalKnown += 1; }
@@ -305,7 +338,7 @@ export function spendSummary(opts = {}) {
305
338
  if (inMonth) {
306
339
  month.usd += leg.usd; month.tokens += leg.tokens; if (unknown) month.atLeast = true;
307
340
  p.monthUsd += leg.usd; if (unknown) p.monthAtLeast = true;
308
- w.monthRuns += 1;
341
+ if (!noWall) w.monthRuns += 1;
309
342
  if (wallKnown) { p.monthWallMs = /** @type {number} */ (p.monthWallMs) + /** @type {number} */ (leg.wallMs); w.monthKnown += 1; }
310
343
  if (wallAtLeast) p.monthWallAtLeast = true;
311
344
  }
@@ -350,17 +383,17 @@ export function monthlyLimitOf(config, home) {
350
383
  * number above 0 throws `ConfigError`.
351
384
  * Compared in whole cents. Also throws `ConfigError` when config.json is unreadable — a gate
352
385
  * whose own instrument is broken refuses; it never silently runs with no limit.
353
- * @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)
354
387
  * @returns {MonthlyRoom}
355
388
  */
356
- export function checkMonthlyRoom({ capUsd, home, now, aboveRunid }) {
389
+ export function checkMonthlyRoom({ capUsd, home, now, aboveRunid, sessionsRoot }) {
357
390
  const cfg = readConfig({ home });
358
391
  if (cfg.problem) throw new ConfigError(cfg.problem);
359
392
  const limit = monthlyLimitOf(cfg.config, home);
360
393
  if (limit === null) {
361
394
  return { ok: true, leftUsd: null, limitUsd: null, atLeast: false };
362
395
  }
363
- const spent = monthSpend({ home, now, aboveRunid });
396
+ const spent = monthSpend({ home, sessionsRoot, now, aboveRunid });
364
397
  const leftCents = Math.max(0, Math.floor((limit - spent.reservedUsd) * 100 + 1e-6));
365
398
  const capCents = Math.ceil(capUsd * 100 - 1e-6); // a non-finite cap never fits (NaN/Infinity compare false) — never read as $0
366
399
  return { ok: capCents <= leftCents, leftUsd: leftCents / 100, limitUsd: limit, atLeast: spent.atLeast, heldUsd: spent.heldUsd, heldRuns: spent.heldRuns };
@@ -389,13 +422,14 @@ export function monthlyRefusalText(room) {
389
422
  */
390
423
  export function settleDeadClaims({ home, by, aboveRunid }) {
391
424
  const { rows, events } = readRunList({ home });
392
- const settled = new Set(events.filter((e) => e.type === 'settled').map((e) => e.runid));
393
425
  let n = 0;
394
426
  for (const row of rowsAbove(rows, aboveRunid)) {
395
- 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;
396
430
  let spentUsd = 0;
397
- try { spentUsd = legSpend(parseJsonl(row.spine).records).usd; } catch { /* no spine: nothing was recorded spent */ }
398
- appendRunEvent({ runid: row.runid, type: 'settled', by, reason: 'process gone', spentUsd, spendComplete: false, at: new Date().toISOString() }, { home });
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 });
399
433
  n += 1;
400
434
  }
401
435
  return n;
@@ -409,20 +443,25 @@ export function settleDeadClaims({ home, by, aboveRunid }) {
409
443
  * it appends `{type:'released', reason:'refused'}` and reports the refusal, nothing spent. No limit set =
410
444
  * no claim (`claimed: false`; the caller lists the run as it always did). An unreadable config, or a row
411
445
  * that cannot be written, throws `ConfigError` — the gate refuses, never reads "no limit".
412
- * @param {{ row: import('./runlist.js').RunRow, capUsd: number, home?: string, now?: () => number }} args
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
413
449
  * @returns {{ room: MonthlyRoom, claimed: boolean }}
414
450
  */
415
- export function claimRun({ row, capUsd, home, now }) {
451
+ export function claimRun({ row, capUsd, home, now, leg = 1 }) {
416
452
  const cfg = readConfig({ home });
417
453
  if (cfg.problem) throw new ConfigError(cfg.problem);
418
454
  if (monthlyLimitOf(cfg.config, home) === null) return { room: { ok: true, leftUsd: null, limitUsd: null, atLeast: false }, claimed: false };
419
- try { appendRun(row, { home }); } catch (/** @type {any} */ e) {
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) {
420
459
  throw new ConfigError(`cannot write this run's claim to the run list (${e.message})`);
421
460
  }
422
461
  settleDeadClaims({ home, by: row.runid, aboveRunid: row.runid });
423
462
  const room = checkMonthlyRoom({ capUsd, home, now, aboveRunid: row.runid });
424
463
  if (!room.ok) {
425
- try { appendRunEvent({ runid: row.runid, type: 'released', by: row.runid, reason: 'refused', at: new Date().toISOString() }, { home }); } catch { /* the refusal stands; a stranded row of a dead process is closed by the next run */ }
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 */ }
426
465
  return { room, claimed: false };
427
466
  }
428
467
  return { room, claimed: true };
@@ -0,0 +1,22 @@
1
+ // Move a file between two paths that may sit on DIFFERENT filesystems.
2
+ // `renameSync` cannot cross a device (EXDEV): a worktree inside the person's own repo can be on /tmp or
3
+ // another drive while the spine / output dir stays under ~/.config. EXDEV only falls back to copy + unlink;
4
+ // every other error rethrows untouched. Renames inside one directory (atomic tmp -> final writes) don't need this.
5
+ import { renameSync, copyFileSync, unlinkSync } from 'node:fs';
6
+
7
+ /**
8
+ * @param {string} src
9
+ * @param {string} dst
10
+ * @param {{ rename?: (a: string, b: string) => void }} [deps] test seam: the rename to try first
11
+ * @returns {void}
12
+ */
13
+ export function moveFile(src, dst, deps = {}) {
14
+ const rename = deps.rename ?? renameSync;
15
+ try {
16
+ rename(src, dst);
17
+ } catch (e) {
18
+ if (/** @type {NodeJS.ErrnoException} */ (e)?.code !== 'EXDEV') throw e;
19
+ copyFileSync(src, dst);
20
+ unlinkSync(src);
21
+ }
22
+ }
@@ -0,0 +1,55 @@
1
+ // P6 item 3 (signed 2026-10-05): bareloop installs a JS repo's packages ITSELF, $0, before any token,
2
+ // for the one case whose command is exact and offline-reproducible: a committed `package-lock.json` /
3
+ // `npm-shrinkwrap.json` -> `npm ci --ignore-scripts`. It is the panel door's own step, never a verb the
4
+ // agent is granted (the tool menu is untouched): the agent never sees, asks for, or triggers it.
5
+ // Anything else (another lock file, no lock file, npm missing, a failure, a timeout) returns `ok:false`
6
+ // and the caller falls back to the install-needed wait with the real command.
7
+
8
+ import { existsSync } from 'node:fs';
9
+ import { spawnClose, CLOSE_ENV_DENY } from './ralph.js';
10
+ import { redactSecrets } from './validate.js';
11
+
12
+ /** Wall-clock cap for one `npm ci`: 10 minutes. A cold install of a large repo is minutes, never an hour;
13
+ * past this the person is better served by the printed command in their own terminal. Operator-set. */
14
+ export const NPM_CI_TIMEOUT_MS = 600_000;
15
+
16
+ /** the lock files whose gap command is `npm ci` (the rule table in src/source.js, LOCKFILE_COMMANDS) */
17
+ export const NPM_CI_LOCKS = Object.freeze(['package-lock.json', 'npm-shrinkwrap.json']);
18
+
19
+ /**
20
+ * The env npm sees: the panel's own env minus every credential `CLOSE_ENV_DENY` names (never re-spelled).
21
+ * `npm ci` needs no provider key; a private registry token (NPM_TOKEN) is stripped too, so such a repo
22
+ * simply falls back to the person's own install.
23
+ * @param {NodeJS.ProcessEnv} base
24
+ * @returns {NodeJS.ProcessEnv}
25
+ */
26
+ export function npmEnv(base) {
27
+ const env = { ...base };
28
+ delete env.NODE_TEST_CONTEXT;
29
+ for (const name of Object.keys(env)) {
30
+ if (CLOSE_ENV_DENY.names.includes(name)
31
+ || CLOSE_ENV_DENY.prefixes.some((p) => name.startsWith(p))
32
+ || CLOSE_ENV_DENY.shape.test(name)) delete env[name];
33
+ }
34
+ return env;
35
+ }
36
+
37
+ /**
38
+ * Run `npm ci --ignore-scripts` in `cwd`. Async (never blocks the panel's event loop); argv array, no shell.
39
+ * @param {string} cwd the directory holding package.json + the lock file
40
+ * @param {{timeoutMs?: number, env?: NodeJS.ProcessEnv, run?: typeof spawnClose}} [o] `run` is the test seam
41
+ * @returns {Promise<{ok: true}|{ok: false, reason: string}>}
42
+ */
43
+ export async function runNpmCi(cwd, { timeoutMs = NPM_CI_TIMEOUT_MS, env = process.env, run = spawnClose } = {}) {
44
+ const r = await run('npm', ['ci', '--ignore-scripts'], { env: npmEnv(env), cwd, timeoutMs });
45
+ if (r.error) {
46
+ const code = r.error.code;
47
+ if (code === 'ETIMEDOUT') return { ok: false, reason: `timed out after ${Math.round(timeoutMs / 1000)}s` };
48
+ if (code === 'ENOENT') return existsSync(cwd) ? { ok: false, reason: 'npm was not found' } : { ok: false, reason: 'the install folder does not exist' };
49
+ return { ok: false, reason: redactSecrets(String(r.error.message ?? r.error)).slice(0, 200) };
50
+ }
51
+ if (r.status === 0) return { ok: true };
52
+ const lines = String(r.stderr || r.stdout || '').split('\n').map((l) => l.trim()).filter(Boolean);
53
+ const last = redactSecrets(lines[lines.length - 1] ?? '').slice(0, 200);
54
+ return { ok: false, reason: last || (r.status === null ? `killed by ${r.signal ?? 'a signal'}` : `exit ${r.status}`) };
55
+ }