bareloop 0.35.0 → 0.37.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.
@@ -31,6 +31,7 @@ import {
31
31
  } from 'node:path';
32
32
  import { fileURLToPath } from 'node:url';
33
33
  import { readRunList, DIED_MTIME_MS, runIsAlive } from '../runlist.js';
34
+ import { draftingPartFor } from '../draftspend.js';
34
35
  import {
35
36
  replayOne, parseJsonl, resolveSiblings, isSidecarByName,
36
37
  } from '../replayio.js';
@@ -52,7 +53,7 @@ import { rowsForHome, findRow, modelChoiceFor } from '../providerrows.js';
52
53
  import { resolveProvider } from '../providers.js';
53
54
  import { REUSE_LOCKED_FIELDS, REUSE_OPEN_FIELDS } from './authorsession.js';
54
55
  import { readResume, checkpointAgeGate, CHECKPOINT_OUTCOMES } from '../reuse.js';
55
- import { statusFor, GOAL_MET_LINE } from './status.js';
56
+ import { statusFor, partStatusFor, GOAL_MET_LINE } from './status.js';
56
57
  import { createSettingsRoutes } from './settingsroutes.js';
57
58
 
58
59
  const HERE = dirname(fileURLToPath(import.meta.url));
@@ -321,6 +322,9 @@ export function resumePlanFor(row, records) {
321
322
  if (!age.ok) return { ok: false, why: String(age.detail ?? 'its checkpoint has expired') };
322
323
  const near = sourceNearSpine(row.spine);
323
324
  if (!near.specPath) return { ok: false, why: 'the signed job file is not beside this run' };
325
+ // P6 item 1: a worktree run resumes IN its worktree — a folder that is gone is a resume the engine would refuse
326
+ const wt = worktreeOfRun(row, records);
327
+ if (wt && !wt.exists) return { ok: false, why: `its worktree folder ${wt.folder} is gone` };
324
328
  const dir = dirname(near.specPath);
325
329
  /** @type {string[]} */
326
330
  let names = [];
@@ -407,10 +411,29 @@ const GOAL_NOT_MET = new Set(['plan-red', 'check-red', 'step-red', 'escalated'])
407
411
  * (replaces P5 item 3, 2026-10-03) is offered on green rows only, and starts a NEW run.
408
412
  * @param {{outcome: string|null, stopReason: string|null, spentUsd: number|null, budgetUsd: number|null, lastEscalation?: any}} summary
409
413
  * @param {{died: boolean, lastThing: string|null}} death
410
- * @param {{resume?: {ok: boolean, why?: string}|null, destinationRefused?: string|null, moneyHalt?: boolean}} [o]
414
+ * @param {{resume?: {ok: boolean, why?: string}|null, destinationRefused?: string|null, moneyHalt?: boolean,
415
+ * worktree?: {folder: string, repo: string, branch: string|null, exists: boolean}|null}} [o]
416
+ * `worktree` (P6 item 1): the run's worktree in the person's repo, from {@link worktreeOfRun}; absent for every other run
411
417
  * @returns {{reason: string, next: string, line: string, actions: {id: string, label: string}[]}|null}
412
418
  */
413
419
  export function endedFor(summary, death, o = {}) {
420
+ const base = endedBase(summary, death, o);
421
+ const wt = o.worktree;
422
+ if (!base || !wt) return base;
423
+ // P6 item 1 — the run worked on a worktree in the person's own repo. GREEN: the branch carries the work (the folder is
424
+ // removed by the engine) and the merge stays the person's — the two commands are shown as TEXT, never run by a button.
425
+ // Any other ending: the worktree folder stays (Resume needs it), so the block names it.
426
+ const green = !death.died && (summary.outcome === 'green' || summary.outcome === 'already-green' || summary.outcome === 'satisfied');
427
+ if (green) {
428
+ if (!wt.branch) return base;
429
+ const kept = wt.exists ? ` The worktree folder ${wt.folder} is still there.` : '';
430
+ return { ...base, next: `In ${wt.repo}: git merge ${wt.branch} — or throw the work away: git branch -D ${wt.branch}.${kept}` };
431
+ }
432
+ return wt.exists ? { ...base, next: `${base.next} Your work is in ${wt.folder}.` } : base;
433
+ }
434
+
435
+ /** @param {any} summary @param {any} death @param {any} o */
436
+ function endedBase(summary, death, o) {
414
437
  const resumeOk = !!(o.resume && o.resume.ok);
415
438
  /** @type {{id: string, label: string}[]} */
416
439
  const RESUME = [{ id: 'resume', label: 'Resume' }];
@@ -572,7 +595,7 @@ function lastJobEndTs(/** @type {any[]} */ records) {
572
595
  return null;
573
596
  }
574
597
 
575
- function endedForRow(row, records, summary, death) {
598
+ function endedForRow(row, records, summary, death, withWorktree = false) {
576
599
  const out = summary.outcome;
577
600
  const maybeResumable = death.died || out === 'escalated' || (typeof out === 'string' && CHECKPOINT_OUTCOMES.includes(out));
578
601
  const resume = maybeResumable ? resumePlanFor(row, records) : null;
@@ -581,6 +604,7 @@ function endedForRow(row, records, summary, death) {
581
604
  resume,
582
605
  moneyHalt: records.some((r) => r && r.type === 'money-halt'),
583
606
  destinationRefused: refused ? String(refused.detail ?? refused.code ?? 'the destination refused it') : null,
607
+ worktree: withWorktree ? worktreeOfRun(row, records) : null,
584
608
  });
585
609
  const esc = summary.lastEscalation;
586
610
  const status = statusFor({
@@ -731,6 +755,40 @@ export function getStopContext(runid, opts = {}) {
731
755
  return { row, live: runIsAlive(row), spineExists: existsSync(row.spine) };
732
756
  }
733
757
 
758
+ /**
759
+ * @param {any} r
760
+ * @returns {number}
761
+ */
762
+ function atMs(r) {
763
+ const t = typeof r?.at === 'string' ? Date.parse(r.at) : NaN;
764
+ return Number.isFinite(t) ? t : 0;
765
+ }
766
+
767
+ /**
768
+ * The ONE owner of run names (P6 item 7): runs of the same job (`row.job`) are numbered oldest `at` first, 1-based
769
+ * (ties keep file order). A resume is one row, so it counts once; died and stopped runs are rows too. Every view
770
+ * reads this number — the page never counts on its own.
771
+ * @param {any[]} rows
772
+ * @returns {Map<string, number>} runid → N
773
+ */
774
+ function runNumbers(rows) {
775
+ /** @type {Map<string, any[]>} */
776
+ const byJob = new Map();
777
+ rows.forEach((row, index) => {
778
+ if (!row || typeof row.runid !== 'string') return;
779
+ const g = byJob.get(row.job) || [];
780
+ g.push({ row, index });
781
+ byJob.set(row.job, g);
782
+ });
783
+ /** @type {Map<string, number>} */
784
+ const out = new Map();
785
+ for (const g of byJob.values()) {
786
+ g.sort((a, b) => (atMs(a.row) - atMs(b.row)) || (a.index - b.index));
787
+ g.forEach(({ row }, i) => out.set(row.runid, i + 1));
788
+ }
789
+ return out;
790
+ }
791
+
734
792
  /**
735
793
  * `GET /api/runs` — every listed run, NEWEST FIRST BY `at` (never by file/
736
794
  * append order — a backfill scan appends rows in sorted-PATH order, which
@@ -749,14 +807,29 @@ export function getStopContext(runid, opts = {}) {
749
807
  */
750
808
  export function listRuns(opts = {}) {
751
809
  const { rows } = readRunList(opts);
752
- const atMs = (r) => {
753
- const t = typeof r?.at === 'string' ? Date.parse(r.at) : NaN;
754
- return Number.isFinite(t) ? t : 0;
755
- };
810
+ const nos = runNumbers(rows);
756
811
  return rows
757
812
  .map((row, index) => ({ row, index })) // index: stable tie-break, see doc above
758
813
  .sort((a, b) => (atMs(b.row) - atMs(a.row)) || (a.index - b.index))
759
- .map(({ row }) => summarizeRow(row));
814
+ .map(({ row }) => ({ ...summarizeRow(row), runNo: nos.get(row.runid) }));
815
+ }
816
+
817
+ /**
818
+ * The run's DRAFTING PART (hamr 2026-10-06, option A), or null: it exists only when the run came out of a panel
819
+ * session whose drafting log carries a metered call (src/draftspend.js, `draftingPartFor`) and the spine carries the
820
+ * drafting figure. It is the FIRST element of `detail.parts`, so EVERY index a reader is handed — `detail.parts`,
821
+ * `legDividers.beforePart`, the flat Audit rows' `partIndex`, the rounds endpoint's `part` — lives in ONE index space
822
+ * (the shown list), shifted by {@link draftingShift} in exactly those four places and nowhere else.
823
+ * @param {any} row @param {any} summary a `replayOne` summary of the row's spine @param {{ home?: string }} opts
824
+ * @returns {any|null}
825
+ */
826
+ function draftingFor(row, summary, opts) {
827
+ return draftingPartFor(row, { draftSpentUsd: summary.draftSpentUsd, draftSpendComplete: summary.draftSpendComplete }, opts);
828
+ }
829
+
830
+ /** @param {any} row @param {any} summary @param {{ home?: string }} opts @returns {0|1} how far the drafting part pushes every other part's index */
831
+ function draftingShift(row, summary, opts) {
832
+ return draftingFor(row, summary, opts) ? 1 : 0;
760
833
  }
761
834
 
762
835
  /**
@@ -789,6 +862,18 @@ function findRunRow(runid, opts) {
789
862
  * @returns {any|null}
790
863
  */
791
864
  export function getRunDetail(runid, opts = {}) {
865
+ const d = getRunDetailBody(runid, opts);
866
+ if (!d) return d;
867
+ const runNo = runNumbers(readRunList(opts).rows).get(runid);
868
+ return runNo === undefined ? d : { ...d, runNo };
869
+ }
870
+
871
+ /**
872
+ * @param {string} runid
873
+ * @param {{ home?: string }} opts
874
+ * @returns {any|null}
875
+ */
876
+ function getRunDetailBody(runid, opts) {
792
877
  const row = findRunRow(runid, opts);
793
878
  if (!row) return null;
794
879
  if (!existsSync(row.spine)) {
@@ -810,8 +895,12 @@ export function getRunDetail(runid, opts = {}) {
810
895
  const auditPaths = resolveAuditPathsForRow(row, rawSpineRecords);
811
896
  const summary = replayOne(row.spine, { auditPathOverride: auditPaths.length > 0 ? auditPaths : null });
812
897
  const timelineKind = summary.timelineKind;
898
+ // the drafting part (see draftingFor) heads the parts list; the enrichment below touches the spine's own parts only
899
+ const draftingPartHere = draftingFor(row, summary, opts);
900
+ const draftingShiftHere = draftingShift(row, summary, opts);
901
+ const enrichedParts = enrichPartsWithStageKind(summary.parts, stageKindMetaFromSpec(resolveSpecForRow(row)));
813
902
  const death = deriveDeath(row, rawSpineRecords, summary.outcome);
814
- const { ended, resume, status } = endedForRow(row, rawSpineRecords, summary, death);
903
+ const { ended, resume, status } = endedForRow(row, rawSpineRecords, summary, death, true);
815
904
  // judgeModel (Summary box "judge:" line): the FIRST `judge-round`'s own
816
905
  // `model` field (src/planrun.js:1704's `onJudgeCost` emit) — a soft-green
817
906
  // close's own paid judge seam, distinct from the worker `model` above.
@@ -980,7 +1069,8 @@ export function getRunDetail(runid, opts = {}) {
980
1069
  // P5-R: the run's legs in order (`after` = how the leg before ended) and the resume count — the Audit tab's
981
1070
  // dividers and the map's connectors read these; never a second derivation of where a leg starts
982
1071
  legs: summary.legs,
983
- legDividers: legDividersFor(rawSpineRecords, summary.parts),
1072
+ // drafting belongs to leg 1: a divider's `beforePart` moves with the parts list it indexes
1073
+ legDividers: legDividersFor(rawSpineRecords, summary.parts).map((d) => ({ ...d, beforePart: d.beforePart + draftingShiftHere })),
984
1074
  resumedCount: Math.max(0, summary.legs.length - 1),
985
1075
  resume: resume && resume.ok ? {
986
1076
  budgetUsd: resume.budgetUsd, maxWallMin: resume.maxWallMin, spentUsd: resume.spentUsd, spendComplete: resume.spendComplete,
@@ -1027,7 +1117,9 @@ export function getRunDetail(runid, opts = {}) {
1027
1117
  // {@link enrichPartsWithStageKind}. `kindMeta` is `null` (every stage
1028
1118
  // passes through unchanged) whenever no spec resolves at all, so the two
1029
1119
  // tabs still never disagree about order/counts/blocked-call figures.
1030
- parts: enrichPartsWithStageKind(summary.parts, stageKindMetaFromSpec(resolveSpecForRow(row))),
1120
+ // a run that DIED (no job-end, runner gone — `death`, decided once above) leaves its last part in flight: that part reads
1121
+ // `died`, never the "happened, so passed" default. Replay alone cannot tell died from still running, so the verdict lives here.
1122
+ parts: (draftingPartHere && Array.isArray(enrichedParts) ? [draftingPartHere, ...enrichedParts] : enrichedParts)?.map((p, i, all) => ({ ...p, status: partStatusFor(death.died && i === all.length - 1 && p.kind !== 'drafting' && p.outcome == null && !p.stopReason ? { ...p, stopReason: 'died' } : p) })),
1031
1123
  replans: summary.replans,
1032
1124
  close: summary.close,
1033
1125
  branch: summary.branch,
@@ -1379,6 +1471,7 @@ export function getRunAudit(runid, opts = {}) {
1379
1471
  skipAudit: true,
1380
1472
  });
1381
1473
  const partOf = makePartLookup(summary);
1474
+ const shift = draftingShift(row, summary, opts); // the drafting part heads the shown list: every index moves with it
1382
1475
 
1383
1476
  const { records } = { records: auditPaths.flatMap((ap) => parseJsonl(ap).records) };
1384
1477
  const windowed = records.filter((r) => r && typeof r === 'object' && typeof r.ts === 'string' && inAnyWindow(r.ts, windows));
@@ -1419,7 +1512,7 @@ export function getRunAudit(runid, opts = {}) {
1419
1512
  path,
1420
1513
  pathShort: shortenPath(path, treeRoot),
1421
1514
  decision: typeof r.decision === 'string' ? r.decision : null,
1422
- partIndex,
1515
+ partIndex: partIndex === null ? null : partIndex + shift,
1423
1516
  partLabel,
1424
1517
  attemptN,
1425
1518
  reason,
@@ -1488,7 +1581,11 @@ export function getRunRounds(runid, query = {}, opts = {}) {
1488
1581
  ? Math.min(query.limit, ROUNDS_PAGE_MAX) : ROUNDS_PAGE_DEFAULT;
1489
1582
 
1490
1583
  const summary = replayOne(row.spine);
1491
- const part = Array.isArray(summary.parts) ? summary.parts[partIndex] : null;
1584
+ // `part` is an index into the SHOWN list (`detail.parts`): when the drafting part heads it, index 0 is the drafting
1585
+ // part (it has no rounds table) and the spine's own part k is shown at k + 1
1586
+ const shift = draftingShift(row, summary, opts);
1587
+ if (shift && partIndex === 0) return null;
1588
+ const part = Array.isArray(summary.parts) ? summary.parts[partIndex - shift] : null;
1492
1589
  if (!part) return null;
1493
1590
  const a = Array.isArray(part.attempts) ? part.attempts[attempt - 1] : null;
1494
1591
  if (!a) return null;
@@ -1616,6 +1713,25 @@ function sourceNearSpine(spinePath) {
1616
1713
  };
1617
1714
  }
1618
1715
 
1716
+ /**
1717
+ * P6 item 1 — the worktree a run worked on in the person's own repo, read off the run's own `source.json` (the manifest
1718
+ * the source door wrote: `worktree` + `repo`) and its spine's `work-branch` record. `null` for every run that has none
1719
+ * (a copied-tree run, a bundle run, an import). `exists` is whether the folder is still on disk: a green run's folder is
1720
+ * removed by the engine, any other ending leaves it.
1721
+ * @param {{ spine: string }} row
1722
+ * @param {any[]} records the run's raw spine records
1723
+ * @returns {{folder: string, repo: string, branch: string|null, exists: boolean}|null}
1724
+ */
1725
+ function worktreeOfRun(row, records) {
1726
+ const near = sourceNearSpine(row.spine);
1727
+ if (!near.sourceJsonPath) return null;
1728
+ let m;
1729
+ try { m = JSON.parse(readFileSync(near.sourceJsonPath, 'utf8')); } catch { return null; }
1730
+ if (typeof m?.worktree !== 'string' || m.worktree === '' || typeof m?.repo !== 'string') return null;
1731
+ const wb = records.findLast((r) => r && r.type === 'work-branch' && typeof r.branch === 'string') ?? null;
1732
+ return { folder: m.worktree, repo: m.repo, branch: wb ? wb.branch : null, exists: existsSync(m.worktree) };
1733
+ }
1734
+
1619
1735
  /**
1620
1736
  * The resolved SPEC OBJECT for a run — routes (a) bundle `spec.json`, (b)
1621
1737
  * `jobs/<job>.json`, (c) the run's own `resolved-spec.json` beside the spine
@@ -2345,6 +2461,39 @@ export function getStartFrom(runid, opts = {}) {
2345
2461
  };
2346
2462
  }
2347
2463
 
2464
+ /**
2465
+ * The Reuse workflow picker's list: one entry per JOB (same-job identity = `workflowKey`, the one `getStartFrom` uses)
2466
+ * that has at least one GREEN local run, newest green first. `runid` = that job's newest green run, so the page hands
2467
+ * it to the existing start-from flow unchanged; `line` is `getStartFrom`'s own estimate line (never a second
2468
+ * spelling). Local runs only — an imported job keeps its Reuse button in the Import view. $0, read-only.
2469
+ * @param {{ home?: string, model?: string }} [opts] `model` passes through to the estimate line, as start-from's does
2470
+ * @returns {{job: string, runid: string, checkType: 'deterministic'|'rubric', line: string}[]}
2471
+ */
2472
+ export function listReuseJobs(opts = {}) {
2473
+ const { rows } = readRunList(opts);
2474
+ const seen = new Set();
2475
+ /** @type {{job: string, runid: string, checkType: 'deterministic'|'rubric', line: string}[]} */
2476
+ const jobs = [];
2477
+ for (let i = rows.length - 1; i >= 0; i -= 1) {
2478
+ const row = rows[i];
2479
+ if (!row || !existsSync(row.spine)) continue;
2480
+ let records;
2481
+ try { records = parseJsonl(row.spine).records; } catch { continue; }
2482
+ const outcome = legsOf(records).at(-1)?.outcome ?? null;
2483
+ // the same endings the Run tab's own Reuse button is offered on (`endedBase`)
2484
+ if (outcome !== 'green' && outcome !== 'already-green' && outcome !== 'satisfied') continue;
2485
+ const signed = signedSpecForRun(row, records);
2486
+ if (!signed) continue;
2487
+ const key = workflowKey(signed.spec);
2488
+ if (seen.has(key)) continue;
2489
+ const pre = getStartFrom(row.runid, opts);
2490
+ if (!pre || !pre.ok) continue;
2491
+ seen.add(key);
2492
+ jobs.push({ job: pre.card.jobName, runid: row.runid, checkType: pre.card.checkType === 'rubric' ? 'rubric' : 'deterministic', line: pre.line });
2493
+ }
2494
+ return jobs;
2495
+ }
2496
+
2348
2497
  /**
2349
2498
  * The reuse card for a signed spec: locked boxes from the spec through the Job tab's own readers, open boxes from
2350
2499
  * the spec's own fence and caps (Source and the saved text from the caller — a spec carries neither).
@@ -2522,11 +2671,19 @@ export function handleRequest(req, res, opts) {
2522
2671
  /** @type {any} */
2523
2672
  let body = null;
2524
2673
  if (raw.length > 0) { try { body = JSON.parse(raw); } catch { body = null; } }
2525
- routes.handle(req, res, pathname, body);
2674
+ // this callback runs OUTSIDE createServer's try/catch (which covers only the synchronous GET path), so a
2675
+ // synchronous throw in a route handler (a full disk, a permission fault) would be an uncaught exception that
2676
+ // takes the whole panel down: answer 500 JSON instead and keep serving
2677
+ try {
2678
+ routes.handle(req, res, pathname, body);
2679
+ } catch (e) {
2680
+ if (res.headersSent) { res.end(); return; }
2681
+ sendJson(res, 500, { ok: false, error: `internal error: ${/** @type {Error} */ (e)?.message ?? String(e)}` });
2682
+ }
2526
2683
  });
2527
2684
  return;
2528
2685
  }
2529
- sendText(res, 405, 'method not allowed — this panel is read-only outside /api/author and /api/settings (GET/HEAD only)');
2686
+ sendText(res, 405, 'method not allowed — this route does not accept this method (GET/HEAD only)');
2530
2687
  return;
2531
2688
  }
2532
2689
  const getRoutes = routesFor(opts, pathname);
@@ -2635,7 +2792,8 @@ export function handleRequest(req, res, opts) {
2635
2792
  * this package's own `bin/bareloop.mjs`).
2636
2793
  * @param {{ port?: number, home?: string, env?: Record<string,string|undefined>,
2637
2794
  * sessionsRoot?: string, spawnFn?: (...a: any[]) => any, bareloopBin?: string,
2638
- * fetchImpl?: typeof fetch, settleMs?: number, userHome?: string }} [opts]
2795
+ * fetchImpl?: typeof fetch, settleMs?: number, userHome?: string,
2796
+ * openFolderSpawn?: (cmd: string, args: string[], o: object) => any, platform?: string }} [opts]
2639
2797
  * @returns {Promise<{ server: import('node:http').Server, port: number, token: string, close: () => Promise<void> }>}
2640
2798
  */
2641
2799
  export function createPanelServer(opts = {}) {
@@ -2686,10 +2844,12 @@ export function createPanelServer(opts = {}) {
2686
2844
  startFrom: {
2687
2845
  get: (runid, o = {}) => getStartFrom(runid, { home, ...o }),
2688
2846
  getImport: (id, o = {}) => getStartFromImport(id, { home, ...o }),
2847
+ listJobs: (o = {}) => listReuseJobs({ home, ...o }),
2689
2848
  },
2690
2849
  });
2691
2850
  settingsRoutes = createSettingsRoutes({
2692
2851
  port: boundPort, token, home, env: opts.env, fetchImpl: opts.fetchImpl,
2852
+ openFolderSpawn: opts.openFolderSpawn, platform: opts.platform,
2693
2853
  });
2694
2854
  runRoutes = createRunRoutes({
2695
2855
  port: boundPort,
@@ -14,7 +14,8 @@
14
14
  import { checkHumanGuard } from './authorroutes.js';
15
15
  import { readConfig, updateConfig, ConfigError } from '../config.js';
16
16
  import { spendSummary, monthlyLimitOf } from '../monthly.js';
17
- import { loadKeysEnv, keysFilePath, filledKeyNames, ensureKeysFile } from '../keysfile.js';
17
+ import { spawn as realSpawn } from 'node:child_process';
18
+ import { loadKeysEnv, keysFilePath, keysHome, filledKeyNames, ensureKeysFile } from '../keysfile.js';
18
19
  import { SHAPES, PRESET_KEY_NAMES, keyRows, endpointOf, defaultUrlOf, usableKey } from '../providerrows.js';
19
20
  import { apiKeyProblem, checkProviderReachable } from '../providers.js';
20
21
 
@@ -22,8 +23,11 @@ import { apiKeyProblem, checkProviderReachable } from '../providers.js';
22
23
  const DEEPSEEK_BALANCE_URL = 'https://api.deepseek.com/user/balance';
23
24
 
24
25
  /**
26
+ * `openFolderSpawn` / `platform` are test seams for the Open keys folder button (a real caller passes
27
+ * neither: `child_process.spawn` and `process.platform`).
25
28
  * @param {{ port: number, token: string, home?: string, env?: Record<string,string|undefined>,
26
- * fetchImpl?: typeof fetch, now?: () => number }} opts
29
+ * fetchImpl?: typeof fetch, now?: () => number, openFolderSpawn?: (cmd: string, args: string[], o: object) => any,
30
+ * platform?: string }} opts
27
31
  */
28
32
  export function createSettingsRoutes(opts) {
29
33
  const fetchImpl = opts.fetchImpl ?? fetch;
@@ -134,6 +138,8 @@ export function createSettingsRoutes(opts) {
134
138
  placeholder: defaultUrlOf(r.provider),
135
139
  keyStatus: !raw ? 'not set' : (raw === 'null' ? 'no key needed' : (problem ? `bad shape (${problem})` : 'found')),
136
140
  canTest: r.provider !== 'gemini-api',
141
+ priceInPerM: typeof r.priceInPerM === 'number' ? r.priceInPerM : null,
142
+ priceOutPerM: typeof r.priceOutPerM === 'number' ? r.priceOutPerM : null,
137
143
  tokens: s.tokensByRow[r.envName] ?? 0,
138
144
  balance: r.provider === 'anthropic-api'
139
145
  ? { kind: 'note', usd: typeof note === 'number' && Number.isFinite(note) ? note : null }
@@ -148,11 +154,32 @@ export function createSettingsRoutes(opts) {
148
154
  const row = currentRows(readConfig({ home: opts.home }).config).find((r) => r.envName === body?.envName);
149
155
  if (!row) { send(400, { ok: false, error: 'unknown key — add NAME=key to your keys file and reload keys' }); return true; }
150
156
  // only the three settings a row has; each is optional in the body, the whole triple is stored
157
+ /** @type {Record<string, string|number|null>} */
151
158
  const patch = {
152
159
  name: typeof body?.name === 'string' ? body.name.trim() : row.name,
153
160
  shape: typeof body?.shape === 'string' ? body.shape : row.provider,
154
161
  baseUrl: typeof body?.baseUrl === 'string' ? body.baseUrl.trim().replace(/\/+$/, '') : row.baseUrl,
155
162
  };
163
+ // the two prices (USD per 1M tokens): absent = unchanged; blank / null = not set (the field is removed,
164
+ // never written as 0); otherwise a finite number >= 0 — anything else is refused and nothing is saved
165
+ for (const f of /** @type {const} */ (['priceInPerM', 'priceOutPerM'])) {
166
+ if (!Object.hasOwn(body ?? {}, f)) continue;
167
+ const v = body[f];
168
+ const label = f === 'priceInPerM' ? 'In' : 'Out';
169
+ if (v === null || (typeof v === 'string' && v.trim() === '')) { patch[f] = null; continue; }
170
+ const n = typeof v === 'number' ? v : (typeof v === 'string' && /^\d+(\.\d+)?$/.test(v.trim()) ? Number(v.trim()) : NaN);
171
+ if (!(Number.isFinite(n) && n >= 0)) {
172
+ send(400, { ok: false, error: `the ${label} price must be a number, 0 or more (USD per 1M tokens), or blank for not set` });
173
+ return true;
174
+ }
175
+ patch[f] = n;
176
+ }
177
+ // a price is both fields or neither — ratesFor refuses a half-set one at run time, so never save half
178
+ const after = (/** @type {'priceInPerM'|'priceOutPerM'} */ f) => (Object.hasOwn(patch, f) ? patch[f] !== null : row[f] !== undefined);
179
+ if (after('priceInPerM') !== after('priceOutPerM')) {
180
+ send(400, { ok: false, error: 'set both prices (In and Out, USD per 1M tokens) or neither — a half-set price is refused' });
181
+ return true;
182
+ }
156
183
  try {
157
184
  updateConfig({ keys: { [row.envName]: patch } }, { home: opts.home });
158
185
  } catch (e) {
@@ -164,6 +191,29 @@ export function createSettingsRoutes(opts) {
164
191
  return true;
165
192
  }
166
193
 
194
+ if (pathname === '/api/settings/open-keys-folder' && req.method === 'POST') {
195
+ // opens the FOLDER (never the file, never an editor) in the file manager; argv array, no shell, detached.
196
+ // Another OS, or a failed spawn: say so and hand back the path, which the page then shows as text.
197
+ const dir = keysHome(opts.home);
198
+ const platform = opts.platform ?? process.platform;
199
+ const cmd = platform === 'linux' ? 'xdg-open' : (platform === 'darwin' ? 'open' : null);
200
+ const cannot = () => send(200, { ok: true, opened: false, path: dir });
201
+ if (!cmd) { cannot(); return true; }
202
+ let child;
203
+ try {
204
+ child = (opts.openFolderSpawn ?? realSpawn)(cmd, [dir], { detached: true, stdio: 'ignore' });
205
+ } catch { cannot(); return true; }
206
+ let settled = false;
207
+ child.once('error', () => { if (!settled) { settled = true; cannot(); } });
208
+ child.once('spawn', () => {
209
+ if (settled) return;
210
+ settled = true;
211
+ if (typeof child.unref === 'function') child.unref();
212
+ send(200, { ok: true, opened: true, path: dir });
213
+ });
214
+ return true;
215
+ }
216
+
167
217
  if (pathname === '/api/settings/providers/balance-note' && req.method === 'POST') {
168
218
  // the Anthropic balance is a NOTE the person types; no check ever reads it
169
219
  const raw = body?.usd;
@@ -49,3 +49,25 @@ export function statusFor(o) {
49
49
  else if (out === 'escalated' && ((o.category === 'cap-halt' && o.moneyHalt) || o.category === 'wall-halt')) key = 'capped';
50
50
  return { key, ...STATUS[key] };
51
51
  }
52
+
53
+ /**
54
+ * The word one PART of a run wears on the step map, the step cards and the Audit rows (hamr 2026-10-07: `[✓] passed` /
55
+ * `[✗] failed`, never "done"). Reads the same table; `running` is the page's call (it knows which part is live), so a
56
+ * part with no verdict and no cut-off is `passed` here.
57
+ * drafting, green/satisfied/already-green outcome, or a part that simply happened (scout/plan/replan/judge) -> passed
58
+ * a red outcome -> its run-outcome word (failed; a cap/stop outcome keeps capped/stopped)
59
+ * a part its leg's halt cut off (`stopReason`): 'money cap' / 'time cap' -> capped, 'you stopped it' -> stopped,
60
+ * 'died' -> died, anything else (provider failed, step stalled, a raw outcome) -> failed
61
+ * @param {{kind?: string, outcome?: string|null, stopReason?: string|null}} part
62
+ * @returns {{key: StatusKey, sign: string, word: string}}
63
+ */
64
+ export function partStatusFor(part) {
65
+ if (part.kind === 'drafting') return statusFor({ outcome: 'green' });
66
+ if (typeof part.outcome === 'string') return statusFor({ outcome: part.outcome });
67
+ const why = part.stopReason;
68
+ if (why) {
69
+ const key = why === 'money cap' || why === 'time cap' ? 'capped' : why === 'you stopped it' ? 'stopped' : why === 'died' ? 'died' : 'failed';
70
+ return { key, ...STATUS[key] };
71
+ }
72
+ return statusFor({ outcome: 'green' });
73
+ }
package/src/planrun.js CHANGED
@@ -970,10 +970,10 @@ ${scoutBlob || '(no scout notes)'}`;
970
970
  * the resume-plan-red option, the fix-loop terminal) say "resume is `run-u`-only
971
971
  * in v1" instead of naming a flag that would fail if typed.
972
972
  * @param {string|null} [opts.stopFile=null] P5 item 5 — the run's STOP REQUEST file
973
- * (`stopFilePath(spine)`, src/legs.js). Read at ONE seam, the round boundary of a step worker
974
- * (the `metered` callback, where the money cap binds): present = emit `stop-requested`, consume
973
+ * (`stopFilePath(spine)`, src/legs.js). Read at ONE seam, the round boundary of EVERY worker
974
+ * (scout, plan/replan drafter, step, close-fix: the `metered` callback, where the money cap binds): present = emit `stop-requested`, consume
975
975
  * the file, end the Loop after that round, and end the leg `stopped` (a checkpoint outcome, filed
976
- * the way a mid-step cap-halt is; resume re-enters that step). Nothing is in flight at that
976
+ * the way a mid-step cap-halt is; resume re-enters that step, or continues the phase). Nothing is in flight at that
977
977
  * point, so the spend is exact. `null` = no stop surface (the default).
978
978
  * @returns {Promise<string>} 'green' | 'already-green' | 'escalated' | 'plan-red' |
979
979
  * 'check-red' | 'close-red' | 'close-unsupported' | 'recipe-stale' | 'pricing-red' |
@@ -2745,11 +2745,12 @@ export async function runPlan(job, { workdir, provider, nativeProvider, provider
2745
2745
  // (nothing in flight, the round just metered). It is the ONE owner of the stop request. The file is
2746
2746
  // consumed here; `self.stop()` ends the Loop after this round exactly as a bound does; `ask`/`askFrom`
2747
2747
  // then throw category `stopped`, which the step loop files as the checkpoint outcome `stopped` the way a
2748
- // mid-step cap-halt is filed. Step workers only (scout/plan/fix never read it).
2749
- if (stopFile !== null && !stopHit && phase.startsWith('step:') && existsSync(stopFile)) {
2748
+ // mid-step cap-halt is filed. EVERY phase that runs rounds reads it here (scout, plan/replan drafter, step,
2749
+ // close-fix) — the one seam the money cap binds at; `relay` files the non-ralph phases' throw as `stopped`.
2750
+ if (stopFile !== null && !stopHit && existsSync(stopFile)) {
2750
2751
  try { unlinkSync(stopFile); } catch { /* already consumed */ }
2751
2752
  stopHit = true;
2752
- emit('stop-requested', { phase, step: phase.slice('step:'.length), iteration: roundIteration, round: roundsThisAttempt, meaning: 'the person asked to stop; the leg ends after this round and a resume re-enters this step' });
2753
+ emit('stop-requested', { phase, ...(phase.startsWith('step:') ? { step: phase.slice('step:'.length) } : {}), iteration: roundIteration, round: roundsThisAttempt, meaning: 'the person asked to stop; the leg ends after this round and a resume continues the run' });
2753
2754
  self.stop();
2754
2755
  } else if (clock.expired()) {
2755
2756
  attemptBounded = { iteration: roundIteration, cause: 'wall', reason: null };
@@ -2903,6 +2904,8 @@ export async function runPlan(job, { workdir, provider, nativeProvider, provider
2903
2904
  // instead and every such fault came back `provider-red` — a broken runner
2904
2905
  // reported as the provider's fault, and an upstream ask aimed at a library
2905
2906
  // for our own missing wiring (the step-stalled lesson).
2907
+ // P5 item 5 — the PERSON's stop, honoured at a round boundary outside a step (scout, plan drafting, replan): nothing failed
2908
+ 'stopped': [`You stopped the run during ${phase}. Nothing is discarded: the work on disk stands, and a resume continues the run.`, ['resume (the same run continues)', 'abandon the task']],
2906
2909
  'interpreter-red': [`The ${phase} phase could not be set up — the runner or a primitive it was handed is not correctly bound.`, ['fix the wiring the detail names', 'retry the run', 'abandon the run']],
2907
2910
  };
2908
2911
  const [decision, options] = (Object.hasOwn(DECIDE, category) ? DECIDE[category] : undefined)
@@ -2914,7 +2917,7 @@ export async function runPlan(job, { workdir, provider, nativeProvider, provider
2914
2917
  // `step-stalled` rides out as ITSELF, not as provider-red. Naming the
2915
2918
  // escalation while returning a casualty label would launder a governance stop
2916
2919
  // into transport noise at exactly the layer the readout reads.
2917
- return category === 'cap-halt' || category === 'wall-halt' || category === 'step-stalled' || category === 'interpreter-red'
2920
+ return category === 'cap-halt' || category === 'wall-halt' || category === 'step-stalled' || category === 'interpreter-red' || category === 'stopped'
2918
2921
  ? category : 'provider-red';
2919
2922
  };
2920
2923
 
package/src/ralph.js CHANGED
@@ -49,7 +49,7 @@ const CLOSE_KILL_GRACE_MS = 2000;
49
49
  * @param {{env: NodeJS.ProcessEnv, cwd?: string, timeoutMs?: number}} o
50
50
  * @returns {Promise<{error: (Error & {code?: string})|null, status: number|null, signal: string|null, stdout: string, stderr: string}>}
51
51
  */
52
- function spawnClose(cmd, args, { env, cwd, timeoutMs }) {
52
+ export function spawnClose(cmd, args, { env, cwd, timeoutMs }) {
53
53
  return new Promise((resolve) => {
54
54
  const child = spawn(cmd, args, { env, cwd });
55
55
  /** @type {Buffer[]} */ const outChunks = [];
package/src/source.js CHANGED
@@ -27,6 +27,7 @@ import { existsSync, lstatSync, readFileSync } from 'node:fs';
27
27
  import { mkdir, readdir, readFile, writeFile, lstat, stat, chmod, access, copyFile, cp, open, rm, realpath, readlink, symlink, constants as fsConstants } from 'node:fs/promises';
28
28
  import { dirname, join, relative, resolve, sep, basename } from 'node:path';
29
29
  import { git } from './kinds.js';
30
+ import { addWorktree, hideBareloopDir } from './worktree.js';
30
31
  import { MAX_BUFFER } from './kinds.js';
31
32
  import { PROVIDER_TIMEOUT_MS } from './clock.js';
32
33
  import { scanSecrets, SECRET_PATTERNS, SECRET_PATTERN_NAMES } from './validate.js';
@@ -440,16 +441,21 @@ async function fetchOnce(url, timeoutMs) {
440
441
  * as declared, unvalidated by `proveDestination`, and `frontDoorFromManifest`
441
442
  * never hands a repo source's destination to `copyOut` — wiring it into
442
443
  * `writeScope` is M3/M4's job, not this one.
443
- * @param {{source: string, into: string, destination?: string,
444
+ * `worktree` (P6 item 1, repo sources ONLY — ignored for every other kind): instead of COPYING the repo, make the
445
+ * run's tree a detached git worktree of the person's own repo at that directory (the shared
446
+ * `src/worktree.js`, the bundle door's own spelling), hide `.bareloop/` from their `git status`, and record the
447
+ * worktree and the repo root in the manifest. `into` then holds only `source.json`; the tree is `worktree`, at the
448
+ * repo's current commit (uncommitted edits are NOT in it). No seed commit is made — the seed IS the repo's HEAD.
449
+ * @param {{source: string, into: string, destination?: string, worktree?: string,
444
450
  * fetchTimeoutMs?: number, afterScan?: () => (void|Promise<void>)}} args
445
451
  * `fetchTimeoutMs` is test-only — production callers omit it and get
446
452
  * `PROVIDER_TIMEOUT_MS`. `afterScan` is a test seam too: called exactly once,
447
453
  * after the secret scan finished and before the freeze loop re-reads any
448
454
  * file's bytes, so a test can swap a file inside that window
449
455
  * deterministically. Production never sets it; absent, behaviour is unchanged.
450
- * @returns {Promise<{stop: null, into: string, tree: string, manifestPath: string, manifest: object}|SourceRefusal>}
456
+ * @returns {Promise<{stop: null, into: string, tree: string, manifestPath: string, manifest: Record<string, any>}|SourceRefusal>}
451
457
  */
452
- export async function prepareSource({ source, into, destination, fetchTimeoutMs = PROVIDER_TIMEOUT_MS, afterScan }) {
458
+ export async function prepareSource({ source, into, destination, worktree, fetchTimeoutMs = PROVIDER_TIMEOUT_MS, afterScan }) {
453
459
  const intoAbs = resolve(into);
454
460
  if (existsSync(intoAbs)) {
455
461
  return refuse('into-exists', `${intoAbs} already exists — a source door writes a FRESH tree, never reuses one (the export worktree rule)`);
@@ -634,6 +640,9 @@ export async function prepareSource({ source, into, destination, fetchTimeoutMs
634
640
  const buf = f.buf ?? (f.symlinkTarget !== undefined ? Buffer.from(f.symlinkTarget, 'utf8') : await readFile(/** @type {string} */ (f.abs)));
635
641
  fileMeta.push({ path: f.rel, bytes: buf.length, sha256: sha256Hex(buf) });
636
642
  const isBinary = f.symlinkTarget === undefined && hasNulByte(buf.subarray(0, 8192));
643
+ // P6 item 2 (hamr 2026-10-05 Q2=A): a repo going onto a WORKTREE is the person's OWN repo, left in place — a secret
644
+ // already in it is MASKED wherever bareloop records it (`redactSecrets`), not a refusal. Every other kind keeps the scan.
645
+ if (worktree !== undefined && frozen.kind === 'repo') continue;
637
646
  const names = secretPatternNames(buf.toString(isBinary ? 'latin1' : 'utf8'));
638
647
  if (names.length) secretHits.push({ rel: f.rel, names });
639
648
  }
@@ -642,6 +651,34 @@ export async function prepareSource({ source, into, destination, fetchTimeoutMs
642
651
  return refuse('source-carries-secret', `${secretHits.length} file(s) carry a known secret shape — refused before anything was written (hard line #3, secrets never enter the tree): ${detail}`);
643
652
  }
644
653
 
654
+ if (worktree !== undefined && frozen.kind === 'repo') {
655
+ const repoRoot = /** @type {string} */ (frozen.root);
656
+ const wt = resolve(worktree);
657
+ try {
658
+ hideBareloopDir(repoRoot);
659
+ addWorktree(repoRoot, wt);
660
+ } catch (e) {
661
+ return refuse('source-worktree-failed', `could not make the job's worktree in ${repoRoot}: ${/** @type {Error} */ (e).message} (the repo needs at least one commit)`);
662
+ }
663
+ const headAt = await git(wt, ['rev-parse', 'HEAD']);
664
+ if (!headAt.ok) return refuse('source-git-failed', `git rev-parse HEAD failed in ${wt}: ${headAt.err}`);
665
+ const wtManifest = {
666
+ kind: 'repo',
667
+ source,
668
+ ...(frozen.sourceSubdir === undefined ? {} : { sourceSubdir: frozen.sourceSubdir }),
669
+ fetchedAt: new Date().toISOString(),
670
+ files: fileMeta,
671
+ seed: headAt.out.trim(),
672
+ destination: destination ?? null,
673
+ worktree: wt,
674
+ repo: repoRoot,
675
+ };
676
+ await mkdir(intoAbs, { recursive: true });
677
+ const wtManifestPath = join(intoAbs, 'source.json');
678
+ await writeFile(wtManifestPath, `${JSON.stringify(wtManifest, null, 2)}\n`);
679
+ return { stop: null, into: intoAbs, tree: wt, manifestPath: wtManifestPath, manifest: wtManifest };
680
+ }
681
+
645
682
  const treeDir = join(intoAbs, 'tree');
646
683
  const outputDir = join(treeDir, 'output');
647
684
  await mkdir(treeDir, { recursive: true });
@@ -809,7 +846,7 @@ const LOCKFILE_COMMANDS = Object.freeze([
809
846
  * manifest)
810
847
  * @param {string} [sourceSubdir] the manifest's own `sourceSubdir` ('' or
811
848
  * undefined when Source IS the repo root)
812
- * @returns {{manager: string, command: string, reason: string}|null} `null`
849
+ * @returns {{dir: string, lockFile: string|null, manager: string, command: string, reason: string}|null} `null`
813
850
  * when there is nothing to report: no JS/TS manifest found walking up from
814
851
  * `sourceSubdir` to `treeDir`, the nearest one found has no dependencies,
815
852
  * `node_modules` is already there, or the manifest cannot even be read as
@@ -846,6 +883,8 @@ export function missingDependencies(treeDir, sourceSubdir = '') {
846
883
  const installCmd = lock ? lock.command : 'npm install';
847
884
  const rel = relative(root, d).split(sep).join('/');
848
885
  return {
886
+ dir: d,
887
+ lockFile: lock ? lock.file : null,
849
888
  manager: installCmd.split(' ')[0],
850
889
  command: rel === '' ? installCmd : `cd ${rel} && ${installCmd}`,
851
890
  reason: `${rel === '' ? 'package.json' : `${rel}/package.json`} lists dependencies but the copy has no node_modules`,
@@ -1109,3 +1148,15 @@ export function frontDoorFromManifest(read) {
1109
1148
  if (typeof destination !== 'string' || !destination) return null;
1110
1149
  return { destination };
1111
1150
  }
1151
+
1152
+ /**
1153
+ * The $0 refusal for a non-repo source, worded by the kind the source door recorded (`prep.manifest.kind`) — a single
1154
+ * file is not a "folder", and Source must be the repo folder with the file named in Destination (hamr 2026-10-05).
1155
+ * @param {string} kind `prep.manifest.kind`: 'file' | 'folder' | 'url' (a 'repo' never reaches this)
1156
+ * @returns {string}
1157
+ */
1158
+ export function nonRepoSourceMessage(kind) {
1159
+ if (kind === 'file') return 'Source is a single file. Source must be the repo folder — put the file in Destination, like src/digest.js. Nothing spent.';
1160
+ if (kind === 'folder') return "Source is a plain folder, not a code project (no git repo found). bareloop can't check this kind of job yet. Nothing spent.";
1161
+ return "Source is not a code project (no git repo found). bareloop can't check this kind of job yet. Nothing spent.";
1162
+ }