claude-code-session-manager 0.82.0 → 0.84.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 (92) hide show
  1. package/dist/assets/{AgentLibrary-pwlkAFb3.js → AgentLibrary-CCVIpoSz.js} +1 -1
  2. package/dist/assets/{DataModel-BZK9PXFD.js → DataModel-BltREYde.js} +1 -1
  3. package/dist/assets/{History-BVRjxJjS.js → History-BZxFkOp6.js} +2 -2
  4. package/dist/assets/{Hooks-CNuwHeGx.js → Hooks-Bznlfaaa.js} +1 -1
  5. package/dist/assets/{HostBilko-cjwNodhV.js → HostBilko-DUG5YHA_.js} +1 -1
  6. package/dist/assets/{Library-YPNm9W92.js → Library-Bi2Fn3w9.js} +1 -1
  7. package/dist/assets/{ListDetail-CY4GM1Om.js → ListDetail-4VBvXKrz.js} +1 -1
  8. package/dist/assets/{MarkdownEditor-BF4y2Jiz.js → MarkdownEditor-D2ft_v5j.js} +1 -1
  9. package/dist/assets/{McpServers-CmBdWtX_.js → McpServers-FDekKkyE.js} +1 -1
  10. package/dist/assets/{Memory-CvkIXNl1.js → Memory-Dkl80uj_.js} +6 -6
  11. package/dist/assets/{Panel-D93o-sxe.js → Panel-LurG5VfD.js} +1 -1
  12. package/dist/assets/{Permissions-DQipg16I.js → Permissions-D2wHBCFA.js} +1 -1
  13. package/dist/assets/{Plugins-B6NwzPfK.js → Plugins-CTw_wwbI.js} +2 -2
  14. package/dist/assets/{ProvenanceBadge-DACVJhrB.js → ProvenanceBadge-CW6HNUv6.js} +1 -1
  15. package/dist/assets/{SaveBar-Cg4lbChb.js → SaveBar-alDHG6cP.js} +1 -1
  16. package/dist/assets/{Scheduler-Dr5ZcBLe.js → Scheduler-DxiPcaiW.js} +7 -7
  17. package/dist/assets/{ScopeSwitcher-C-locvy0.js → ScopeSwitcher-DzFXUKLZ.js} +1 -1
  18. package/dist/assets/Settings-B6H3v2am.js +3 -0
  19. package/dist/assets/{SkillReferenceGraph-DDzuYgSK.js → SkillReferenceGraph-B8EZalpN.js} +1 -1
  20. package/dist/assets/{Skills-DFvhiOAQ.js → Skills-DwuHuA08.js} +2 -2
  21. package/dist/assets/SystemPrompt-CMMqpGYn.js +1 -0
  22. package/dist/assets/{TagLibrary-DruUYaAc.js → TagLibrary-C2N5C1n9.js} +1 -1
  23. package/dist/assets/{TiptapBody-Dr4a--42.js → TiptapBody-btlID-dQ.js} +1 -1
  24. package/dist/assets/{Toggle-B_EH2TFb.js → Toggle-BGOn3DZj.js} +1 -1
  25. package/dist/assets/{index-DApB4DHS.js → index-QLRf0epp.js} +316 -314
  26. package/dist/assets/{index-CYhdtisq.css → index-mnjNDpb1.css} +1 -1
  27. package/dist/assets/{settingsSchema-BKa-xk8g.js → settingsSchema-DA3N2Up3.js} +1 -1
  28. package/dist/index.html +2 -2
  29. package/package.json +4 -1
  30. package/scripts/hooks/guard-destructive-git.cjs +514 -0
  31. package/scripts/hooks/guard-inline-implementation.cjs +219 -0
  32. package/scripts/hooks/guard-prd-writes.cjs +200 -0
  33. package/src/main/__tests__/epicMintTelemetryTap.test.cjs +64 -0
  34. package/src/main/__tests__/health-delegation-chain.test.cjs +2 -1
  35. package/src/main/__tests__/health-queue-dispatch.test.cjs +84 -0
  36. package/src/main/__tests__/health-usage-poller.test.cjs +97 -0
  37. package/src/main/__tests__/health-worktree-cap-blocked.test.cjs +65 -0
  38. package/src/main/__tests__/opsErrorLogTelemetryTap.test.cjs +143 -0
  39. package/src/main/__tests__/pollLoop-dispatch-on-failure.test.cjs +120 -0
  40. package/src/main/__tests__/promptSessionTranscript.test.cjs +0 -0
  41. package/src/main/__tests__/queue-starvation-dispatch-driver.test.cjs +143 -0
  42. package/src/main/__tests__/rateLimitPollerStreak.test.cjs +79 -0
  43. package/src/main/__tests__/scheduleJobTransitionsTelemetryTap.test.cjs +72 -0
  44. package/src/main/__tests__/scheduler-inplace-salvage.test.cjs +74 -0
  45. package/src/main/__tests__/scheduler-job-overrun.test.cjs +58 -0
  46. package/src/main/__tests__/scheduler-notify-originating-tab-transcript.test.cjs +1 -0
  47. package/src/main/__tests__/scheduler-periodic-reverify-guard.test.cjs +134 -2
  48. package/src/main/__tests__/scheduler-reap-dead-running-jobs.test.cjs +33 -0
  49. package/src/main/__tests__/scheduler-stuck-failed-escalation.test.cjs +136 -0
  50. package/src/main/__tests__/telemetryClient.test.cjs +810 -0
  51. package/src/main/__tests__/telemetryContract.test.cjs +883 -0
  52. package/src/main/crashDiagnostics.cjs +29 -1
  53. package/src/main/health.cjs +197 -3
  54. package/src/main/index.cjs +65 -6
  55. package/src/main/ipcSchemas.cjs +19 -2
  56. package/src/main/lib/__tests__/crashTelemetry.test.cjs +97 -0
  57. package/src/main/lib/__tests__/delegationReadiness.test.cjs +302 -4
  58. package/src/main/lib/__tests__/fixtures/scheduler-machine.json.corrupt-1789147548 +34 -0
  59. package/src/main/lib/__tests__/gitWorktree.test.cjs +413 -1
  60. package/src/main/lib/__tests__/jobWorktreeBootLive.test.cjs +71 -0
  61. package/src/main/lib/__tests__/queueStoreAtomicWrite.test.cjs +88 -0
  62. package/src/main/lib/__tests__/queueStoreMachineStateRecovery.test.cjs +123 -0
  63. package/src/main/lib/__tests__/reaperHelpers.test.cjs +58 -0
  64. package/src/main/lib/__tests__/telemetryBacklog.test.cjs +620 -0
  65. package/src/main/lib/__tests__/telemetryBoot.test.cjs +125 -0
  66. package/src/main/lib/__tests__/telemetryConsent.test.cjs +130 -0
  67. package/src/main/lib/__tests__/telemetryCounters.test.cjs +57 -0
  68. package/src/main/lib/__tests__/telemetryCountersMetadataColumn.test.cjs +89 -0
  69. package/src/main/lib/crashTelemetry.cjs +37 -0
  70. package/src/main/lib/delegationReadiness.cjs +119 -1
  71. package/src/main/lib/epicMint.cjs +2 -0
  72. package/src/main/lib/gitWorktree.cjs +427 -17
  73. package/src/main/lib/jobWorktree.cjs +2 -1
  74. package/src/main/lib/jobWorktreeBootLive.cjs +51 -0
  75. package/src/main/lib/jobWorktreeTerminalOrphanLive.cjs +68 -0
  76. package/src/main/lib/opsErrorLog.cjs +78 -25
  77. package/src/main/lib/queueStore.cjs +233 -20
  78. package/src/main/lib/reaperHelpers.cjs +23 -1
  79. package/src/main/lib/scheduleJobSchema.cjs +7 -0
  80. package/src/main/lib/scheduleJobTransitions.cjs +12 -0
  81. package/src/main/lib/telemetryBacklog.cjs +601 -0
  82. package/src/main/lib/telemetryBoot.cjs +71 -0
  83. package/src/main/lib/telemetryClient.cjs +653 -0
  84. package/src/main/lib/telemetryConsent.cjs +34 -0
  85. package/src/main/lib/telemetryCounters.cjs +45 -0
  86. package/src/main/promptSessionTranscript.cjs +0 -0
  87. package/src/main/pty.cjs +2 -0
  88. package/src/main/scheduler.cjs +481 -33
  89. package/src/preload/api.d.ts +84 -4
  90. package/src/preload/index.cjs +9 -0
  91. package/dist/assets/Settings-BVrAle90.js +0 -3
  92. package/dist/assets/SystemPrompt-8PiTyUyL.js +0 -1
