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
@@ -25,21 +25,34 @@
25
25
  // for every cap/threshold: a shell never widens what it was asked to do).
26
26
 
27
27
  import { createServer } from 'node:http';
28
- import { readFileSync, existsSync, statSync } from 'node:fs';
28
+ import { readFileSync, existsSync, readdirSync } from 'node:fs';
29
29
  import {
30
30
  dirname, join, basename, relative, isAbsolute, sep,
31
31
  } from 'node:path';
32
32
  import { fileURLToPath } from 'node:url';
33
- import { readRunList, DIED_MTIME_MS } from '../runlist.js';
33
+ import { readRunList, DIED_MTIME_MS, runIsAlive } from '../runlist.js';
34
34
  import {
35
35
  replayOne, parseJsonl, resolveSiblings, isSidecarByName,
36
36
  } from '../replayio.js';
37
- import { summarizeForAllLine, auditWindow } from '../replay.js';
37
+ import { summarizeForAllLine, auditWindowsOf } from '../replay.js';
38
+ import { legsOf, legsWallMs, stopFilePath } from '../legs.js';
39
+ import { chainSpend } from '../ledger.js';
38
40
  import { runBehaviour } from '../behaviour.js';
39
41
  import { SPEND_RECORD_TYPES, floorsFromRecords } from '../ledger.js';
40
- import { jobSpecHash } from '../job.js';
42
+ import { jobSpecHash, workflowKey } from '../job.js';
41
43
  import { confirmProtections } from '../authorflow.js';
42
- import { createAuthorRoutes, mintToken } from './authorroutes.js';
44
+ import { createAuthorRoutes, mintToken, checkHostGuard, panelMoney2 } from './authorroutes.js';
45
+ import { createRunRoutes } from './runroutes.js';
46
+ import { createImportRoutes, readImports, bundleStatus, IMPORT_CHANGED_LINE } from './importroutes.js';
47
+ import { parseImportedRunId, safeSpinePath, spineStartedAt } from './importrun.js';
48
+ import { checkBundleDeps, resolveBundleSpec } from '../bundle.js';
49
+ import { checkCloseByteSignature } from '../close-integrity.js';
50
+ import { keysHome } from '../keysfile.js';
51
+ import { rowsForHome, findRow, modelChoiceFor } from '../providerrows.js';
52
+ import { resolveProvider } from '../providers.js';
53
+ import { REUSE_LOCKED_FIELDS, REUSE_OPEN_FIELDS } from './authorsession.js';
54
+ import { readResume, checkpointAgeGate, CHECKPOINT_OUTCOMES } from '../reuse.js';
55
+ import { statusFor, GOAL_MET_LINE } from './status.js';
43
56
  import { createSettingsRoutes } from './settingsroutes.js';
44
57
 
45
58
  const HERE = dirname(fileURLToPath(import.meta.url));
@@ -71,9 +84,7 @@ export const DEFAULT_PORT = 4700;
71
84
  * @returns {'✓'|'✗'|'▶'}
72
85
  */
73
86
  export function glyphForOutcome(outcome) {
74
- if (outcome === 'green' || outcome === 'already-green' || outcome === 'satisfied') return '✓';
75
- if (outcome === null || outcome === undefined) return '▶';
76
- return '✗';
87
+ return /** @type {'✓'|'✗'|'▶'} */ (statusFor({ outcome }).sign);
77
88
  }
78
89
 
79
90
  /**
@@ -137,9 +148,9 @@ export function checkTypeTitle(verdictType, atIso) {
137
148
  return null;
138
149
  }
139
150
 
140
- // DIED (hamr's ruling B, 2026-09-25): a run with no `job-end` is `died` [?], never [✗], once its
141
- // spine file's mtime is older than DIED_MTIME_MS — the constant and its full comment live in
142
- // src/runlist.js (the monthly limit reads the same rule); re-exported here for the panel's callers.
151
+ // DIED (hamr's ruling B, 2026-09-25): a run with no `job-end` is `died` [?], never [✗], once nothing is
152
+ // running it — `runIsAlive` (src/runlist.js: the row's pid first, the spine-mtime rule DIED_MTIME_MS only
153
+ // for a row with no pid; the monthly limit reads the same rule); the constant is re-exported here for the panel's callers.
143
154
  export { DIED_MTIME_MS };
144
155
 
145
156
  /**
@@ -231,31 +242,30 @@ export function formatTimestamp(ts) {
231
242
  * fields, which assume a `job-end` exists) — the ONE owner of this
232
243
  * derivation, used by every caller below (`/api/runs`, `/api/runs/:id`) so
233
244
  * the two never drift apart.
234
- * @param {string} spinePath
245
+ * @param {import('../runlist.js').RunRow} row the run's listed row (its pid, else its spine's mtime, says whether it is still running)
235
246
  * @param {any[]} records raw parsed spine records (already read once by the caller)
236
247
  * @param {string|null} outcome `replayRun`'s own `summary.outcome`
237
- * @returns {{died: boolean, why: string|null, spendFloorUsd: number|null, wallFloorMs: number|null}}
248
+ * @returns {{died: boolean, why: string|null, lastThing: string|null, spendFloorUsd: number|null, wallFloorMs: number|null}}
238
249
  */
239
- function deriveDeath(spinePath, records, outcome) {
250
+ function deriveDeath(row, records, outcome) {
240
251
  const notDied = {
241
- died: false, why: null, spendFloorUsd: null, wallFloorMs: null,
252
+ died: false, why: null, lastThing: null, spendFloorUsd: null, wallFloorMs: null,
242
253
  };
243
254
  if (outcome !== null && outcome !== undefined) return notDied; // a real job-end was reached
244
255
  // no job-end yet — the floor derivation is identical whether this turns
245
256
  // out to be a died run or one that is genuinely still running (build item
246
257
  // 6): computed here, once, before the died/still-running branch below.
247
258
  const floors = floorsFromRecords(records);
248
- let mtimeMs;
249
- try { mtimeMs = statSync(spinePath).mtimeMs; } catch { return { ...notDied, ...floors }; }
250
- if (Date.now() - mtimeMs <= DIED_MTIME_MS) return { ...notDied, ...floors }; // still fresh — genuinely `running`, not died
259
+ if (runIsAlive(row)) return { ...notDied, ...floors }; // something is still running it — genuinely `running`, not died
251
260
 
252
261
  const withTs = records.filter((r) => r && typeof r === 'object' && typeof r.ts === 'string');
253
262
  const last = withTs.length ? withTs[withTs.length - 1] : null;
254
263
  const when = last ? formatTimestamp(last.ts) : 'an unknown time';
255
- const why = `died — no ending was recorded (killed, crashed, or the machine slept). Last thing it did: ${describeLastRecord(last)} at ${when}.`;
264
+ const lastThing = `${describeLastRecord(last)} at ${when}`;
265
+ const why = `died — no ending was recorded (killed, crashed, or the machine slept). Last thing it did: ${lastThing}.`;
256
266
 
257
267
  return {
258
- died: true, why, ...floors,
268
+ died: true, why, lastThing, ...floors,
259
269
  };
260
270
  }
261
271
 
@@ -275,6 +285,352 @@ function formatDurationMs(ms) {
275
285
  return `${m}m${String(rem).padStart(2, '0')}s`;
276
286
  }
277
287
 
288
+ /**
289
+ * P5 item 2 — may this run be resumed, and under which spec file? The ONE owner of
290
+ * "would the engine accept `--resume` for this run" on the panel side: it asks the SAME
291
+ * library readers the engine's own refusals come from (`readResume` with
292
+ * `CHECKPOINT_OUTCOMES`, `checkpointAgeGate`), plus the two facts the panel needs to
293
+ * spawn it (a signed spec file beside the run, whose hash is the one the run was signed
294
+ * under). `ok:false` means the panel declines to offer a button the engine would refuse
295
+ * by design; it carries a plain `why`.
296
+ *
297
+ * The spec file is the run's own `resolved-spec.json` or one of its
298
+ * `resolved-spec-r<k>.json` siblings (a resume under raised caps writes one), picked by
299
+ * HASH against the run's `job-start.specHash` — a run resumed under raised caps must be
300
+ * resumed again under ITS caps, never the original's. No match = no resume offered.
301
+ * @param {{ spine: string, job: string, pid?: number }} row
302
+ * @param {any[]} records the run's raw spine records
303
+ * @returns {{ok: true, specPath: string, spec: any, specHash: string, budgetUsd: number|null,
304
+ * maxWallMin: number|null, spentUsd: number|null, spendComplete: boolean,
305
+ * draftSpentUsd: number|null, draftSpendComplete: boolean|null, wallUsedMs: number|null}|{ok: false, why: string}}
306
+ */
307
+ export function resumePlanFor(row, records) {
308
+ if (runIsAlive(row)) return { ok: false, why: 'it is still running' };
309
+ // P5-R: a resumed run is one file of several legs. What may be resumed again is the LATEST leg, under the spec
310
+ // THAT leg ran with (its `job-start.specHash` — a leg resumed under raised caps carries the re-signed hash), so
311
+ // resume #2 is offered under the latest signed caps, never the first leg's.
312
+ const legs = legsOf(records);
313
+ const jobStart = (legs.findLast((l) => l.jobStart !== null) ?? null)?.jobStart ?? null;
314
+ if (!jobStart) return { ok: false, why: 'its log has no start record' };
315
+ const dead = readResume(records, { direct: true, resumableOutcomes: CHECKPOINT_OUTCOMES });
316
+ if (!dead.started) return { ok: false, why: 'its log has no start record' };
317
+ if (dead.greened) return { ok: false, why: 'it already met its goal' };
318
+ if (dead.ended) return { ok: false, why: 'it ended with an answer, not a stop' };
319
+ if (!dead.restart) return { ok: false, why: 'it never opened an attempt to continue' };
320
+ const age = checkpointAgeGate(records);
321
+ if (!age.ok) return { ok: false, why: String(age.detail ?? 'its checkpoint has expired') };
322
+ const near = sourceNearSpine(row.spine);
323
+ if (!near.specPath) return { ok: false, why: 'the signed job file is not beside this run' };
324
+ const dir = dirname(near.specPath);
325
+ /** @type {string[]} */
326
+ let names = [];
327
+ try { names = readdirSync(dir).filter((n) => n === 'resolved-spec.json' || /^resolved-spec-r\d+\.json$/.test(n)); } catch { names = []; }
328
+ for (const n of names) {
329
+ let spec;
330
+ try { spec = JSON.parse(readFileSync(join(dir, n), 'utf8')); } catch { continue; }
331
+ if (!spec || typeof spec !== 'object') continue;
332
+ const specHash = jobSpecHash(spec);
333
+ if (specHash !== jobStart.specHash || spec.job !== dead.job) continue;
334
+ const draft = typeof jobStart.draftSpentUsd === 'number' && jobStart.draftSpentUsd > 0 ? jobStart.draftSpentUsd : null;
335
+ const spentKnown = typeof dead.spentUsd === 'number' && Number.isFinite(dead.spentUsd);
336
+ return {
337
+ ok: true,
338
+ specPath: join(dir, n),
339
+ spec,
340
+ specHash,
341
+ budgetUsd: typeof spec.budgetUsd === 'number' ? spec.budgetUsd : null,
342
+ maxWallMin: typeof spec.maxWallMs === 'number' ? spec.maxWallMs / 60000 : null,
343
+ spentUsd: spentKnown ? dead.spentUsd + (draft ?? 0) : null,
344
+ spendComplete: spentKnown && dead.spendComplete !== false && (draft === null || jobStart.draftSpendComplete !== false),
345
+ draftSpentUsd: draft,
346
+ draftSpendComplete: draft === null ? null : jobStart.draftSpendComplete !== false,
347
+ wallUsedMs: typeof dead.restart.priorWallMs === 'number' && Number.isFinite(dead.restart.priorWallMs) ? dead.restart.priorWallMs : null,
348
+ };
349
+ }
350
+ return { ok: false, why: 'no signed job file beside this run matches the hash it ran under' };
351
+ }
352
+
353
+ /**
354
+ * P5-R — the Audit tab's LEG DIVIDERS (hamr 2026-10-02: "clear audit mention resume"). One per resume, CODE-OWNED
355
+ * fixed sentences (ui-verdict-words: never model text) built from how the previous leg ended (`after`) and what the
356
+ * run had spent when it stopped:
357
+ * `── stopped: money cap reached ($8.00) · resumed 2026-10-03 14:10 ──`.
358
+ * `beforePart` is the index of the first part of the leg that picked up (the parts list length when that leg has not
359
+ * started a part yet). Money is the chain's own spend at that leg's end on the ONE basis ({@link chainSpend}).
360
+ * @param {any[]} spineRecords the run's raw records
361
+ * @param {any[]} parts `summary.parts` (each carries `leg` on a resumed run)
362
+ * @returns {{leg: number, beforePart: number, after: string|null, at: string|null, text: string}[]}
363
+ */
364
+ export function legDividersFor(spineRecords, parts) {
365
+ const legs = legsOf(spineRecords);
366
+ /** @type {{leg: number, beforePart: number, after: string|null, at: string|null, text: string}[]} */
367
+ const out = [];
368
+ for (let k = 1; k < legs.length; k += 1) {
369
+ const l = legs[k];
370
+ const first = parts.findIndex((p) => typeof p.leg === 'number' && p.leg >= l.leg);
371
+ const spent = chainSpend(legs.slice(0, k).flatMap((x) => x.records));
372
+ const money = `${spent.complete ? '' : 'at least '}${panelMoney2(spent.usd)}`;
373
+ const a = l.after;
374
+ let stopped;
375
+ if (a === 'cap-halt') stopped = `money cap reached (${money})`;
376
+ else if (a === 'wall-halt') stopped = 'time cap reached';
377
+ else if (a === 'provider-red') stopped = 'the model provider failed';
378
+ else if (a === 'step-stalled') stopped = 'a step stopped making progress';
379
+ else if (a === 'stopped') stopped = 'you stopped it';
380
+ else if (a === 'hitl-pause') stopped = 'paused for a decision';
381
+ else if (a === 'died' || a === null) stopped = 'no ending was recorded';
382
+ else stopped = String(a);
383
+ const at = typeof l.start?.at === 'string' ? l.start.at : (typeof l.start?.ts === 'string' ? l.start.ts : null);
384
+ out.push({
385
+ leg: l.leg,
386
+ beforePart: first === -1 ? parts.length : first,
387
+ after: a,
388
+ at,
389
+ text: `stopped: ${stopped} · resumed ${at ? formatTimestamp(at) : 'at an unknown time'}`,
390
+ });
391
+ }
392
+ return out;
393
+ }
394
+
395
+ /** outcomes whose ending is "the checks said no" (a graded answer from a working close) */
396
+ const GOAL_NOT_MET = new Set(['plan-red', 'check-red', 'step-red', 'escalated']);
397
+
398
+ /**
399
+ * P5 item 1 — the ENDED block: why a run ended, what to do next, and which buttons the
400
+ * engine would accept. CODE-OWNED FIXED SENTENCES (ui-verdict-words: never model text);
401
+ * the only slot-fills are numbers and the engine's own recorded detail. ONE owner,
402
+ * feeding both `getRunDetail` (`ended`) and `summarizeRow` (`endedLine`). `null` while
403
+ * a run is live (no Ended block while running).
404
+ *
405
+ * The table (outcome → reason / next / buttons) is PANEL-BUILD.md P5 item 1. The
406
+ * Resume button appears only when `o.resume` says the engine would accept it; "Reuse workflow"
407
+ * (replaces P5 item 3, 2026-10-03) is offered on green rows only, and starts a NEW run.
408
+ * @param {{outcome: string|null, stopReason: string|null, spentUsd: number|null, budgetUsd: number|null, lastEscalation?: any}} summary
409
+ * @param {{died: boolean, lastThing: string|null}} death
410
+ * @param {{resume?: {ok: boolean, why?: string}|null, destinationRefused?: string|null, moneyHalt?: boolean}} [o]
411
+ * @returns {{reason: string, next: string, line: string, actions: {id: string, label: string}[]}|null}
412
+ */
413
+ export function endedFor(summary, death, o = {}) {
414
+ const resumeOk = !!(o.resume && o.resume.ok);
415
+ /** @type {{id: string, label: string}[]} */
416
+ const RESUME = [{ id: 'resume', label: 'Resume' }];
417
+ /** Reuse workflow (replaces P5 item 3's Start from this, hamr 2026-10-03): the same signed job on a new source — a NEW
418
+ * run, never a rerun. Offered on GREEN rows only; every red row says "Change the job: Clear the card and draft a new one" instead. */
419
+ const REUSE = [{ id: 'reuse', label: 'Reuse workflow' }];
420
+ const CHANGE = 'Change the job: Clear the card and draft a new one.';
421
+ const detailOf = (/** @type {string|null} */ s) => {
422
+ if (typeof s !== 'string' || s.length === 0) return '';
423
+ return s.length > 120 ? `${s.slice(0, 120)}…` : s;
424
+ };
425
+ /** a resumable ending whose Resume the engine would refuse: say so, never offer the button */
426
+ const resumeOr = (/** @type {string} */ okNext) => (resumeOk ? okNext : `Resume is not available for this run${o.resume && o.resume.why ? ` (${o.resume.why})` : ''}.`);
427
+
428
+ if (death.died) {
429
+ return {
430
+ reason: `Stopped with no ending recorded${death.lastThing ? ` (last thing it did: ${death.lastThing})` : ''}.`,
431
+ next: resumeOr('Resume.'),
432
+ line: resumeOk ? 'no ending recorded — resume' : 'no ending recorded',
433
+ actions: resumeOk ? RESUME : [],
434
+ };
435
+ }
436
+ const raw = summary.outcome;
437
+ if (raw === null || raw === undefined) return null;
438
+ const outcome = raw;
439
+ const esc = summary.lastEscalation;
440
+ const cat = typeof esc?.category === 'string' ? esc.category : null;
441
+
442
+ // `escalated` is a TERMINAL: its sentence may name the real governor, but it is never
443
+ // resumable. Two governors, never mixed (F198): the STRIKE ladder (the fix loop stopped
444
+ // improving) also files category `cap-halt` — it is the MONEY cap only when the spine
445
+ // says so (a `money-halt` record, or the job-end outcome `cap-halt` itself).
446
+ if (outcome === 'escalated') {
447
+ const strikes = esc?.spend?.strikes;
448
+ const limit = esc?.spend?.strikeLimit;
449
+ if (cat === 'cap-halt' && o.moneyHalt) {
450
+ const money = typeof summary.spentUsd === 'number' && typeof summary.budgetUsd === 'number'
451
+ ? ` (${panelMoney2(summary.spentUsd)} of ${panelMoney2(summary.budgetUsd)})` : '';
452
+ return {
453
+ reason: `Money cap reached${money}.`, next: CHANGE, line: 'money cap', actions: [],
454
+ };
455
+ }
456
+ if (cat === 'cap-halt' && typeof strikes === 'number' && typeof limit === 'number') {
457
+ return {
458
+ reason: `The fix loop stopped improving (${strikes} of ${limit} tries, no check got better).`,
459
+ next: CHANGE,
460
+ line: 'stopped improving',
461
+ actions: [],
462
+ };
463
+ }
464
+ if (cat === 'wall-halt') {
465
+ return { reason: 'Time cap reached.', next: CHANGE, line: 'time cap', actions: [] };
466
+ }
467
+ if (cat === 'provider-red') {
468
+ const d = detailOf(summary.stopReason);
469
+ return {
470
+ reason: `The model provider failed${d ? ` (${d})` : ''}.`, next: CHANGE, line: 'provider failed', actions: [],
471
+ };
472
+ }
473
+ }
474
+
475
+ if (outcome === 'green' || outcome === 'already-green' || outcome === 'satisfied') {
476
+ if (o.destinationRefused) {
477
+ return {
478
+ reason: 'Goal met, but the output could not be delivered.',
479
+ next: 'Fix the destination, then Reuse workflow.',
480
+ line: `${GOAL_MET_LINE} — not delivered`,
481
+ actions: REUSE,
482
+ };
483
+ }
484
+ return { reason: 'Goal met.', next: 'Nothing to do.', line: GOAL_MET_LINE, actions: REUSE };
485
+ }
486
+ if (outcome === 'cap-halt') {
487
+ const money = typeof summary.spentUsd === 'number' && typeof summary.budgetUsd === 'number'
488
+ ? ` (${panelMoney2(summary.spentUsd)} of ${panelMoney2(summary.budgetUsd)})` : '';
489
+ return {
490
+ reason: `Money cap reached${money}.`,
491
+ next: resumeOr('Raise the cap, then Resume.'),
492
+ line: resumeOk ? 'money cap — resume' : 'money cap',
493
+ actions: resumeOk ? RESUME : [],
494
+ };
495
+ }
496
+ if (outcome === 'wall-halt') {
497
+ return {
498
+ reason: 'Time cap reached.',
499
+ next: resumeOr('Raise the time, then Resume.'),
500
+ line: resumeOk ? 'time cap — resume' : 'time cap',
501
+ actions: resumeOk ? RESUME : [],
502
+ };
503
+ }
504
+ if (outcome === 'stopped') {
505
+ // P5 item 5: the PERSON ended the leg (Stop). Nothing failed; the finished steps stand.
506
+ return {
507
+ reason: 'You stopped it.',
508
+ next: resumeOr('Resume.'),
509
+ line: resumeOk ? 'you pressed Stop — resume' : 'you pressed Stop',
510
+ actions: resumeOk ? RESUME : [],
511
+ };
512
+ }
513
+ if (outcome === 'provider-red') {
514
+ const d = detailOf(summary.stopReason);
515
+ return {
516
+ reason: `The model provider failed${d ? ` (${d})` : ''}.`,
517
+ next: resumeOr('Resume.'),
518
+ line: resumeOk ? 'provider failed — resume' : 'provider failed',
519
+ actions: resumeOk ? RESUME : [],
520
+ };
521
+ }
522
+ if (outcome === 'step-stalled') {
523
+ return {
524
+ reason: 'A step stopped making progress.',
525
+ next: resumeOr('Resume, or change the job: Clear the card and draft a new one.'),
526
+ line: resumeOk ? 'step stalled — resume' : 'step stalled',
527
+ actions: resumeOk ? RESUME : [],
528
+ };
529
+ }
530
+ if (GOAL_NOT_MET.has(outcome)) {
531
+ const d = detailOf(summary.stopReason);
532
+ return {
533
+ reason: `Goal not met — the checks said no${d ? ` (${d})` : ''}.`,
534
+ next: CHANGE,
535
+ line: 'checks said no',
536
+ actions: [],
537
+ };
538
+ }
539
+ if (outcome === 'close-red') {
540
+ return {
541
+ reason: 'The check itself broke (instrument fault), not your goal.',
542
+ next: CHANGE,
543
+ line: 'check broke',
544
+ actions: [],
545
+ };
546
+ }
547
+ // the raw engine detail is never shown here (it can name retired surfaces); the code is enough
548
+ return {
549
+ reason: `Stopped before or outside the work (code: ${outcome}).`,
550
+ next: CHANGE,
551
+ line: 'stopped before the work',
552
+ actions: [],
553
+ };
554
+ }
555
+
556
+ /**
557
+ * The Ended block for one listed run (P5 item 1): asks {@link resumePlanFor} only when
558
+ * the run's ending is one a resume could ever apply to, so a finished green never reads
559
+ * its spec files. Returns the block, the resume plan (for the confirm box's numbers) and
560
+ * nothing else — the sentences all live in {@link endedFor}.
561
+ * @param {{ spine: string, job: string, pid?: number }} row
562
+ * @param {any[]} records
563
+ * @param {any} summary `replayOne`'s summary
564
+ * @param {{died: boolean, lastThing: string|null}} death
565
+ */
566
+ /** the timestamp of the spine's LAST `job-end` record, or null (live, died, or no timestamp) */
567
+ function lastJobEndTs(/** @type {any[]} */ records) {
568
+ for (let i = records.length - 1; i >= 0; i -= 1) {
569
+ const r = records[i];
570
+ if (r && r.type === 'job-end') return typeof r.ts === 'string' ? r.ts : null;
571
+ }
572
+ return null;
573
+ }
574
+
575
+ function endedForRow(row, records, summary, death) {
576
+ const out = summary.outcome;
577
+ const maybeResumable = death.died || out === 'escalated' || (typeof out === 'string' && CHECKPOINT_OUTCOMES.includes(out));
578
+ const resume = maybeResumable ? resumePlanFor(row, records) : null;
579
+ const refused = [...records].reverse().find((r) => r && r.type === 'destination-refused') ?? null;
580
+ const ended = endedFor(summary, death, {
581
+ resume,
582
+ moneyHalt: records.some((r) => r && r.type === 'money-halt'),
583
+ destinationRefused: refused ? String(refused.detail ?? refused.code ?? 'the destination refused it') : null,
584
+ });
585
+ const esc = summary.lastEscalation;
586
+ const status = statusFor({
587
+ outcome: out, died: death.died, category: typeof esc?.category === 'string' ? esc.category : null,
588
+ moneyHalt: records.some((r) => r && r.type === 'money-halt'),
589
+ });
590
+ return { ended, resume, status };
591
+ }
592
+
593
+ /**
594
+ * P5 item 6 — a run whose row is listed (its runner is alive) but whose spine does not
595
+ * exist YET: the runner appends the row first, then opens the spine. It is `starting`,
596
+ * never `file missing`. Carries the same field names a normal row/detail does so every
597
+ * client reader (filters, groups) sees a live `▶` run, with every figure honestly null.
598
+ * @param {{ runid: string, job: string, at: string, via: string }} row
599
+ * @returns {any}
600
+ */
601
+ function startingStub(row) {
602
+ return {
603
+ runid: row.runid,
604
+ job: row.job,
605
+ at: row.at,
606
+ via: row.via,
607
+ fileMissing: false,
608
+ starting: true,
609
+ died: false,
610
+ glyph: '▶',
611
+ status: statusFor({ outcome: null }),
612
+ outcome: null,
613
+ endedLine: null,
614
+ ended: null,
615
+ resume: null,
616
+ checkType: 'unknown',
617
+ checkTypeTitle: null,
618
+ model: null,
619
+ spend: 'unknown',
620
+ wall: 'unknown',
621
+ date: typeof row.at === 'string' ? row.at.slice(0, 10) : null,
622
+ spentUsd: null,
623
+ spendComplete: false,
624
+ spendFloorUsd: null,
625
+ wallFloorMs: null,
626
+ draftSpentUsd: null,
627
+ draftSpendComplete: null,
628
+ budgetUsd: null,
629
+ steps: [],
630
+ parts: [],
631
+ };
632
+ }
633
+
278
634
  /**
279
635
  * One `/api/runs` row, or `{ ...row, fileMissing: true }` when the row's
280
636
  * spine no longer exists on disk — never silently listed as if it were
@@ -285,6 +641,7 @@ function formatDurationMs(ms) {
285
641
  */
286
642
  function summarizeRow(row) {
287
643
  if (!existsSync(row.spine)) {
644
+ if (runIsAlive(row)) return startingStub(row);
288
645
  return {
289
646
  runid: row.runid, job: row.job, at: row.at, via: row.via, fileMissing: true,
290
647
  };
@@ -300,7 +657,8 @@ function summarizeRow(row) {
300
657
  };
301
658
  }
302
659
  const line = summarizeForAllLine(summary);
303
- const death = deriveDeath(row.spine, rawRecords, summary.outcome);
660
+ const death = deriveDeath(row, rawRecords, summary.outcome);
661
+ const { ended, status } = endedForRow(row, rawRecords, summary, death);
304
662
  return {
305
663
  runid: row.runid,
306
664
  job: row.job,
@@ -308,7 +666,11 @@ function summarizeRow(row) {
308
666
  via: row.via,
309
667
  fileMissing: false,
310
668
  died: death.died,
311
- glyph: death.died ? '?' : glyphForOutcome(summary.outcome),
669
+ endedLine: ended ? ended.line : null,
670
+ // P5-R: one card per run — how many times it was resumed (a resumed run is the same run, never a second card)
671
+ resumedCount: Math.max(0, summary.legs.length - 1),
672
+ glyph: status.sign,
673
+ status,
312
674
  checkType: checkTypeLabel(summary.verdictType, row.at),
313
675
  checkTypeTitle: checkTypeTitle(summary.verdictType, row.at),
314
676
  model: summary.model,
@@ -328,13 +690,47 @@ function summarizeRow(row) {
328
690
  // no drafting fold at all, exactly like `summary.draftSpentUsd` itself.
329
691
  spentUsd: death.died ? null : summary.spentUsd,
330
692
  spendComplete: death.died ? false : summary.spendComplete,
331
- spendFloorUsd: death.died ? death.spendFloorUsd : null,
693
+ // P5 item 6: the SAME floors the right pane reads (`deriveDeath`), for a died OR a
694
+ // still-running spine — so a live card and its pane show the same numbers. Both
695
+ // null once a job-end exists (the real figures above are complete then).
696
+ spendFloorUsd: death.spendFloorUsd,
697
+ wallFloorMs: death.wallFloorMs,
332
698
  draftSpentUsd: summary.draftSpentUsd,
333
699
  draftSpendComplete: summary.draftSpendComplete,
334
700
  budgetUsd: summary.budgetUsd,
335
701
  };
336
702
  }
337
703
 
704
+ /**
705
+ * The Resume route's reader (P5 item 2): the listed row plus the resume plan, from the
706
+ * ONE {@link resumePlanFor} the Ended block asks. `null` when the runid is not listed
707
+ * or its spine is gone.
708
+ * @param {string} runid
709
+ * @param {{ home?: string }} [opts]
710
+ * @returns {{row: any, plan: any}|null}
711
+ */
712
+ export function getResumeContext(runid, opts = {}) {
713
+ const { rows } = readRunList(opts);
714
+ const row = rows.find((r) => r && r.runid === runid);
715
+ if (!row || !existsSync(row.spine)) return null;
716
+ return { row, plan: resumePlanFor(row, parseJsonl(row.spine).records) };
717
+ }
718
+
719
+ /**
720
+ * The Stop route's reader (P5 item 5): the listed row, whether its LATEST leg is live (`runIsAlive`: the row's pid
721
+ * is the latest leg's, folded from the run list's `leg-start`), and whether its spine exists yet. `null` when the
722
+ * runid is not listed.
723
+ * @param {string} runid
724
+ * @param {{ home?: string }} [opts]
725
+ * @returns {{row: any, live: boolean, spineExists: boolean}|null}
726
+ */
727
+ export function getStopContext(runid, opts = {}) {
728
+ const { rows } = readRunList(opts);
729
+ const row = rows.find((r) => r && r.runid === runid);
730
+ if (!row) return null;
731
+ return { row, live: runIsAlive(row), spineExists: existsSync(row.spine) };
732
+ }
733
+
338
734
  /**
339
735
  * `GET /api/runs` — every listed run, NEWEST FIRST BY `at` (never by file/
340
736
  * append order — a backfill scan appends rows in sorted-PATH order, which
@@ -363,6 +759,25 @@ export function listRuns(opts = {}) {
363
759
  .map(({ row }) => summarizeRow(row));
364
760
  }
365
761
 
762
+ /**
763
+ * The run-list row of a run, for the readers below. An IMPORTED run (`<importId>~<runid>`, see importrun.js) is not in
764
+ * the run list: its row is built from the imports file and a checked path inside that bundle's own `runs/` folder
765
+ * (read only, no pid — never live, never resumable from here). `undefined` when no such run.
766
+ * @param {string} runid
767
+ * @param {{ home?: string }} opts
768
+ * @returns {any}
769
+ */
770
+ function findRunRow(runid, opts) {
771
+ const imp = parseImportedRunId(runid);
772
+ if (imp) {
773
+ const bundle = readImports(opts.home).find((x) => x.id === imp.importId);
774
+ const spine = bundle ? safeSpinePath(bundle.dir, imp.runid) : null;
775
+ if (!bundle || !spine) return undefined;
776
+ return { runid, job: bundle.job, at: spineStartedAt(spine), via: 'import', spine, patient: null };
777
+ }
778
+ return readRunList(opts).rows.find((r) => r && r.runid === runid);
779
+ }
780
+
366
781
  /**
367
782
  * `GET /api/runs/:runid` — the full replay for the Run tab: steps (or
368
783
  * iterations), counters, summary-box fields. Looks `runid` up in the run
@@ -374,28 +789,29 @@ export function listRuns(opts = {}) {
374
789
  * @returns {any|null}
375
790
  */
376
791
  export function getRunDetail(runid, opts = {}) {
377
- const { rows } = readRunList(opts);
378
- const row = rows.find((r) => r && r.runid === runid);
792
+ const row = findRunRow(runid, opts);
379
793
  if (!row) return null;
380
794
  if (!existsSync(row.spine)) {
795
+ if (runIsAlive(row)) return startingStub(row);
381
796
  return {
382
797
  runid, job: row.job, at: row.at, via: row.via, fileMissing: true,
383
798
  };
384
799
  }
385
800
  // build item 7 (2026-09-28): resolve the audit path through the ONE
386
- // shared resolver (`resolveAuditPathForRow`, already the sole owner for
801
+ // shared resolver (`resolveAuditPathsForRow`, already the sole owner for
387
802
  // `scopedBehaviour`/`getRunAudit`) BEFORE calling `replayOne`, and hand it
388
803
  // the result — otherwise `replayOne`'s own internal resolution only ever
389
804
  // finds the FINISHED-run convention, so a still-LIVE run's part-level
390
805
  // `byTool`/`toolCalls` (summary.parts, read from `replayRun`) read
391
806
  // "unknown" even though the SAME run's Run-tab tools/cache summary and
392
- // Audit tab (both driven through `resolveAuditPathForRow` already) can
807
+ // Audit tab (both driven through `resolveAuditPathsForRow` already) can
393
808
  // see the during-run sidecar just fine.
394
809
  const rawSpineRecords = parseJsonl(row.spine).records;
395
- const auditPath = resolveAuditPathForRow(row, rawSpineRecords);
396
- const summary = replayOne(row.spine, { auditPathOverride: auditPath });
810
+ const auditPaths = resolveAuditPathsForRow(row, rawSpineRecords);
811
+ const summary = replayOne(row.spine, { auditPathOverride: auditPaths.length > 0 ? auditPaths : null });
397
812
  const timelineKind = summary.timelineKind;
398
- const death = deriveDeath(row.spine, rawSpineRecords, summary.outcome);
813
+ const death = deriveDeath(row, rawSpineRecords, summary.outcome);
814
+ const { ended, resume, status } = endedForRow(row, rawSpineRecords, summary, death);
399
815
  // judgeModel (Summary box "judge:" line): the FIRST `judge-round`'s own
400
816
  // `model` field (src/planrun.js:1704's `onJudgeCost` emit) — a soft-green
401
817
  // close's own paid judge seam, distinct from the worker `model` above.
@@ -463,7 +879,7 @@ export function getRunDetail(runid, opts = {}) {
463
879
  id: u.id,
464
880
  occurrence: u.occurrence,
465
881
  outcome: u.outcome ?? null,
466
- state: stateFor(u.outcome, isLast),
882
+ state: u.stopReason ? 'stopped' : stateFor(u.outcome, isLast),
467
883
  rounds: u.rounds,
468
884
  toolCalls: u.toolCalls,
469
885
  wallMs: u.wallMs,
@@ -475,6 +891,29 @@ export function getRunDetail(runid, opts = {}) {
475
891
  attempts: u.attempts,
476
892
  };
477
893
  });
894
+ // P5-R: a step cut off by its leg's halt and CONTINUED by the next leg is ONE step — the summary counts steps, not
895
+ // parts. The continued part replaces the stopped one in this list (so it is done iff its continuation is), and the
896
+ // two parts' rounds/time/spend/attempts are added up (an unknown figure stays unknown).
897
+ /** @type {any[]} */
898
+ const merged = [];
899
+ summary.steps.forEach((u, idx) => {
900
+ const cur = steps[idx];
901
+ const prev = merged[merged.length - 1];
902
+ if (u.continued === true && prev && prev.id === cur.id) {
903
+ const add = (/** @type {number|null} */ a, /** @type {number|null} */ b) => (typeof a === 'number' && typeof b === 'number' ? a + b : null);
904
+ merged[merged.length - 1] = {
905
+ ...cur,
906
+ occurrence: prev.occurrence,
907
+ rounds: add(prev.rounds, cur.rounds),
908
+ toolCalls: add(prev.toolCalls, cur.toolCalls),
909
+ wallMs: add(prev.wallMs, cur.wallMs),
910
+ spentUsd: add(prev.spentUsd, cur.spentUsd),
911
+ unpricedRounds: (prev.unpricedRounds ?? 0) + (cur.unpricedRounds ?? 0),
912
+ attempts: [...(prev.attempts ?? []), ...(cur.attempts ?? [])].map((a, n) => ({ ...a, n: n + 1 })),
913
+ };
914
+ } else merged.push(cur);
915
+ });
916
+ steps = merged;
478
917
  }
479
918
  // died before any step/iteration ever started (steps empty — the run was
480
919
  // still in scout/planning) — one placeholder box, never an empty map. Its
@@ -521,10 +960,32 @@ export function getRunDetail(runid, opts = {}) {
521
960
  provider: summary.provider,
522
961
  judgeModel,
523
962
  budgetUsd: summary.budgetUsd,
524
- glyph: death.died ? '?' : glyphForOutcome(summary.outcome),
963
+ glyph: status.sign,
964
+ status,
965
+ // the header's times: when the run started (its list row) and when its last job-end was written (null while live / died)
966
+ startedAt: typeof row.at === 'string' ? row.at : null,
967
+ endedAt: lastJobEndTs(rawSpineRecords),
525
968
  outcome: summary.outcome,
526
969
  died: death.died,
527
970
  stopReason: death.died ? death.why : summary.stopReason,
971
+ // P5 item 1: the Ended block — `{reason, next, line, actions}` (code-owned fixed
972
+ // sentences, {@link endedFor}), `null` while the run is live. `resume` carries the
973
+ // numbers the Resume confirm box prefills (the signed caps, spent so far); `null`
974
+ // whenever Resume is not offered.
975
+ ended,
976
+ // P5 item 5: is the run's LATEST leg live (the page offers Stop only then), and has a stop been asked for
977
+ // (the request file exists beside the spine) — the page reads "stopping after this turn…" until the leg ends
978
+ live: row.via !== 'import' && runIsAlive(row),
979
+ stopping: row.via !== 'import' && runIsAlive(row) && existsSync(stopFilePath(row.spine)),
980
+ // P5-R: the run's legs in order (`after` = how the leg before ended) and the resume count — the Audit tab's
981
+ // dividers and the map's connectors read these; never a second derivation of where a leg starts
982
+ legs: summary.legs,
983
+ legDividers: legDividersFor(rawSpineRecords, summary.parts),
984
+ resumedCount: Math.max(0, summary.legs.length - 1),
985
+ resume: resume && resume.ok ? {
986
+ budgetUsd: resume.budgetUsd, maxWallMin: resume.maxWallMin, spentUsd: resume.spentUsd, spendComplete: resume.spendComplete,
987
+ wallUsedMs: resume.wallUsedMs,
988
+ } : null,
528
989
  spentUsd: summary.spentUsd,
529
990
  // draftSpentUsd (hamr's ruling 2026-09-28) — the drafting share of this
530
991
  // run's cap, or null when this run carried none. `src/replay.js`'s
@@ -579,7 +1040,7 @@ export function getRunDetail(runid, opts = {}) {
579
1040
  // gate-audit rows the Audit tab now shows (this run's own job-start..
580
1041
  // job-end ts window, item 2's fix for a real measured defect: a
581
1042
  // gate-audit sidecar CAN carry other runs' rows when several spines
582
- // share one filename — see {@link runAuditWindow}'s doc) — never
1043
+ // share one filename — see {@link runAuditWindows}'s doc) — never
583
1044
  // `replayRun`'s own top-level `summary.behaviour`, which reads the
584
1045
  // WHOLE sidecar file unscoped and is contaminated on a real archived run
585
1046
  // (measured: pulselog-person-live-2's mu2p83go reads 142 unscoped tool
@@ -611,12 +1072,16 @@ export function getRunDetail(runid, opts = {}) {
611
1072
  * about which rows belong to a run) — kept here only to find `job-start`/
612
1073
  * `job-end` off the raw `spineRecords` array the panel already has in hand.
613
1074
  * @param {any[]} spineRecords
614
- * @returns {{startTs: number, endTs: number}}
1075
+ * @returns {{startTs: number, endTs: number}[]} one window per leg of the run (P5-R); one for a run nobody resumed
615
1076
  */
616
- function runAuditWindow(spineRecords) {
617
- const jobStart = spineRecords.find((r) => r && r.type === 'job-start') ?? null;
618
- const jobEnd = [...spineRecords].reverse().find((r) => r && r.type === 'job-end') ?? null;
619
- return auditWindow(jobStart, jobEnd);
1077
+ function runAuditWindows(spineRecords) {
1078
+ return auditWindowsOf(spineRecords);
1079
+ }
1080
+
1081
+ /** @param {string} ts @param {{startTs: number, endTs: number}[]} windows */
1082
+ function inAnyWindow(ts, windows) {
1083
+ const ms = Date.parse(ts);
1084
+ return Number.isFinite(ms) && windows.some((w) => ms >= w.startTs && ms <= w.endTs);
620
1085
  }
621
1086
 
622
1087
  /**
@@ -691,7 +1156,7 @@ function makeRoundLookup(spineRecords) {
691
1156
  * `backfill` row (never a live run) and on some pre-cutoff `run-u` rows
692
1157
  * — both correctly fall through to `null` here.
693
1158
  * Callers still scope the rows they read from this file by this run's own
694
- * job-start..job-end ts window ({@link runAuditWindow}) — a shared tree/
1159
+ * job-start..job-end ts window ({@link runAuditWindows}) — a shared tree/
695
1160
  * worktree gate-audit file can carry another run's rows too (this file's own
696
1161
  * header comment on `runAuditWindow`), live or finished.
697
1162
  *
@@ -705,42 +1170,44 @@ function makeRoundLookup(spineRecords) {
705
1170
  * rename target, not a panel-server read-path bug).
706
1171
  * @param {{spine: string, patient: string|null}} row
707
1172
  * @param {any[]} spineRecords
708
- * @returns {string|null}
1173
+ * @returns {string[]} every file the run's tool log is in, earliest leg first (empty: none was ever written)
709
1174
  */
710
- function resolveAuditPathForRow(row, spineRecords) {
1175
+ function resolveAuditPathsForRow(row, spineRecords) {
1176
+ /** @type {string[]} */
1177
+ const paths = [];
711
1178
  const { auditPath } = resolveSiblings(row.spine);
712
- if (auditPath) return auditPath;
713
- const hasJobEnd = spineRecords.some((r) => r && r.type === 'job-end');
714
- if (hasJobEnd) return null;
715
- if (typeof row.patient !== 'string' || row.patient.length === 0) return null;
1179
+ if (auditPath && existsSync(auditPath)) paths.push(auditPath);
1180
+ // P5-R: a resumed run's LIVE leg writes its tool log in the patient tree while the earlier legs' rows already sit in
1181
+ // the run's own file — both are the run's, in that order. "Live" = the LATEST leg has no job-end yet.
1182
+ const last = legsOf(spineRecords).at(-1);
1183
+ const hasJobEnd = last ? last.jobEnd !== null : spineRecords.some((r) => r && r.type === 'job-end');
1184
+ if (hasJobEnd) return paths;
1185
+ if (paths.length > 0 && legsOf(spineRecords).length <= 1) return paths; // a never-resumed run's sibling is the whole log
1186
+ if (typeof row.patient !== 'string' || row.patient.length === 0) return paths;
716
1187
  const live = join(row.patient, 'gate-audit.jsonl');
717
- return existsSync(live) ? live : null;
1188
+ if (existsSync(live) && !paths.includes(live)) paths.push(live);
1189
+ return paths;
718
1190
  }
719
1191
 
720
1192
  /**
721
1193
  * `runBehaviour`, fed only the gate-audit rows inside this run's own ts
722
- * window (see {@link runAuditWindow}) — the panel-side fix for the same
1194
+ * window (see {@link runAuditWindows}) — the panel-side fix for the same
723
1195
  * contamination `getRunAudit` fixes for the Audit tab, kept as ONE shared
724
1196
  * scoping function so the Audit tab and the Run tab's tools/cache summary
725
1197
  * can never disagree with each other about which rows belong to this run.
726
1198
  * `null` when no gate-audit sidecar exists at all (never a fake all-zero
727
1199
  * object — same rule `replayRun`'s own `auditAvailable` already follows).
728
1200
  * `auditPath` resolution (finished sibling, or the live during-run fallback)
729
- * is {@link resolveAuditPathForRow} — the one shared owner.
1201
+ * is {@link resolveAuditPathsForRow} — the one shared owner.
730
1202
  * @param {{spine: string, patient: string|null}} row
731
1203
  * @param {any[]} spineRecords
732
1204
  * @returns {ReturnType<typeof runBehaviour>|null}
733
1205
  */
734
1206
  function scopedBehaviour(row, spineRecords) {
735
- const auditPath = resolveAuditPathForRow(row, spineRecords);
736
- if (!auditPath || !existsSync(auditPath)) return null;
737
- const { records } = parseJsonl(auditPath);
738
- const { startTs, endTs } = runAuditWindow(spineRecords);
739
- const windowed = records.filter((r) => {
740
- if (!r || typeof r !== 'object' || typeof r.ts !== 'string') return false;
741
- const ms = Date.parse(r.ts);
742
- return Number.isFinite(ms) && ms >= startTs && ms <= endTs;
743
- });
1207
+ const auditPaths = resolveAuditPathsForRow(row, spineRecords);
1208
+ if (auditPaths.length === 0) return null;
1209
+ const windows = runAuditWindows(spineRecords);
1210
+ const windowed = auditPaths.flatMap((ap) => parseJsonl(ap).records).filter((r) => r && typeof r === 'object' && typeof r.ts === 'string' && inAnyWindow(r.ts, windows));
744
1211
  return runBehaviour(windowed);
745
1212
  }
746
1213
 
@@ -881,8 +1348,7 @@ function shortenPath(path, root) {
881
1348
  * @returns {{runid: string, rows: any[], raw: string, empty: boolean, reason: 'no-sidecar'|'sidecar-empty'|null}|null}
882
1349
  */
883
1350
  export function getRunAudit(runid, opts = {}) {
884
- const { rows } = readRunList(opts);
885
- const row = rows.find((r) => r && r.runid === runid);
1351
+ const row = findRunRow(runid, opts);
886
1352
  if (!row) return null;
887
1353
  if (!existsSync(row.spine)) {
888
1354
  return {
@@ -890,18 +1356,18 @@ export function getRunAudit(runid, opts = {}) {
890
1356
  };
891
1357
  }
892
1358
  const { records: spineRecords, skipped: spineSkipped } = parseJsonl(row.spine);
893
- // {@link resolveAuditPathForRow} needs `spineRecords` (to know whether this
1359
+ // {@link resolveAuditPathsForRow} needs `spineRecords` (to know whether this
894
1360
  // run has reached `job-end` yet) before it can decide whether the LIVE
895
1361
  // during-run fallback is even worth trying — so the spine is parsed once,
896
1362
  // here, before the sidecar path is resolved (moved up from right after the
897
1363
  // old `resolveSiblings`-only check this replaces).
898
- const auditPath = resolveAuditPathForRow(row, spineRecords);
899
- if (!auditPath || !existsSync(auditPath)) {
1364
+ const auditPaths = resolveAuditPathsForRow(row, spineRecords);
1365
+ if (auditPaths.length === 0) {
900
1366
  return {
901
1367
  runid, rows: [], raw: '', empty: true, reason: 'no-sidecar',
902
1368
  };
903
1369
  }
904
- const { startTs, endTs } = runAuditWindow(spineRecords);
1370
+ const windows = runAuditWindows(spineRecords);
905
1371
  const roundOf = makeRoundLookup(spineRecords);
906
1372
  const roundRecords = sortedRoundRecords(spineRecords);
907
1373
  // `preParsedSpine` avoids a second parse of the same spine file just read
@@ -914,30 +1380,25 @@ export function getRunAudit(runid, opts = {}) {
914
1380
  });
915
1381
  const partOf = makePartLookup(summary);
916
1382
 
917
- const { records } = parseJsonl(auditPath);
918
- const windowed = records.filter((r) => {
919
- if (!r || typeof r !== 'object' || typeof r.ts !== 'string') return false;
920
- const ms = Date.parse(r.ts);
921
- return Number.isFinite(ms) && ms >= startTs && ms <= endTs;
922
- });
1383
+ const { records } = { records: auditPaths.flatMap((ap) => parseJsonl(ap).records) };
1384
+ const windowed = records.filter((r) => r && typeof r === 'object' && typeof r.ts === 'string' && inAnyWindow(r.ts, windows));
923
1385
  // item 1 (build item, 2026-09-26): "Raw log" must show only THIS run's own
924
1386
  // window, same rule as `windowed` above (a sidecar can carry other runs'
925
- // rows — see {@link runAuditWindow}'s doc). Filtered at the LINE level
1387
+ // rows — see {@link runAuditWindows}'s doc). Filtered at the LINE level
926
1388
  // (never the parsed-record level) so a matched line stays byte-for-byte —
927
1389
  // "raw" means raw, not a re-serialized JSON.stringify of the parsed
928
1390
  // object. A line whose `ts` can't be parsed (malformed JSON, or a
929
1391
  // well-formed row missing/mistyping `ts`) is dropped rather than guessed
930
1392
  // into the window, matching the same honesty rule `windowed` already
931
1393
  // follows for its own rows.
932
- const rawText = readFileSync(auditPath, 'utf8')
1394
+ const rawText = auditPaths.map((ap) => readFileSync(ap, 'utf8'))
1395
+ .join('\n')
933
1396
  .split('\n')
934
1397
  .filter((line) => line.trim() !== '')
935
1398
  .filter((line) => {
936
1399
  let r;
937
1400
  try { r = JSON.parse(line); } catch { return false; }
938
- if (!r || typeof r !== 'object' || typeof r.ts !== 'string') return false;
939
- const ms = Date.parse(r.ts);
940
- return Number.isFinite(ms) && ms >= startTs && ms <= endTs;
1401
+ return !!r && typeof r === 'object' && typeof r.ts === 'string' && inAnyWindow(r.ts, windows);
941
1402
  })
942
1403
  .join('\n');
943
1404
  // item 2 (2026-09-26 build spec): every row's path shortened relative to
@@ -1016,8 +1477,7 @@ export const ROUNDS_PAGE_MAX = 200;
1016
1477
  * @returns {any|null}
1017
1478
  */
1018
1479
  export function getRunRounds(runid, query = {}, opts = {}) {
1019
- const { rows } = readRunList(opts);
1020
- const row = rows.find((r) => r && r.runid === runid);
1480
+ const row = findRunRow(runid, opts);
1021
1481
  if (!row) return null;
1022
1482
  if (!existsSync(row.spine)) return null;
1023
1483
 
@@ -1545,8 +2005,7 @@ function jobsDir() {
1545
2005
  * @returns {any|null}
1546
2006
  */
1547
2007
  export function getRunJob(runid, opts = {}) {
1548
- const { rows } = readRunList(opts);
1549
- const row = rows.find((r) => r && r.runid === runid);
2008
+ const row = findRunRow(runid, opts);
1550
2009
  if (!row) return null;
1551
2010
 
1552
2011
  const near = existsSync(row.spine) ? sourceNearSpine(row.spine) : { specPath: null, sourceJsonPath: null };
@@ -1695,6 +2154,290 @@ export function getRunJob(runid, opts = {}) {
1695
2154
  return none();
1696
2155
  }
1697
2156
 
2157
+ // ─────────────────────────────────────────────────────────────────────────────────────────────────────────────
2158
+ // REUSE WORKFLOW (hamr 2026-10-03; replaces P5 item 3's "Start from this"). A GREEN run — or an imported job — can be
2159
+ // run again as the SAME signed workflow on a new source. This section is the ONE owner of (a) the prefill, (b) which
2160
+ // boxes are locked, (c) the track record. The page asks; it never decides. A reuse is a NEW run (a new runid).
2161
+ // Nothing here drafts: changing the goal, checks, guardrails, judge examples or model is Clear (then draft).
2162
+ // ─────────────────────────────────────────────────────────────────────────────────────────────────────────────
2163
+
2164
+ /**
2165
+ * The signed spec a run's LATEST leg ran under — the `resolved-spec*.json` beside the run whose hash is the one its
2166
+ * latest `job-start` carries (the same lookup `resumePlanFor` does). `null` = no such file.
2167
+ * @param {{ spine: string }} row
2168
+ * @param {any[]} records
2169
+ * @returns {{spec: any, specPath: string, specHash: string}|null}
2170
+ */
2171
+ function signedSpecForRun(row, records) {
2172
+ const jobStart = (legsOf(records).findLast((l) => l.jobStart !== null) ?? null)?.jobStart ?? null;
2173
+ if (!jobStart || typeof jobStart.specHash !== 'string') return null;
2174
+ const near = sourceNearSpine(row.spine);
2175
+ if (!near.specPath) return null;
2176
+ const dir = dirname(near.specPath);
2177
+ /** @type {string[]} */
2178
+ let names = [];
2179
+ try { names = readdirSync(dir).filter((n) => n === 'resolved-spec.json' || /^resolved-spec-r\d+\.json$/.test(n)); } catch { names = []; }
2180
+ for (const n of names) {
2181
+ let spec;
2182
+ try { spec = JSON.parse(readFileSync(join(dir, n), 'utf8')); } catch { continue; }
2183
+ if (!spec || typeof spec !== 'object') continue;
2184
+ if (jobSpecHash(spec) === jobStart.specHash) return { spec, specPath: join(dir, n), specHash: jobStart.specHash };
2185
+ }
2186
+ return null;
2187
+ }
2188
+
2189
+ /**
2190
+ * The worker a signed spec ran: provider, baseUrl and model, the model falling back to the provider's default tier
2191
+ * when the spec names none (the same fallback `resolveWorkerModel` makes at run time).
2192
+ * @param {any} spec
2193
+ * @returns {{provider: string, baseUrl?: string, model: string}}
2194
+ */
2195
+ function workerOfSpec(spec) {
2196
+ const provider = typeof spec?.provider === 'string' ? spec.provider : '';
2197
+ let model = typeof spec?.model === 'string' ? spec.model : '';
2198
+ if (!model) { try { model = resolveProvider(provider).tiers.sonnet; } catch { model = ''; } }
2199
+ return { provider, ...(typeof spec?.baseUrl === 'string' && spec.baseUrl ? { baseUrl: spec.baseUrl } : {}), model };
2200
+ }
2201
+ /** @param {{provider: string, baseUrl?: string, model: string}} w @returns {string} one spelling of "the same worker" */
2202
+ const workerId = (w) => `${w.provider}\u0000${w.baseUrl ?? ''}\u0000${w.model}`;
2203
+ /**
2204
+ * The worker the reuse card's CURRENT Model box names: the Settings row's provider, baseUrl and Name. `null` when the
2205
+ * box is blank or names no row.
2206
+ * @param {string} model a Settings Name
2207
+ * @param {{ home?: string }} opts
2208
+ * @returns {{provider: string, baseUrl?: string, model: string}|null}
2209
+ */
2210
+ function workerOfChoice(model, opts) {
2211
+ const c = modelChoiceFor(rowsForHome(keysHome(opts.home)), model);
2212
+ return c ? { provider: c.provider, ...(c.baseUrl ? { baseUrl: c.baseUrl } : {}), model: c.name } : null;
2213
+ }
2214
+
2215
+ /**
2216
+ * The track record of a WORKFLOW: every LISTED run whose signed spec (the one its latest leg ran under) has this
2217
+ * `workflowKey` — the same goal, checks, guardrails, model and close, whatever source, destination or caps. One run =
2218
+ * ONE entry (a resumed run is one run, its FINAL outcome — `legsOf`), and a run that is still live is neither green
2219
+ * nor not-green yet. `avgSpendUsd` / `avgWallMs` average the FINISHED runs (a recorded terminal) whose figure is exact
2220
+ * (chain spend priced; working time from readable stamps, resume gaps excluded) — `null` when no run qualifies, never 0.
2221
+ * Read from the runs the panel already lists: no registry, no plan handover (hamr ruling A).
2222
+ * `opts.worker` narrows the record to runs on the same WORKER (provider + baseUrl + model — Model is open on a reuse, so
2223
+ * the key no longer separates models): `undefined` = every model, a worker = only runs whose signed spec ran that one,
2224
+ * `null` = no model chosen, so no run matches.
2225
+ * @param {string} key a {@link workflowKey}
2226
+ * @param {{ home?: string, worker?: {provider: string, baseUrl?: string, model: string}|null }} [opts]
2227
+ * @returns {{runs: number, green: number, notGreen: number, live: number, finished: number, avgSpendUsd: number|null, avgWallMs: number|null}}
2228
+ */
2229
+ export function trackRecordFor(key, opts = {}) {
2230
+ const { rows } = readRunList(opts);
2231
+ let green = 0;
2232
+ let notGreen = 0;
2233
+ let live = 0;
2234
+ let finished = 0;
2235
+ let spendSum = 0;
2236
+ let spendN = 0;
2237
+ let wallSum = 0;
2238
+ let wallN = 0;
2239
+ for (const row of rows) {
2240
+ if (!row || !existsSync(row.spine)) continue;
2241
+ let records;
2242
+ try { records = parseJsonl(row.spine).records; } catch { continue; }
2243
+ const legs = legsOf(records);
2244
+ const signed = signedSpecForRun(row, records);
2245
+ if (!signed || workflowKey(signed.spec) !== key) continue;
2246
+ if (opts.worker !== undefined && (opts.worker === null || workerId(workerOfSpec(signed.spec)) !== workerId(opts.worker))) continue;
2247
+ const outcome = legs.at(-1)?.outcome ?? null;
2248
+ if (outcome === null && runIsAlive(row)) { live += 1; continue; }
2249
+ if (outcome === 'green') green += 1; else notGreen += 1;
2250
+ // the averages read FINISHED runs only (a recorded terminal), and only figures that are exact: an unpriced run
2251
+ // or one with unreadable stamps is left out, never counted as $0 or 0 min (unknown is never zero)
2252
+ if (outcome === null) continue;
2253
+ finished += 1;
2254
+ const sp = chainSpend(records);
2255
+ if (sp.complete) { spendSum += sp.usd; spendN += 1; }
2256
+ const wall = legsWallMs(legs);
2257
+ if (wall.ms !== null && wall.complete) { wallSum += wall.ms; wallN += 1; }
2258
+ }
2259
+ return {
2260
+ runs: green + notGreen + live, green, notGreen, live, finished,
2261
+ avgSpendUsd: spendN > 0 ? spendSum / spendN : null,
2262
+ avgWallMs: wallN > 0 ? wallSum / wallN : null,
2263
+ };
2264
+ }
2265
+
2266
+ /**
2267
+ * The estimate line above a reuse card (code-owned fixed text): `Same job — G green · N not green · about $X and
2268
+ * M min a run`, from the workflow's own listed runs. What is unknown is said, never rendered as $0 or 0 min.
2269
+ * @param {{runs?: number, green: number, notGreen: number, finished?: number, avgSpendUsd: number|null, avgWallMs?: number|null}|null} record
2270
+ * @returns {string}
2271
+ */
2272
+ export function reuseLine(record) {
2273
+ if (!record) return 'Same job';
2274
+ if ('runs' in record && record.runs === 0) return 'Same job — no runs yet on this model';
2275
+ const head = `Same job — ${record.green} green · ${record.notGreen} not green`;
2276
+ const wallMs = record.avgWallMs ?? null;
2277
+ const cost = record.avgSpendUsd === null ? null : panelMoney2(record.avgSpendUsd);
2278
+ const mins = wallMs === null ? null : (wallMs < 60_000 ? '<1' : String(Math.round(wallMs / 60_000)));
2279
+ if (cost !== null && mins !== null) return `${head} · about ${cost} and ${mins} min a run`;
2280
+ if (cost !== null) return `${head} · about ${cost} a run, time not recorded`;
2281
+ if (mins !== null) return `${head} · about ${mins} min a run, cost not recorded`;
2282
+ return (record.finished ?? record.green + record.notGreen) === 0
2283
+ ? `${head} · no finished run yet to price or time`
2284
+ : `${head} · cost and time not recorded`;
2285
+ }
2286
+
2287
+ /**
2288
+ * `start-from` (the route keeps its name) for one listed run: the card prefilled from the run's SIGNED job, which
2289
+ * boxes are locked and which are open, and the track record. Locked boxes carry the signed values, read by the
2290
+ * SAME readers the Job tab uses (`successFromSpec`/`guardrailsFromSpec`); the open four — Source, Destination, the
2291
+ * $ cap and the Time cap — start at what the run had. `ok:false` when the run's signed job is not on disk (nothing
2292
+ * to copy: change the job: Clear the card). `null` when the runid is not listed.
2293
+ * @param {string} runid
2294
+ * @param {{ home?: string, model?: string }} [opts] `model` = the Model box's current Name (the estimate counts only runs on that worker); absent = the card's own Model
2295
+ * @returns {{ok: true, origin: {runid: string, job: string}, card: Record<string, any>, from: 'signed job',
2296
+ * locked: readonly string[], open: readonly string[], note: string|null,
2297
+ * specHash: string, workflowKey: string, spec: any, specPath: string,
2298
+ * trackRecord: ReturnType<typeof trackRecordFor>, line: string}|{ok: false, error: string}|null}
2299
+ */
2300
+ export function getStartFrom(runid, opts = {}) {
2301
+ const { rows } = readRunList(opts);
2302
+ const row = rows.find((r) => r && r.runid === runid);
2303
+ if (!row) return null;
2304
+ if (!existsSync(row.spine)) return { ok: false, error: 'this run has no log on disk — nothing to reuse' };
2305
+ const records = parseJsonl(row.spine).records;
2306
+ const signed = signedSpecForRun(row, records);
2307
+ if (!signed) return { ok: false, error: "this run's signed job is not on disk — nothing to reuse (change the job: Clear the card and draft a new one)" };
2308
+ const near = sourceNearSpine(row.spine);
2309
+ const spec = signed.spec;
2310
+ /** @type {Record<string, any>} */
2311
+ let saved = {};
2312
+ if (near.specPath) {
2313
+ try {
2314
+ const raw = JSON.parse(readFileSync(join(dirname(near.specPath), 'card.json'), 'utf8'));
2315
+ if (raw && typeof raw === 'object') saved = raw;
2316
+ } catch { saved = {}; }
2317
+ }
2318
+ let manifest = null;
2319
+ if (near.sourceJsonPath) { try { manifest = JSON.parse(readFileSync(near.sourceJsonPath, 'utf8')); } catch { manifest = null; } }
2320
+ const jobStart = records.find((r) => r && r.type === 'job-start') ?? null;
2321
+ const card = reuseCardFromSpec(spec, {
2322
+ model: typeof saved.model === 'string' && saved.model ? saved.model
2323
+ : (findRow(rowsForHome(keysHome(opts.home)), { provider: spec.provider, baseUrl: spec.baseUrl, model: spec.model })?.name
2324
+ ?? (typeof jobStart?.model === 'string' ? jobStart.model : '')),
2325
+ source: typeof saved.source === 'string' && saved.source ? saved.source : (typeof manifest?.source === 'string' ? manifest.source : (typeof row.patient === 'string' ? row.patient : '')),
2326
+ judgeExamples: typeof saved.judgeExamples === 'string' ? saved.judgeExamples : '',
2327
+ jobName: row.job,
2328
+ });
2329
+ const key = workflowKey(spec);
2330
+ const trackRecord = trackRecordFor(key, { ...opts, worker: workerOfChoice(opts.model ?? card.model, opts) });
2331
+ return {
2332
+ ok: true,
2333
+ origin: { runid, job: row.job },
2334
+ card,
2335
+ from: 'signed job',
2336
+ locked: REUSE_LOCKED_FIELDS,
2337
+ open: REUSE_OPEN_FIELDS,
2338
+ note: card.checkType === 'rubric' && card.judgeExamples === '' ? 'judge examples were not saved for this run' : null,
2339
+ specHash: signed.specHash,
2340
+ workflowKey: key,
2341
+ spec,
2342
+ specPath: signed.specPath,
2343
+ trackRecord,
2344
+ line: reuseLine(trackRecord),
2345
+ };
2346
+ }
2347
+
2348
+ /**
2349
+ * The reuse card for a signed spec: locked boxes from the spec through the Job tab's own readers, open boxes from
2350
+ * the spec's own fence and caps (Source and the saved text from the caller — a spec carries neither).
2351
+ * @param {any} spec
2352
+ * @param {{model: string, source: string, judgeExamples: string, jobName: string}} extra
2353
+ * @returns {Record<string, any>}
2354
+ */
2355
+ function reuseCardFromSpec(spec, extra) {
2356
+ const writeScope = Array.isArray(spec.writeScope) ? spec.writeScope.filter((/** @type {any} */ x) => typeof x === 'string') : [];
2357
+ return {
2358
+ jobName: typeof spec.job === 'string' && spec.job ? spec.job : extra.jobName,
2359
+ checkType: spec.verdictType === 'soft-green' ? 'rubric' : 'deterministic',
2360
+ model: typeof spec.model === 'string' && spec.model && !extra.model ? spec.model : extra.model,
2361
+ goal: typeof spec.goal === 'string' ? spec.goal : '',
2362
+ source: extra.source,
2363
+ destination: writeScope.join(', '),
2364
+ success: successFromSpec(spec) ?? '',
2365
+ guardrails: guardrailsFromSpec(spec) ?? '',
2366
+ judgeExamples: extra.judgeExamples,
2367
+ capUsd: typeof spec.budgetUsd === 'number' ? spec.budgetUsd : null,
2368
+ maxWallMs: typeof spec.maxWallMs === 'number' ? spec.maxWallMs : undefined,
2369
+ };
2370
+ }
2371
+
2372
+ /**
2373
+ * P5 item 4 — the facts of a spec for an IMPORTED job's view, read by the SAME readers the Job tab uses for a run
2374
+ * (so an imported job and a run read alike): check type, goal, success checks, guardrails, model, caps.
2375
+ * @param {any} spec
2376
+ * @returns {{checkType: string, goal: string, success: string, guardrails: string, model: string, budgetUsd: number|null, maxWallMs: number|null}}
2377
+ */
2378
+ function describeSpec(spec) {
2379
+ const nr = (/** @type {string|null} */ v) => (typeof v === 'string' && v.length > 0 ? v : 'not recorded');
2380
+ return {
2381
+ checkType: checkTypeLabel(typeof spec.verdictType === 'string' ? spec.verdictType : null, null),
2382
+ goal: nr(typeof spec.goal === 'string' ? spec.goal : null),
2383
+ success: nr(successFromSpec(spec)),
2384
+ guardrails: nr(guardrailsFromSpec(spec)),
2385
+ model: nr(typeof spec.model === 'string' ? spec.model : null),
2386
+ budgetUsd: typeof spec.budgetUsd === 'number' ? spec.budgetUsd : null,
2387
+ maxWallMs: typeof spec.maxWallMs === 'number' ? spec.maxWallMs : null,
2388
+ };
2389
+ }
2390
+
2391
+ /**
2392
+ * Reuse workflow, for an IMPORTED job: the bundle is read AGAIN now and must be clean (`readBundle` — its own hash
2393
+ * covers every close script, so a swapped script is `bundle-tampered` here), must resolve its own `bareloop`
2394
+ * dependency (`checkBundleDeps`), and every close stage's signed sha256 must match the script's bytes on disk
2395
+ * (`checkCloseByteSignature`, over the spec with `$BARELOOP_BUNDLE` resolved to this folder — the same in-memory
2396
+ * substitution `bareloop run <bundle>` makes). The reuse spec is that resolved spec; the close scripts stay where
2397
+ * they were just verified (the engine re-verifies their bytes at run start and before every close run). The person
2398
+ * signs it on this machine at [Sign & run]. Same card, same locked/open boxes as a run's reuse. `null` when no such
2399
+ * imported job.
2400
+ * @param {string} id
2401
+ * @param {{ home?: string, model?: string }} [opts] `model` as for {@link getStartFrom}
2402
+ * @returns {{ok: true, origin: {runid: null, job: string, importId: string}, card: Record<string, any>, from: 'imported job',
2403
+ * locked: readonly string[], open: readonly string[], note: string|null, specHash: string, workflowKey: string, spec: any,
2404
+ * specPath: null, trackRecord: ReturnType<typeof trackRecordFor>, line: string}|{ok: false, error: string}|null}
2405
+ */
2406
+ export function getStartFromImport(id, opts = {}) {
2407
+ const row = readImports(opts.home).find((r) => r.id === id);
2408
+ if (!row) return null;
2409
+ const st = bundleStatus(row);
2410
+ if (st.status === 'changed') return { ok: false, error: IMPORT_CHANGED_LINE };
2411
+ if (st.status !== 'ok') return { ok: false, error: `${st.statusText ?? 'this imported job cannot be read'} — re-import it before reusing it` };
2412
+ const deps = checkBundleDeps(row.dir);
2413
+ if (!deps.ok) return { ok: false, error: deps.reds[0]?.detail ?? 'the imported job cannot resolve its own dependency' };
2414
+ const { spec } = resolveBundleSpec(st.bundle, row.dir);
2415
+ const bytes = checkCloseByteSignature(spec, row.dir);
2416
+ if (!bytes.ok) return { ok: false, error: `a close script of this imported job does not match its signed bytes (${bytes.reds.map((r) => r.stage).join(', ')}) — refusing to reuse it` };
2417
+ // the bundle's provider is no longer forced: Model is open on a reuse, so the person's chosen Settings row is the worker.
2418
+ // The card opens on the row matching the bundle's own provider when there is one, else blank (the page's menu picks).
2419
+ const rows = rowsForHome(keysHome(opts.home));
2420
+ const named = findRow(rows, { provider: spec.provider, baseUrl: spec.baseUrl, model: spec.model })?.name ?? '';
2421
+ const card = reuseCardFromSpec(spec, { model: named, source: '', judgeExamples: '', jobName: row.job });
2422
+ const key = workflowKey(spec);
2423
+ const trackRecord = trackRecordFor(key, { ...opts, worker: workerOfChoice(opts.model ?? card.model, opts) });
2424
+ return {
2425
+ ok: true,
2426
+ origin: { runid: null, job: row.job, importId: id },
2427
+ card,
2428
+ from: 'imported job',
2429
+ locked: REUSE_LOCKED_FIELDS,
2430
+ open: REUSE_OPEN_FIELDS,
2431
+ note: 'a Source is needed — an exported job carries none; it is checked and signed on this machine',
2432
+ specHash: jobSpecHash(spec),
2433
+ workflowKey: key,
2434
+ spec,
2435
+ specPath: null,
2436
+ trackRecord,
2437
+ line: reuseLine(trackRecord),
2438
+ };
2439
+ }
2440
+
1698
2441
  /** @param {any} res @param {number} code @param {any} body */
1699
2442
  function sendJson(res, code, body) {
1700
2443
  const text = JSON.stringify(body);
@@ -1724,7 +2467,7 @@ function sendText(res, code, text) {
1724
2467
  * only `createPanelServer` below always supplies one.
1725
2468
  * @param {import('node:http').IncomingMessage} req
1726
2469
  * @param {import('node:http').ServerResponse} res
1727
- * @param {{ home?: string, port: number, token?: string, authorRoutes?: ReturnType<typeof createAuthorRoutes>, settingsRoutes?: ReturnType<typeof createSettingsRoutes> }} opts
2470
+ * @param {{ home?: string, port: number, token?: string, authorRoutes?: ReturnType<typeof createAuthorRoutes>, settingsRoutes?: ReturnType<typeof createSettingsRoutes>, runRoutes?: ReturnType<typeof createRunRoutes>, importRoutes?: ReturnType<typeof createImportRoutes> }} opts
1728
2471
  */
1729
2472
  export function handleRequest(req, res, opts) {
1730
2473
  /** the ONE writable route family for a path, or null — `/api/author/*` (authoring) or
@@ -1733,6 +2476,8 @@ export function handleRequest(req, res, opts) {
1733
2476
  function routesFor(o, p) {
1734
2477
  if (o.authorRoutes && p.startsWith('/api/author')) return o.authorRoutes;
1735
2478
  if (o.settingsRoutes && p.startsWith('/api/settings')) return o.settingsRoutes;
2479
+ if (o.runRoutes && /^\/api\/runs\/[^/]+\/(resume|stop)$/.test(p)) return o.runRoutes;
2480
+ if (o.importRoutes && (p === '/api/fs/list' || p === '/api/imports' || p.startsWith('/api/imports/'))) return o.importRoutes;
1736
2481
  return null;
1737
2482
  }
1738
2483
  const method = req.method ?? 'GET';
@@ -1746,6 +2491,13 @@ export function handleRequest(req, res, opts) {
1746
2491
  }
1747
2492
  const { pathname } = url;
1748
2493
 
2494
+ // every read names the panel's own address as Host — no token needed to read, but another origin's
2495
+ // page (a rebound DNS name) must not be able to fetch the run list
2496
+ if ((method === 'GET' || method === 'HEAD') && !checkHostGuard(req, { port: opts.port }).ok) {
2497
+ sendText(res, 403, 'wrong Host — this panel answers only at its own 127.0.0.1 address');
2498
+ return;
2499
+ }
2500
+
1749
2501
  if (method !== 'GET' && method !== 'HEAD') {
1750
2502
  const routes = routesFor(opts, pathname);
1751
2503
  if (routes) {
@@ -1883,7 +2635,7 @@ export function handleRequest(req, res, opts) {
1883
2635
  * this package's own `bin/bareloop.mjs`).
1884
2636
  * @param {{ port?: number, home?: string, env?: Record<string,string|undefined>,
1885
2637
  * sessionsRoot?: string, spawnFn?: (...a: any[]) => any, bareloopBin?: string,
1886
- * fetchImpl?: typeof fetch }} [opts]
2638
+ * fetchImpl?: typeof fetch, settleMs?: number, userHome?: string }} [opts]
1887
2639
  * @returns {Promise<{ server: import('node:http').Server, port: number, token: string, close: () => Promise<void> }>}
1888
2640
  */
1889
2641
  export function createPanelServer(opts = {}) {
@@ -1901,10 +2653,14 @@ export function createPanelServer(opts = {}) {
1901
2653
  let authorRoutes;
1902
2654
  /** @type {ReturnType<typeof createSettingsRoutes>|undefined} */
1903
2655
  let settingsRoutes;
2656
+ /** @type {ReturnType<typeof createRunRoutes>|undefined} */
2657
+ let runRoutes;
2658
+ /** @type {ReturnType<typeof createImportRoutes>|undefined} */
2659
+ let importRoutes;
1904
2660
  const server = createServer((req, res) => {
1905
2661
  try {
1906
2662
  handleRequest(req, res, {
1907
- home, port: boundPort, token, authorRoutes, settingsRoutes,
2663
+ home, port: boundPort, token, authorRoutes, settingsRoutes, runRoutes, importRoutes,
1908
2664
  });
1909
2665
  } catch (e) {
1910
2666
  sendText(res, 500, `internal error: ${/** @type {Error} */ (e).message}`);
@@ -1927,10 +2683,28 @@ export function createPanelServer(opts = {}) {
1927
2683
  bareloopBin: opts.bareloopBin,
1928
2684
  fetchImpl: opts.fetchImpl,
1929
2685
  home,
2686
+ startFrom: {
2687
+ get: (runid, o = {}) => getStartFrom(runid, { home, ...o }),
2688
+ getImport: (id, o = {}) => getStartFromImport(id, { home, ...o }),
2689
+ },
1930
2690
  });
1931
2691
  settingsRoutes = createSettingsRoutes({
1932
2692
  port: boundPort, token, home, env: opts.env, fetchImpl: opts.fetchImpl,
1933
2693
  });
2694
+ runRoutes = createRunRoutes({
2695
+ port: boundPort,
2696
+ token,
2697
+ home,
2698
+ env: opts.env,
2699
+ spawnFn: opts.spawnFn,
2700
+ bareloopBin: opts.bareloopBin,
2701
+ settleMs: opts.settleMs,
2702
+ getResumeContext: (runid) => getResumeContext(runid, { home }),
2703
+ getStopContext: (runid) => getStopContext(runid, { home }),
2704
+ });
2705
+ importRoutes = createImportRoutes({
2706
+ port: boundPort, token, home, userHome: opts.userHome, describeSpec,
2707
+ });
1934
2708
  resolve({
1935
2709
  server,
1936
2710
  port: boundPort,