@@ -172,8 +172,31 @@ function isUnderRoot(p, root) {
172
172
  // versa). Reset to 0 per kind on every restart by design — a crash can never
173
173
  // leave either counter permanently wedged above its cap; reconcileWorktreesOnBoot
174
174
  // cleans up any leaked ON-DISK checkouts separately (see below).
175
+ //
176
+ // This counter can still drift UPWARD of reality (a missed decrement —
177
+ // cleanupWorktree never ran because the owning process crashed, was
178
+ // reaped, or SIGTERM'd before teardown) for as long as the app keeps
179
+ // running. `reserveWorktreeSlot` below is what makes that drift
180
+ // self-healing: instead of trusting this counter forever once it reaches
181
+ // the cap, a rejection is verified against OBSERVED on-disk reality
182
+ // (`getObservedWorktreeCount`) before being honored, and the counter is
183
+ // corrected down to match reality when it's proven to have leaked.
175
184
  const activeWorktreeCount = { job: 0, epic: 0 };
176
185
 
186
+ // Per-kind promise chain used as a simple async mutex (see `withKindLock`)
187
+ // so the cap-check-and-reserve decision in `reserveWorktreeSlot` — which now
188
+ // sometimes needs to `await` a fresh on-disk observation — stays a single
189
+ // atomic step across concurrent callers, the same guarantee the old
190
+ // purely-synchronous check+increment gave for free.
191
+ const worktreeCapLockChain = { job: Promise.resolve(), epic: Promise.resolve() };
192
+
193
+ // Short-TTL memo of `getObservedWorktreeCount`'s result, per kind, so a burst
194
+ // of callers hitting the cap in quick succession (e.g. several scheduler
195
+ // ticks in a row while the machine is genuinely full) doesn't turn into a
196
+ // `git worktree list` storm. Cleared per-kind by the test hook below.
197
+ const observedWorktreeCountCache = { job: { count: null, at: 0 }, epic: { count: null, at: 0 } };
198
+ const OBSERVED_WORKTREE_COUNT_TTL_MS = 2000;
199
+
177
200
  function execGit(args, { cwd, timeout = 20_000 } = {}) {
178
201
  return new Promise((resolve, reject) => {
179
202
  execFile('git', args, { cwd, timeout, windowsHide: true, encoding: 'utf8' }, (err, stdout, stderr) => {
@@ -350,15 +373,27 @@ async function teardownOrphanedCheckout(dir) {
350
373
  *
351
374
  * A checkout younger than `staleAgeMs` (mtime-based) is NEVER touched — this
352
375
  * is what keeps an in-progress job or Epic safe from a concurrent boot sweep.
353
- * This intentionally does NOT accept an `isLive` predicate the way
376
+ * This intentionally does NOT accept a queue-aware `isLive` predicate the way
354
377
  * `reconcileWorktreesOnBoot`'s per-cwd pass does: that predicate is scoped to
355
378
  * ONE project's own active-index.json per call, but this sweep walks EVERY
356
379
  * project's checkouts under the shared kind root in one pass — applying one
357
380
  * project's liveness answer to another project's checkout would be worse
358
381
  * than no answer at all (a false "not live" for a foreign key would tear
359
382
  * down a genuinely active Epic before its own project's turn ever runs this
360
- * sweep). The age threshold (7 days for epics, by default) is the only
361
- * safety margin here, deliberately.
383
+ * sweep).
384
+ *
385
+ * It DOES, however, check `hasLiveHolder` on every candidate that clears the
386
+ * age threshold: unlike a queue-derived `isLive`, a live `/proc` cwd holder
387
+ * is project-agnostic and machine-global — a directory's mtime does NOT
388
+ * advance when a long-running process only writes into subdirectories of it
389
+ * (exactly the "starry-night-ships"-class workload), so an old mtime is not
390
+ * proof of abandonment. A process still holding the checkout as its cwd is
391
+ * authoritative over any timer and is never reaped, regardless of age.
392
+ * `holders` may be supplied precomputed (one `/proc` scan reused across many
393
+ * candidates/kinds); when omitted this computes its own. On darwin (no
394
+ * `/proc`) `listCwdHolders`/`hasLiveHolder` always report no holder, so the
395
+ * age threshold alone gates darwin — same accepted tradeoff as
396
+ * `reclaimTerminalJobOrphans`'s `isLive`.
362
397
  *
363
398
  * Every path visited is verified (`isUnderRoot`) to be nested under this
364
399
  * kind's own root before any read or delete, so a maliciously-shaped on-disk
@@ -371,6 +406,7 @@ async function sweepStaleWorktreeCheckouts(kind, opts = {}) {
371
406
  const staleAgeMs = Number.isFinite(opts.staleAgeMs) && opts.staleAgeMs >= 0
372
407
  ? opts.staleAgeMs
373
408
  : getStaleSweepAgeMs(kind);
409
+ const holders = opts.holders instanceof Set ? opts.holders : listCwdHolders();
374
410
  const result = { checkoutsRemoved: 0, emptyRootsRemoved: 0 };
375
411
 
376
412
  let hashEntries;
@@ -405,6 +441,11 @@ async function sweepStaleWorktreeCheckouts(kind, opts = {}) {
405
441
  }
406
442
  if (Date.now() - stat.mtimeMs < staleAgeMs) continue; // still fresh — never touched
407
443
 
444
+ if (hasLiveHolder(checkoutDir, holders)) {
445
+ console.log(`[gitWorktree] stale-age sweep (kind:${kind}): skipping ${checkoutDir} — live cwd holder outlasts the age threshold`);
446
+ continue;
447
+ }
448
+
408
449
  try {
409
450
  await teardownOrphanedCheckout(checkoutDir);
410
451
  result.checkoutsRemoved++;
@@ -465,6 +506,199 @@ async function captureAndCarryBaseDiff({ cwd, dir }) {
465
506
  return { ok: true, paths };
466
507
  }
467
508
 
509
+ /**
510
+ * Counts how many worktrees of `kind` genuinely exist on disk RIGHT NOW,
511
+ * machine-wide — not limited to any one project's cwd, since the in-memory
512
+ * cap (`activeWorktreeCount`) is itself shared across every project using
513
+ * this kind. Walks the kind's own root directory (same enumeration
514
+ * `sweepStaleWorktreeCheckouts` uses) to find candidate checkouts, resolves
515
+ * each to its owning main tree via `mainTreeFromWorktreeGitFile` (never
516
+ * trusting a directory's mere presence — a half-created or already-removed
517
+ * checkout must not count), then asks EACH distinct main tree's own `git
518
+ * worktree list --porcelain` (the same invocation `reconcileWorktreesOnBoot`
519
+ * already uses, via `execGit`/`parseWorktreeListPorcelain` — no second
520
+ * git-invocation style) which of its registered worktrees still live under
521
+ * this kind's root. Never throws; an unreadable root or a git failure for a
522
+ * given main tree simply contributes 0 from that tree rather than aborting
523
+ * the whole count.
524
+ */
525
+ async function computeObservedWorktreeCount(kind) {
526
+ const root = worktreeRootFor(kind);
527
+ let hashEntries;
528
+ try {
529
+ hashEntries = await fsp.readdir(root, { withFileTypes: true });
530
+ } catch {
531
+ return 0; // root doesn't exist (or unreadable) — nothing on disk
532
+ }
533
+
534
+ const checkoutDirs = [];
535
+ for (const hashEntry of hashEntries) {
536
+ if (!hashEntry.isDirectory()) continue;
537
+ const hashDir = path.join(root, hashEntry.name);
538
+ if (!isUnderRoot(hashDir, root)) continue;
539
+ let keyEntries;
540
+ try {
541
+ keyEntries = await fsp.readdir(hashDir, { withFileTypes: true });
542
+ } catch {
543
+ continue;
544
+ }
545
+ for (const keyEntry of keyEntries) {
546
+ if (!keyEntry.isDirectory()) continue;
547
+ const checkoutDir = path.join(hashDir, keyEntry.name);
548
+ if (isUnderRoot(checkoutDir, root)) checkoutDirs.push(checkoutDir);
549
+ }
550
+ }
551
+ if (!checkoutDirs.length) return 0;
552
+
553
+ const mainTrees = new Set();
554
+ for (const dir of checkoutDirs) {
555
+ const mainTree = await mainTreeFromWorktreeGitFile(dir);
556
+ if (mainTree) mainTrees.add(mainTree);
557
+ }
558
+
559
+ const liveWorktreePaths = new Set();
560
+ for (const mainTree of mainTrees) {
561
+ let out = '';
562
+ try {
563
+ out = await execGit(['worktree', 'list', '--porcelain'], { cwd: mainTree, timeout: 15_000 });
564
+ } catch {
565
+ continue; // that main tree is unreadable right now — its worktrees simply don't count this pass
566
+ }
567
+ for (const entry of parseWorktreeListPorcelain(out)) {
568
+ if (entry.worktree && isUnderRoot(entry.worktree, root)) {
569
+ liveWorktreePaths.add(path.resolve(entry.worktree));
570
+ }
571
+ }
572
+ }
573
+ return liveWorktreePaths.size;
574
+ }
575
+
576
+ /**
577
+ * Enumerates every process on the machine currently holding some directory as
578
+ * its cwd, as a Set of resolved absolute paths (never throws). Linux-only —
579
+ * walks `/proc/*\/cwd` via `readlinkSync`, tolerating a per-pid `EACCES`
580
+ * (owned by another user) or `ENOENT`/`ESRCH` (process exited between the
581
+ * `readdir` and the `readlink`) by simply skipping that pid rather than
582
+ * aborting the whole scan. On darwin (no `/proc`) returns an empty Set with
583
+ * no log line — `hasLiveHolder`'s caller is expected to also gate on a live
584
+ * `runtime.pid`, which is the darwin-covering half of that check.
585
+ *
586
+ * A readlink target ending in ` (deleted)` (the kernel's marker for a cwd
587
+ * whose directory entry was unlinked while still held open) has that suffix
588
+ * stripped before being added, so a holder of an already-unlinked directory
589
+ * still counts as a holder of the original path.
590
+ */
591
+ function listCwdHolders() {
592
+ const holders = new Set();
593
+ if (process.platform !== 'linux') return holders;
594
+ let pids;
595
+ try {
596
+ pids = fs.readdirSync('/proc').filter((name) => /^\d+$/.test(name));
597
+ } catch {
598
+ return holders;
599
+ }
600
+ const DELETED_SUFFIX = ' (deleted)';
601
+ for (const pid of pids) {
602
+ try {
603
+ const target = fs.readlinkSync(`/proc/${pid}/cwd`);
604
+ const clean = target.endsWith(DELETED_SUFFIX) ? target.slice(0, -DELETED_SUFFIX.length) : target;
605
+ holders.add(path.resolve(clean));
606
+ } catch {
607
+ // EACCES (not our process), ENOENT/ESRCH (pid exited mid-scan) — skip.
608
+ }
609
+ }
610
+ return holders;
611
+ }
612
+
613
+ /**
614
+ * True when any process on the machine holds `dir` (or a path nested under
615
+ * it) as its cwd — the liveness signal that lets boot reconciliation tell a
616
+ * worktree still backing a running `claude -p` executor (survives an Electron
617
+ * restart via `detached: true`) apart from a genuinely abandoned one. Never
618
+ * throws.
619
+ *
620
+ * `holders`, when supplied, must be a `Set` already produced by
621
+ * `listCwdHolders()` — this lets a caller sweeping N candidate worktrees
622
+ * compute the `/proc` scan once and reuse it, rather than paying a full
623
+ * `readdir` + per-pid `readlink` pass for every candidate. When omitted, this
624
+ * computes its own (a single-candidate convenience, and what the bare
625
+ * `hasLiveHolder(dir)` contract in the AC exercises directly).
626
+ */
627
+ function hasLiveHolder(dir, holders) {
628
+ try {
629
+ if (!dir || typeof dir !== 'string') return false;
630
+ if (process.platform !== 'linux') return false;
631
+ const resolvedDir = path.resolve(dir);
632
+ const set = holders instanceof Set ? holders : listCwdHolders();
633
+ for (const holder of set) {
634
+ if (holder === resolvedDir || holder.startsWith(resolvedDir + path.sep)) return true;
635
+ }
636
+ return false;
637
+ } catch {
638
+ return false;
639
+ }
640
+ }
641
+
642
+ /** TTL-memoized wrapper over `computeObservedWorktreeCount` — see the cache header above. */
643
+ async function getObservedWorktreeCount(kind, ttlMs = OBSERVED_WORKTREE_COUNT_TTL_MS) {
644
+ const cache = observedWorktreeCountCache[kind];
645
+ const now = Date.now();
646
+ if (cache.count !== null && now - cache.at < ttlMs) return cache.count;
647
+ const count = await computeObservedWorktreeCount(kind);
648
+ cache.count = count;
649
+ cache.at = now;
650
+ return count;
651
+ }
652
+
653
+ /**
654
+ * Serializes `fn` per kind via a promise chain, so the cap-check-and-reserve
655
+ * decision below stays a single atomic step across concurrent callers even
656
+ * though it now sometimes needs to `await` a fresh on-disk observation.
657
+ * `worktreeCapLockChain[kind]` is deliberately kept always-resolving (its
658
+ * own rejection is swallowed) so one caller's failure can never wedge every
659
+ * later caller behind it.
660
+ */
661
+ function withKindLock(kind, fn) {
662
+ const previous = worktreeCapLockChain[kind];
663
+ const result = previous.then(fn, fn);
664
+ worktreeCapLockChain[kind] = result.then(() => {}, () => {});
665
+ return result;
666
+ }
667
+
668
+ /**
669
+ * Decides whether a new worktree of `kind` may be created right now, and if
670
+ * so reserves the slot — the single choke point `createWorktree` calls
671
+ * before doing any actual git work. Replaces the old bare
672
+ * `if (activeWorktreeCount[kind] >= cap) reject; else activeWorktreeCount[kind]++`
673
+ * with the same fast path when comfortably under cap (no await at all, same
674
+ * as before), but when the in-memory counter is AT OR OVER the cap, verifies
675
+ * that against observed on-disk reality before honoring the rejection —
676
+ * self-healing a leaked (never-decremented) counter instead of wedging the
677
+ * cap at a stale high-water mark for the rest of the process's life.
678
+ *
679
+ * TOCTOU safety for concurrent callers: the entire decision (including the
680
+ * observed-count await) runs inside `withKindLock`, so only one caller per
681
+ * kind is ever mid-decision at a time — a strictly stronger guarantee than
682
+ * the old purely-synchronous check+increment, which only protected callers
683
+ * that happened to land in the same tick.
684
+ */
685
+ async function reserveWorktreeSlot(kind) {
686
+ return withKindLock(kind, async () => {
687
+ const cap = getMaxConcurrentWorktrees(kind);
688
+ if (activeWorktreeCount[kind] >= cap) {
689
+ const observed = await getObservedWorktreeCount(kind);
690
+ if (observed < activeWorktreeCount[kind]) {
691
+ activeWorktreeCount[kind] = observed;
692
+ }
693
+ if (activeWorktreeCount[kind] >= cap) {
694
+ return { ok: false, reason: `worktree cap reached (${cap} concurrent)` };
695
+ }
696
+ }
697
+ activeWorktreeCount[kind]++;
698
+ return { ok: true };
699
+ });
700
+ }
701
+
468
702
  /**
469
703
  * Create a linked worktree on a fresh branch checked out from the main
470
704
  * tree's current HEAD. Returns `{ ok: true, dir, branch, baseCwd,
@@ -500,16 +734,11 @@ async function createWorktree({ kind, cwd, key }) {
500
734
 
501
735
  const baseWasClean = await isBaseTreeClean(cwd);
502
736
 
503
- if (activeWorktreeCount[kind] >= getMaxConcurrentWorktrees(kind)) {
504
- return { ok: false, reason: `worktree cap reached (${getMaxConcurrentWorktrees(kind)} concurrent)` };
505
- }
506
- // Reserve the slot SYNCHRONOUSLY (before any `await` below) so two callers
507
- // invoked in the same tick can't both pass the check above and both
508
- // proceed — without this, the cap is a TOCTOU race: N concurrent callers
509
- // all read the pre-increment count before either increments it. Released
510
- // again below on any failure path so a failed create never permanently
511
- // shrinks capacity.
512
- activeWorktreeCount[kind]++;
737
+ // Cap check + slot reservation as a single atomic step, per kind — see
738
+ // reserveWorktreeSlot's header. Released again below on any failure path
739
+ // so a failed create never permanently shrinks capacity.
740
+ const reservation = await reserveWorktreeSlot(kind);
741
+ if (!reservation.ok) return reservation;
513
742
 
514
743
  const dir = worktreeDirFor(kind, cwd, key);
515
744
  const branch = branchNameFor(kind, key);
@@ -607,6 +836,49 @@ function parseBlockingMergePaths(stderrText) {
607
836
  return { tracked: Array.from(new Set(tracked)), untracked: Array.from(new Set(untracked)) };
608
837
  }
609
838
 
839
+ /**
840
+ * Resolves `cwd`'s default branch, without ever hardcoding `main`: prefers
841
+ * the remote-tracked default (`origin/HEAD`, set by `git clone`/`git remote
842
+ * set-head`), falls back to the repo's own `init.defaultBranch` config, then
843
+ * to whichever of a local `main`/`master` branch actually exists, and only
844
+ * as a last resort assumes `main`. Never throws — every git failure along
845
+ * this chain (no remote, no config, no matching local branch) simply falls
846
+ * through to the next source.
847
+ */
848
+ async function resolveDefaultBranch(cwd) {
849
+ try {
850
+ const out = (await execGit(['symbolic-ref', 'refs/remotes/origin/HEAD'], { cwd, timeout: 10_000 })).trim();
851
+ const match = out.match(/^refs\/remotes\/origin\/(.+)$/);
852
+ if (match && match[1]) return match[1];
853
+ } catch { /* no remote, or origin/HEAD not set — fall through */ }
854
+ try {
855
+ const out = (await execGit(['config', '--get', 'init.defaultBranch'], { cwd, timeout: 10_000 })).trim();
856
+ if (out) return out;
857
+ } catch { /* not configured — fall through */ }
858
+ try {
859
+ await execGit(['show-ref', '--verify', '--quiet', 'refs/heads/main'], { cwd, timeout: 10_000 });
860
+ return 'main';
861
+ } catch { /* no local main — fall through */ }
862
+ try {
863
+ await execGit(['show-ref', '--verify', '--quiet', 'refs/heads/master'], { cwd, timeout: 10_000 });
864
+ return 'master';
865
+ } catch { /* no local master either — fall through */ }
866
+ return 'main';
867
+ }
868
+
869
+ /**
870
+ * `cwd`'s current branch name, or `null` on a detached HEAD (or any other
871
+ * state `git symbolic-ref` can't resolve to a branch). Never throws.
872
+ */
873
+ async function getCurrentBranch(cwd) {
874
+ try {
875
+ const out = (await execGit(['symbolic-ref', '-q', '--short', 'HEAD'], { cwd, timeout: 10_000 })).trim();
876
+ return out || null;
877
+ } catch {
878
+ return null;
879
+ }
880
+ }
881
+
610
882
  /**
611
883
  * Integrate a branch back into `cwd`'s current HEAD — fast-forward when
612
884
  * possible, a real merge commit when the main tree advanced underneath (a
@@ -625,9 +897,30 @@ function parseBlockingMergePaths(stderrText) {
625
897
  * job's own work — landing such a commit would just re-apply the human's
626
898
  * uncommitted edit back onto itself via a merge, and could conflict with
627
899
  * the base tree still holding that same path dirty.
900
+ *
901
+ * Refuses outright — before touching any other git state — when `cwd`'s
902
+ * HEAD is not on the repo's own default branch (resolved via
903
+ * `resolveDefaultBranch`, never hardcoded to `main`) or is detached. A
904
+ * stray checkout onto some other branch (e.g. a leftover `sm-job/*` branch
905
+ * from a prior run) would otherwise make every subsequent merge-base/merge
906
+ * silently target the WRONG tree, producing merge "conflicts" that are
907
+ * really just wrong-base artifacts (RCA: starry-night-ships, 2026-09-12).
908
+ * This refusal never checks out, resets, or switches anything — it only
909
+ * reports; a human fixes the checkout.
628
910
  */
629
911
  async function integrateBranch({ cwd, branch, key, kind, carriedPaths }) {
630
912
  if (!cwd || !branch) return { ok: false, reason: 'missing cwd/branch' };
913
+
914
+ const defaultBranch = await resolveDefaultBranch(cwd);
915
+ const currentBranch = await getCurrentBranch(cwd);
916
+ if (currentBranch !== defaultBranch) {
917
+ const actual = currentBranch === null ? 'detached HEAD' : currentBranch;
918
+ return {
919
+ ok: false,
920
+ reason: `refusing to integrate: HEAD is on "${actual}", not the repo's default branch "${defaultBranch}" — a stray checkout must be fixed before integration can proceed`,
921
+ };
922
+ }
923
+
631
924
  let branchHead;
632
925
  try {
633
926
  branchHead = (await execGit(['rev-parse', branch], { cwd, timeout: 10_000 })).trim();
@@ -901,6 +1194,99 @@ function keyFromBranch(kind, branch) {
901
1194
  return branch.slice(prefix.length);
902
1195
  }
903
1196
 
1197
+ /**
1198
+ * Reclaims a job-kind worktree checkout whose OWNING QUEUE ROW is already
1199
+ * terminal (`completed`/`failed`/`skipped` — passed in via `terminalSlugs`)
1200
+ * without waiting for either the stale-age sweep (`sweepStaleWorktreeCheckouts`,
1201
+ * gated on `staleSweepAgeMs`, default 24h) or the next app boot
1202
+ * (`reconcileWorktreesOnBoot` only runs once, at startup). A job whose run
1203
+ * ended without ever reaching `cleanupWorktree` (crash, reap, SIGTERM, an
1204
+ * exception before teardown) otherwise leaves its checkout on disk
1205
+ * indefinitely even though the row itself already resolved.
1206
+ *
1207
+ * Never assumes it's safe to delete — proves it, two ways, for each
1208
+ * candidate:
1209
+ * 1. `isBranchMergedIntoHead`: the branch holds ZERO commits that aren't
1210
+ * already on `cwd`'s HEAD (equivalently, zero commits ahead of main) —
1211
+ * so integrating it again could add nothing, and removing it loses no
1212
+ * committed work.
1213
+ * 2. Every path still dirty in the checkout itself (`git status
1214
+ * --porcelain` run with cwd = the checkout dir) lives under
1215
+ * `session-manager-operations/` — i.e. it's carried-over ops-folder
1216
+ * noise (queue.json/history.jsonl churn picked up by
1217
+ * `captureAndCarryBaseDiff` when the worktree was created), never real
1218
+ * uncommitted job output.
1219
+ *
1220
+ * A candidate failing either check is left alone for a human/the stale-age
1221
+ * sweep to deal with. Returns the list of slugs actually reclaimed. Never
1222
+ * throws.
1223
+ *
1224
+ * Neither proof above is a liveness check: a still-running executor that
1225
+ * hasn't committed yet trivially satisfies proof 1 (zero commits ahead), and
1226
+ * satisfies proof 2 whenever its in-progress output is gitignored. So an
1227
+ * optional `isLive(slug, entry)` predicate — supplied by the caller, which
1228
+ * alone knows the owning queue row's recorded pid; this module keeps no
1229
+ * queue knowledge of its own — is checked FIRST, before either proof, since
1230
+ * it is the cheapest and most decisive gate. A candidate it reports live is
1231
+ * skipped and logged once, without ever reaching (or affecting) the
1232
+ * merge/status proofs below. `isLive` throwing is treated fail-SAFE: assume
1233
+ * live (skip), never assume dead — leaking a checkout one more sweep cycle
1234
+ * is far cheaper than deleting a running job's worktree out from under it.
1235
+ */
1236
+ async function reclaimTerminalJobOrphans({ cwd, terminalSlugs, isLive }) {
1237
+ const kind = 'job';
1238
+ const root = worktreeRootFor(kind);
1239
+ const reclaimed = [];
1240
+ if (!cwd || !(terminalSlugs instanceof Set) || terminalSlugs.size === 0) return reclaimed;
1241
+ if (!(await isGitRepo(cwd))) return reclaimed;
1242
+
1243
+ let out = '';
1244
+ try {
1245
+ out = await execGit(['worktree', 'list', '--porcelain'], { cwd, timeout: 15_000 });
1246
+ } catch {
1247
+ return reclaimed;
1248
+ }
1249
+
1250
+ for (const entry of parseWorktreeListPorcelain(out)) {
1251
+ if (!entry.worktree || !isUnderRoot(entry.worktree, root)) continue;
1252
+ const slug = keyFromBranch(kind, entry.branch);
1253
+ if (!slug || !terminalSlugs.has(slug)) continue;
1254
+
1255
+ if (typeof isLive === 'function') {
1256
+ let live;
1257
+ try {
1258
+ live = await isLive(slug, entry);
1259
+ } catch {
1260
+ live = true; // fail-safe: an isLive that throws must never be read as "dead"
1261
+ }
1262
+ if (live) continue;
1263
+ }
1264
+
1265
+ const merged = await isBranchMergedIntoHead(cwd, entry.branch);
1266
+ if (!merged) continue; // real un-landed work — never touch
1267
+
1268
+ let statusOut = '';
1269
+ try {
1270
+ statusOut = await execGit(['status', '--porcelain'], { cwd: entry.worktree, timeout: 15_000 });
1271
+ } catch {
1272
+ continue; // can't prove the checkout is clean-of-real-work — never assume
1273
+ }
1274
+ const dirtyPaths = statusOut.split('\n').map((l) => l.slice(3).trim()).filter(Boolean);
1275
+ const opsOnly = dirtyPaths.every(
1276
+ (p) => p === 'session-manager-operations' || p.startsWith('session-manager-operations/')
1277
+ );
1278
+ if (!opsOnly) continue; // real non-ops dirty content present — leave for a human
1279
+
1280
+ await removeWorktreeDir(cwd, entry.worktree);
1281
+ if (entry.branch) {
1282
+ try { await execGit(['branch', '-D', entry.branch], { cwd, timeout: 10_000 }); } catch { /* already gone */ }
1283
+ }
1284
+ reclaimed.push(slug);
1285
+ }
1286
+ try { await execGit(['worktree', 'prune'], { cwd, timeout: 10_000 }); } catch { /* best-effort */ }
1287
+ return reclaimed;
1288
+ }
1289
+
904
1290
  /**
905
1291
  * Boot reconciliation: a worktree that survives a process crash (app killed
906
1292
  * mid-run, host reboot) leaks disk and a dangling branch forever unless
@@ -916,9 +1302,14 @@ function keyFromBranch(kind, branch) {
916
1302
  * what lets the epic kind skip a worktree whose owning Epic is still
917
1303
  * `active` in the project's active-index.json (the caller supplies that
918
1304
  * check; this module has no active-index.json knowledge of its own — see
919
- * this chain's next PRD for the real wiring). Job-kind reconciliation has no
920
- * equivalent concept (a job worktree found at boot is, by definition, from a
921
- * run that didn't finish) and typically omits `isLive`.
1305
+ * this chain's next PRD for the real wiring). The job kind uses the same
1306
+ * mechanism for a different signal: a job is spawned `detached: true`
1307
+ * (scheduler.cjs), so its `claude -p` executor can survive an app restart —
1308
+ * scheduler.cjs's own `isLive` treats a worktree as live when its owning
1309
+ * queue row is still `running` with a live pid, OR `hasLiveHolder` finds a
1310
+ * process whose cwd is still the checkout itself (see jobWorktreeBootLive.cjs).
1311
+ * A caller that omits `isLive` entirely (e.g. an ad hoc test) still gets the
1312
+ * old unconditional-reap behavior for that kind.
922
1313
  *
923
1314
  * After the per-cwd pass, this also runs `sweepStaleWorktreeCheckouts` —
924
1315
  * a second, root-directory-driven pass that is NOT limited to `cwds`, so a
@@ -961,7 +1352,7 @@ async function reconcileWorktreesOnBoot(cwds, opts = {}) {
961
1352
 
962
1353
  let sweep = { checkoutsRemoved: 0, emptyRootsRemoved: 0 };
963
1354
  try {
964
- sweep = await sweepStaleWorktreeCheckouts(kind, { staleAgeMs: opts.staleAgeMs });
1355
+ sweep = await sweepStaleWorktreeCheckouts(kind, { staleAgeMs: opts.staleAgeMs, holders: opts.holders });
965
1356
  } catch {
966
1357
  /* never let the orphan sweep take down boot reconciliation */
967
1358
  }
@@ -1018,15 +1409,22 @@ module.exports = {
1018
1409
  createWorktree,
1019
1410
  captureAndCarryBaseDiff,
1020
1411
  pathsIdenticalToBranch,
1412
+ resolveDefaultBranch,
1413
+ getCurrentBranch,
1021
1414
  integrateBranch,
1022
1415
  cleanupWorktree,
1023
1416
  salvageWorktreeDiff,
1024
1417
  salvageDirtyDelta,
1025
1418
  isBranchMergedIntoHead,
1026
1419
  parseWorktreeListPorcelain,
1420
+ listCwdHolders,
1421
+ hasLiveHolder,
1027
1422
  reconcileWorktreesOnBoot,
1028
1423
  sweepStaleWorktreeCheckouts,
1029
1424
  mainTreeFromWorktreeGitFile,
1425
+ getObservedWorktreeCount,
1426
+ reserveWorktreeSlot,
1427
+ reclaimTerminalJobOrphans,
1030
1428
  // Job-kind convenience wrappers — same call shape jobWorktree.cjs has
1031
1429
  // always exposed.
1032
1430
  createJobWorktree,
@@ -1047,4 +1445,16 @@ module.exports = {
1047
1445
  configFor(kind);
1048
1446
  return activeWorktreeCount[kind];
1049
1447
  },
1448
+ // Test-only escape hatch for the observed-worktree-count TTL cache, so a
1449
+ // test simulating a leak-then-recover sequence isn't at the mercy of a
1450
+ // stale count left behind by an earlier test in the same process.
1451
+ _resetObservedWorktreeCountCacheForTests(kind) {
1452
+ if (kind) {
1453
+ configFor(kind);
1454
+ observedWorktreeCountCache[kind] = { count: null, at: 0 };
1455
+ } else {
1456
+ observedWorktreeCountCache.job = { count: null, at: 0 };
1457
+ observedWorktreeCountCache.epic = { count: null, at: 0 };
1458
+ }
1459
+ },
1050
1460
  };
@@ -65,7 +65,8 @@ module.exports = {
65
65
  salvageJobWorktreeDiff: gitWorktree.salvageJobWorktreeDiff,
66
66
  salvageJobDirtyDelta: gitWorktree.salvageJobDirtyDelta,
67
67
  parseWorktreeListPorcelain: gitWorktree.parseWorktreeListPorcelain,
68
- reconcileWorktreesOnBoot: (cwds) => gitWorktree.reconcileWorktreesOnBoot(cwds, { kind: KIND }),
68
+ reconcileWorktreesOnBoot: (cwds, opts = {}) => gitWorktree.reconcileWorktreesOnBoot(cwds, { kind: KIND, isLive: opts.isLive }),
69
+ reclaimTerminalJobOrphans: gitWorktree.reclaimTerminalJobOrphans,
69
70
  // Test-only escape hatch for the in-memory concurrency counter.
70
71
  _resetActiveWorktreeCountForTests(n = 0) { gitWorktree._resetActiveWorktreeCountForTests(KIND, n); },
71
72
  _getActiveWorktreeCountForTests() { return gitWorktree._getActiveWorktreeCountForTests(KIND); },
@@ -0,0 +1,51 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * jobWorktreeBootLive.cjs — builds the `isLive` predicate scheduler.cjs's
5
+ * boot sweep hands to `jobWorktree.reconcileWorktreesOnBoot` (PRD 1162).
6
+ *
7
+ * Kept in a separate lib file — same reasoning as reaperHelpers.cjs's header
8
+ * — so the predicate's logic is unit-testable without importing scheduler.cjs
9
+ * (which requires electron/ipcMain).
10
+ *
11
+ * A job is spawned `detached: true` (scheduler.cjs), so its `claude -p`
12
+ * executor SURVIVES the Electron app being killed/restarted — a worktree
13
+ * found at boot is NOT, by itself, proof the run that owns it died. This
14
+ * predicate treats a worktree as live via either signal:
15
+ * 1. its owning queue row is still `status: 'running'` with a `runtime.pid`
16
+ * that `claudePidAlive` reports alive, or
17
+ * 2. `hasLiveHolder` finds a process whose cwd is the checkout itself (or a
18
+ * path under it) — covers a row that went terminal (or was never
19
+ * written) while the process is still actually running.
20
+ * Either match logs one line naming the slug and which check held it, so a
21
+ * persistently-skipped leak stays visible (RCA: PRD 1108).
22
+ */
23
+
24
+ /**
25
+ * @param {{ bootJobs: Array<object>, claudePidAlive: (pid: number) => boolean, hasLiveHolder: (dir: string, holders?: Set<string>) => boolean, cwdHolders?: Set<string> }} deps
26
+ * @returns {(slug: string, entry: { worktree: string, branch: string|null }) => boolean}
27
+ */
28
+ function buildJobWorktreeIsLive({ bootJobs, claudePidAlive, hasLiveHolder, cwdHolders }) {
29
+ const runningPidBySlug = new Map();
30
+ for (const j of Array.isArray(bootJobs) ? bootJobs : []) {
31
+ if (j && j.status === 'running' && j.runtime && j.runtime.pid) {
32
+ runningPidBySlug.set(j.slug, j.runtime.pid);
33
+ }
34
+ }
35
+
36
+ return function isLive(slug, entry) {
37
+ const pid = runningPidBySlug.get(slug);
38
+ if (pid && claudePidAlive(pid)) {
39
+ console.log(`[gitWorktree] boot sweep: skipping job worktree slug=${slug} — running row with live pid=${pid}`);
40
+ return true;
41
+ }
42
+ const dir = entry && entry.worktree;
43
+ if (dir && hasLiveHolder(dir, cwdHolders)) {
44
+ console.log(`[gitWorktree] boot sweep: skipping job worktree slug=${slug} — live cwd holder under ${dir}`);
45
+ return true;
46
+ }
47
+ return false;
48
+ };
49
+ }
50
+
51
+ module.exports = { buildJobWorktreeIsLive };
@@ -0,0 +1,68 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * jobWorktreeTerminalOrphanLive.cjs — builds the `isLive` predicate
5
+ * scheduler.cjs's `reclaimTerminalJobOrphansThrottled` hands to
6
+ * `jobWorktree.reclaimTerminalJobOrphans` (PRD 1163).
7
+ *
8
+ * Kept in a separate lib file — same reasoning as jobWorktreeBootLive.cjs's
9
+ * header — so the predicate's logic is unit-testable without importing
10
+ * scheduler.cjs (which requires electron/ipcMain).
11
+ *
12
+ * A queue row already resolved to a terminal status (completed/failed/
13
+ * skipped) is not, by itself, proof its executor actually stopped —
14
+ * overrunning-job escalation, a phantom pidless reap, or a cancel whose kill
15
+ * didn't land can all produce a terminal row while the real process keeps
16
+ * running. This predicate treats a candidate worktree as live via either
17
+ * signal, checked in this order (cheapest/most decisive first):
18
+ * 1. `hasLiveHolder` finds a process whose cwd is the checkout itself (or a
19
+ * path under it) — project-agnostic, catches a still-running executor
20
+ * regardless of what its queue row says.
21
+ * 2. the terminal row's own recorded `runtime.pid` is still alive
22
+ * (`claudePidAlive`) — catches the case `hasLiveHolder` can't see
23
+ * (darwin has no `/proc`, so `hasLiveHolder` always reports no holder
24
+ * there; the pid check is the only gate on that platform, which is an
25
+ * accepted, documented tradeoff).
26
+ * Either match logs one line naming the slug and the reason, and reports it
27
+ * to the optional `onLive(slug, reason)` callback — this is how the caller
28
+ * (scheduler.cjs) learns which terminal rows to stamp
29
+ * `worktreeReclaimBlockedLive` on, without this module ever reading or
30
+ * writing queue state itself.
31
+ */
32
+
33
+ /**
34
+ * @param {{
35
+ * terminalJobs: Array<object>,
36
+ * claudePidAlive: (pid: number) => boolean,
37
+ * hasLiveHolder: (dir: string, holders?: Set<string>) => boolean,
38
+ * cwdHolders?: Set<string>,
39
+ * onLive?: (slug: string, reason: string) => void,
40
+ * }} deps
41
+ * @returns {(slug: string, entry: { worktree: string, branch: string|null }) => boolean}
42
+ */
43
+ function buildTerminalOrphanIsLive({ terminalJobs, claudePidAlive, hasLiveHolder, cwdHolders, onLive }) {
44
+ const pidBySlug = new Map();
45
+ for (const j of Array.isArray(terminalJobs) ? terminalJobs : []) {
46
+ if (j && j.slug && j.runtime && j.runtime.pid) pidBySlug.set(j.slug, j.runtime.pid);
47
+ }
48
+
49
+ return function isLive(slug, entry) {
50
+ const dir = entry && entry.worktree;
51
+ if (dir && hasLiveHolder(dir, cwdHolders)) {
52
+ const reason = `live cwd holder under ${dir}`;
53
+ console.log(`[gitWorktree] terminal orphan reclaim: skipping job worktree slug=${slug} — ${reason}`);
54
+ if (onLive) onLive(slug, reason);
55
+ return true;
56
+ }
57
+ const pid = pidBySlug.get(slug);
58
+ if (pid && claudePidAlive(pid)) {
59
+ const reason = `terminal row's recorded pid=${pid} still alive`;
60
+ console.log(`[gitWorktree] terminal orphan reclaim: skipping job worktree slug=${slug} — ${reason}`);
61
+ if (onLive) onLive(slug, reason);
62
+ return true;
63
+ }
64
+ return false;
65
+ };
66
+ }
67
+
68
+ module.exports = { buildTerminalOrphanIsLive };