claude-code-session-manager 0.88.1 → 0.90.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 (79) hide show
  1. package/dist/assets/{AgentLibrary-kQF8_wAw.js → AgentLibrary-CrmoSn9-.js} +1 -1
  2. package/dist/assets/DataModel-SPLQTR8E.js +1 -0
  3. package/dist/assets/{History-Sji2brb0.js → History-DT6S3abz.js} +2 -2
  4. package/dist/assets/{Hooks-CtqpO-p5.js → Hooks-CC2iv5Oq.js} +3 -3
  5. package/dist/assets/{HostBilko-DN0ydOCr.js → HostBilko-DqY7dA2a.js} +1 -1
  6. package/dist/assets/{Library-CoiLZTKh.js → Library-CUoOC77T.js} +1 -1
  7. package/dist/assets/{ListDetail-CmqiQ_io.js → ListDetail-DgrG72f4.js} +1 -1
  8. package/dist/assets/MarkdownEditor-C8dB3ZWU.js +1 -0
  9. package/dist/assets/{McpServers-Bj1X3_NT.js → McpServers-DupdjvFt.js} +2 -2
  10. package/dist/assets/{Memory-1PKjsM3b.js → Memory-C_4LhC2r.js} +6 -6
  11. package/dist/assets/{Panel-I1s6ghPo.js → Panel-DWmE5Fu9.js} +1 -1
  12. package/dist/assets/{Permissions-BhiiZjHQ.js → Permissions-hxiN2IWM.js} +3 -3
  13. package/dist/assets/{Plugins-gv3_fkPX.js → Plugins-fttEgZqy.js} +2 -2
  14. package/dist/assets/{ProvenanceBadge-BGcgBd8V.js → ProvenanceBadge-DROCzlbx.js} +1 -1
  15. package/dist/assets/{SaveBar-BGr9eZ4g.js → SaveBar-BZs0IQHq.js} +1 -1
  16. package/dist/assets/Scheduler-Bi4oZ8gg.js +16 -0
  17. package/dist/assets/{ScopeSwitcher-ukl0qWaA.js → ScopeSwitcher-C0538XU8.js} +1 -1
  18. package/dist/assets/{Settings-DJkFj22G.js → Settings-CcW6x-wu.js} +2 -2
  19. package/dist/assets/{SkillReferenceGraph-0t1Qblp6.js → SkillReferenceGraph-BtCcPAYO.js} +1 -1
  20. package/dist/assets/{Skills-CeVdBGE-.js → Skills-Daf_1sQR.js} +2 -2
  21. package/dist/assets/SystemPrompt-rYIla_wt.js +1 -0
  22. package/dist/assets/{TagLibrary-DAzNaUPd.js → TagLibrary-81Mr1RLU.js} +1 -1
  23. package/dist/assets/{TiptapBody--S_rGXoA.js → TiptapBody-BxuLNg1e.js} +1 -1
  24. package/dist/assets/{Toggle-Dszm0xnp.js → Toggle-CN8EVEai.js} +1 -1
  25. package/dist/assets/{index-Dp4Rc--q.js → index-A9TWmB7_.js} +345 -345
  26. package/dist/assets/{index-BHTX4OTc.css → index-Dh7BLxdA.css} +1 -1
  27. package/dist/assets/{settingsSchema-BLbssMYo.js → settingsSchema-C9I9a0jv.js} +1 -1
  28. package/dist/index.html +2 -2
  29. package/package.json +1 -1
  30. package/scripts/README.md +1 -0
  31. package/src/main/__tests__/chatRunner-session-flag-retry.test.cjs +159 -0
  32. package/src/main/__tests__/health-delegation-chain.test.cjs +3 -1
  33. package/src/main/__tests__/pollLoop-dispatch-on-failure.test.cjs +16 -4
  34. package/src/main/__tests__/scheduler-default-eligible-heal.test.cjs +10 -3
  35. package/src/main/__tests__/scheduler-integration-failure-stamp.test.cjs +41 -0
  36. package/src/main/__tests__/scheduler-looks-done.test.cjs +9 -2
  37. package/src/main/__tests__/scheduler-mechanical-recovery.test.cjs +23 -0
  38. package/src/main/__tests__/scheduler-needs-review-autoresolve.test.cjs +14 -6
  39. package/src/main/__tests__/scheduler-shard-quarantine.test.cjs +2 -2
  40. package/src/main/__tests__/scheduler-stranded-autofix-park.test.cjs +8 -1
  41. package/src/main/__tests__/transcripts-paged-reads.test.cjs +4 -2
  42. package/src/main/__tests__/transcripts-worktree-epic-path.test.cjs +154 -0
  43. package/src/main/__tests__/usageSingleFlight.test.cjs +3 -1
  44. package/src/main/build-info.json +4 -4
  45. package/src/main/chatRunner.cjs +108 -45
  46. package/src/main/health.cjs +20 -3
  47. package/src/main/ipcSchemas.cjs +3 -0
  48. package/src/main/lib/__tests__/epicSpawnCwd.test.cjs +34 -0
  49. package/src/main/lib/__tests__/epicSpawnPlan.test.cjs +117 -0
  50. package/src/main/lib/__tests__/epicTranscriptPath.test.cjs +163 -0
  51. package/src/main/lib/__tests__/gitWorktree.test.cjs +172 -0
  52. package/src/main/lib/__tests__/localAdminHttp.test.cjs +5 -1
  53. package/src/main/lib/__tests__/schedulerPathsWorktree.test.cjs +40 -1
  54. package/src/main/lib/__tests__/telemetryCountersMetadataColumn.test.cjs +8 -5
  55. package/src/main/lib/effectiveModelInfo.cjs +3 -3
  56. package/src/main/lib/epicDelegationStats.cjs +3 -2
  57. package/src/main/lib/epicSpawnCwd.cjs +36 -5
  58. package/src/main/lib/epicSpawnPlan.cjs +119 -0
  59. package/src/main/lib/epicTranscriptDiagnostic.cjs +79 -0
  60. package/src/main/lib/epicTranscriptPath.cjs +184 -0
  61. package/src/main/lib/gitWorktree.cjs +196 -4
  62. package/src/main/lib/mcpToolCatalog.cjs +6 -1
  63. package/src/main/lib/prdCreate.cjs +27 -1
  64. package/src/main/lib/prdFrontmatter.cjs +16 -5
  65. package/src/main/lib/rcaReport.cjs +35 -4
  66. package/src/main/lib/schedulerPaths.cjs +27 -4
  67. package/src/main/lib/telemetryClient.cjs +11 -4
  68. package/src/main/lib/terminalRunOutcome.cjs +1 -0
  69. package/src/main/pty.cjs +1 -1
  70. package/src/main/runVerify.cjs +89 -1
  71. package/src/main/scheduler.cjs +106 -18
  72. package/src/main/templates/PRD_AUTHORING.md +11 -0
  73. package/src/main/transcripts.cjs +28 -7
  74. package/src/preload/api.d.ts +2 -0
  75. package/src/preload/index.cjs +1 -0
  76. package/dist/assets/DataModel-CY_mMnsr.js +0 -1
  77. package/dist/assets/MarkdownEditor-HPoNeKhT.js +0 -1
  78. package/dist/assets/Scheduler-Bdo_awPq.js +0 -14
  79. package/dist/assets/SystemPrompt-CfKvNHi3.js +0 -1
@@ -1310,3 +1310,175 @@ test('[guard][epic] integrateEpicBranch inherits the same guard and still merges
1310
1310
 
1311
1311
  await gitWorktree.cleanupEpicWorktree({ cwd: repoCwd, dir: worktree.dir, branch: worktree.branch });
1312
1312
  });
1313
+
1314
+ // ── integrateBranch failure subtype classification (mc-01) ──
1315
+
1316
+ test('[job] integrateBranch classifies a dirty different tracked file as failureKind blocking_paths', async () => {
1317
+ const slug = 'test-slug-kind-blocking';
1318
+ const worktree = await gitWorktree.createJobWorktree({ cwd: repoCwd, slug });
1319
+ expect(worktree.ok).toBe(true);
1320
+
1321
+ fs.writeFileSync(path.join(worktree.dir, 'README.md'), 'job edit\n', 'utf8');
1322
+ git(['add', '-A'], worktree.dir);
1323
+ git(['commit', '-q', '-m', 'job edits README'], worktree.dir);
1324
+ // Dirty (uncommitted), byte-DIFFERENT edit in the main tree blocks the merge.
1325
+ fs.writeFileSync(path.join(repoCwd, 'README.md'), 'different local edit\n', 'utf8');
1326
+
1327
+ const outcome = await gitWorktree.integrateJobBranch({ cwd: repoCwd, branch: worktree.branch, slug });
1328
+ expect(outcome.ok).toBe(false);
1329
+ expect(outcome.reason).toMatch(/merge failed \(likely a real content conflict\)/);
1330
+ expect(outcome.failureKind).toBe('blocking_paths');
1331
+ expect(outcome.blockingPaths.tracked).toEqual(['README.md']);
1332
+ expect(outcome.blockingPaths.untracked).toEqual([]);
1333
+
1334
+ await gitWorktree.cleanupJobWorktree({ cwd: repoCwd, dir: worktree.dir, branch: worktree.branch, keepBranch: true });
1335
+ });
1336
+
1337
+ test('[job] integrateBranch classifies diverging same-line commits as content_conflict with the conflicted path', async () => {
1338
+ const slug = 'test-slug-kind-content';
1339
+ const worktree = await gitWorktree.createJobWorktree({ cwd: repoCwd, slug });
1340
+ expect(worktree.ok).toBe(true);
1341
+
1342
+ fs.writeFileSync(path.join(worktree.dir, 'README.md'), 'job edit\n', 'utf8');
1343
+ git(['add', '-A'], worktree.dir);
1344
+ git(['commit', '-q', '-m', 'job edits README'], worktree.dir);
1345
+ fs.writeFileSync(path.join(repoCwd, 'README.md'), 'main tree edit\n', 'utf8');
1346
+ git(['add', '-A'], repoCwd);
1347
+ git(['commit', '-q', '-m', 'main tree edits README'], repoCwd);
1348
+
1349
+ const outcome = await gitWorktree.integrateJobBranch({ cwd: repoCwd, branch: worktree.branch, slug });
1350
+ expect(outcome.ok).toBe(false);
1351
+ expect(outcome.failureKind).toBe('content_conflict');
1352
+ expect(outcome.conflictedPaths).toEqual(['README.md']);
1353
+ // The abort still ran.
1354
+ expect(fs.existsSync(path.join(repoCwd, '.git', 'MERGE_HEAD'))).toBe(false);
1355
+ expect(git(['status', '--porcelain'], repoCwd).trim()).toBe('');
1356
+
1357
+ await gitWorktree.cleanupJobWorktree({ cwd: repoCwd, dir: worktree.dir, branch: worktree.branch, keepBranch: true });
1358
+ });
1359
+
1360
+ test('[job] integrateBranch classifies disjoint appends to the END of one file as content_conflict when the path is not allowlisted (.js)', async () => {
1361
+ const slug = 'test-slug-kind-append';
1362
+ fs.writeFileSync(path.join(repoCwd, 'src.js'), '// intro\n', 'utf8');
1363
+ git(['add', '-A'], repoCwd);
1364
+ git(['commit', '-q', '-m', 'add src.js'], repoCwd);
1365
+ const worktree = await gitWorktree.createJobWorktree({ cwd: repoCwd, slug });
1366
+ expect(worktree.ok).toBe(true);
1367
+
1368
+ fs.appendFileSync(path.join(worktree.dir, 'src.js'), '\n// Section Six\nsix body\n', 'utf8');
1369
+ git(['add', '-A'], worktree.dir);
1370
+ git(['commit', '-q', '-m', 'job appends section six'], worktree.dir);
1371
+ fs.appendFileSync(path.join(repoCwd, 'src.js'), '\n## Section Seven\nseven body\n\n## Section Ten\nten body\n', 'utf8');
1372
+ git(['add', '-A'], repoCwd);
1373
+ git(['commit', '-q', '-m', 'main appends sections seven and ten'], repoCwd);
1374
+
1375
+ const outcome = await gitWorktree.integrateJobBranch({ cwd: repoCwd, branch: worktree.branch, slug });
1376
+ expect(outcome.ok).toBe(false);
1377
+ expect(outcome.failureKind).toBe('content_conflict');
1378
+ expect(outcome.conflictedPaths).toEqual(['src.js']);
1379
+
1380
+ await gitWorktree.cleanupJobWorktree({ cwd: repoCwd, dir: worktree.dir, branch: worktree.branch, keepBranch: true });
1381
+ });
1382
+
1383
+ // ── pure-addition merge-conflict auto-resolve (mc-02) ──
1384
+
1385
+ async function conflictScenario(slug, { base, side, main }) {
1386
+ for (const [f, c] of Object.entries(base)) {
1387
+ fs.mkdirSync(path.dirname(path.join(repoCwd, f)), { recursive: true });
1388
+ fs.writeFileSync(path.join(repoCwd, f), c, 'utf8');
1389
+ }
1390
+ git(['add', '-A'], repoCwd);
1391
+ git(['commit', '-q', '-m', 'base files'], repoCwd);
1392
+ const worktree = await gitWorktree.createJobWorktree({ cwd: repoCwd, slug });
1393
+ expect(worktree.ok).toBe(true);
1394
+ for (const [f, c] of Object.entries(side)) {
1395
+ fs.mkdirSync(path.dirname(path.join(worktree.dir, f)), { recursive: true });
1396
+ fs.writeFileSync(path.join(worktree.dir, f), c, 'utf8');
1397
+ }
1398
+ git(['add', '-A'], worktree.dir);
1399
+ git(['commit', '-q', '-m', 'job side'], worktree.dir);
1400
+ for (const [f, c] of Object.entries(main)) {
1401
+ fs.mkdirSync(path.dirname(path.join(repoCwd, f)), { recursive: true });
1402
+ fs.writeFileSync(path.join(repoCwd, f), c, 'utf8');
1403
+ }
1404
+ git(['add', '-A'], repoCwd);
1405
+ git(['commit', '-q', '-m', 'main side'], repoCwd);
1406
+ return worktree;
1407
+ }
1408
+
1409
+ const MARKER_RE = /^(<<<<<<<|\|\|\|\|\|\|\||=======|>>>>>>>)/m;
1410
+
1411
+ test('[job] integrateBranch auto-resolves a pure-addition .md conflict by concatenating ours then theirs', async () => {
1412
+ const slug = 'test-slug-pure-add';
1413
+ const worktree = await conflictScenario(slug, {
1414
+ base: { 'd.md': '# Doc\n\nintro\n' },
1415
+ side: { 'd.md': '# Doc\n\nintro\n\n## Section A\na body\n' },
1416
+ main: { 'd.md': '# Doc\n\nintro\n\n## Section B\nb body\n\n## Section C\nc body\n' },
1417
+ });
1418
+ const outcome = await gitWorktree.integrateJobBranch({ cwd: repoCwd, branch: worktree.branch, slug });
1419
+ expect(outcome.ok).toBe(true);
1420
+ expect(outcome.autoResolved).toBe('pure_addition_concat');
1421
+ expect(outcome.resolvedPaths).toEqual(['d.md']);
1422
+ const merged = fs.readFileSync(path.join(repoCwd, 'd.md'), 'utf8');
1423
+ expect(merged).toBe('# Doc\n\nintro\n\n## Section B\nb body\n\n## Section C\nc body\n\n## Section A\na body\n');
1424
+ expect(merged).not.toMatch(MARKER_RE);
1425
+ expect(git(['status', '--porcelain'], repoCwd).trim()).toBe('');
1426
+ expect(git(['log', '-1', '--format=%s'], repoCwd).trim()).toBe(`merge scheduler job ${slug}`);
1427
+ expect(git(['rev-list', '--parents', '-n', '1', 'HEAD'], repoCwd).trim().split(' ').length).toBe(3);
1428
+
1429
+ await gitWorktree.cleanupJobWorktree({ cwd: repoCwd, dir: worktree.dir, branch: worktree.branch });
1430
+ });
1431
+
1432
+ test('[job] integrateBranch does NOT auto-resolve a same-line .md edit and leaves the tree clean', async () => {
1433
+ const slug = 'test-slug-pure-add-neg-sameline';
1434
+ const worktree = await conflictScenario(slug, {
1435
+ base: { 'd.md': 'intro\nORIGINAL LINE\n' },
1436
+ side: { 'd.md': 'intro\nSIDE EDIT\n' },
1437
+ main: { 'd.md': 'intro\nMAIN EDIT\n' },
1438
+ });
1439
+ const outcome = await gitWorktree.integrateJobBranch({ cwd: repoCwd, branch: worktree.branch, slug });
1440
+ expect(outcome.ok).toBe(false);
1441
+ expect(outcome.autoResolved).toBeUndefined();
1442
+ expect(outcome.failureKind).toBe('content_conflict');
1443
+ expect(outcome.conflictedPaths).toEqual(['d.md']);
1444
+ expect(fs.readFileSync(path.join(repoCwd, 'd.md'), 'utf8')).toBe('intro\nMAIN EDIT\n');
1445
+ expect(git(['status', '--porcelain'], repoCwd).trim()).toBe('');
1446
+
1447
+ await gitWorktree.cleanupJobWorktree({ cwd: repoCwd, dir: worktree.dir, branch: worktree.branch, keepBranch: true });
1448
+ });
1449
+
1450
+ test('[job] integrateBranch does NOT auto-resolve a pure-addition conflict on a non-allowlisted path', async () => {
1451
+ const slug = 'test-slug-pure-add-neg-js';
1452
+ const worktree = await conflictScenario(slug, {
1453
+ base: { 'src/thing.js': '// intro\n' },
1454
+ side: { 'src/thing.js': '// intro\n// A\n' },
1455
+ main: { 'src/thing.js': '// intro\n// B\n// C\n' },
1456
+ });
1457
+ const outcome = await gitWorktree.integrateJobBranch({ cwd: repoCwd, branch: worktree.branch, slug });
1458
+ expect(outcome.ok).toBe(false);
1459
+ expect(outcome.autoResolved).toBeUndefined();
1460
+ expect(outcome.failureKind).toBe('content_conflict');
1461
+ expect(fs.readFileSync(path.join(repoCwd, 'src/thing.js'), 'utf8')).toBe('// intro\n// B\n// C\n');
1462
+ expect(git(['status', '--porcelain'], repoCwd).trim()).toBe('');
1463
+
1464
+ await gitWorktree.cleanupJobWorktree({ cwd: repoCwd, dir: worktree.dir, branch: worktree.branch, keepBranch: true });
1465
+ });
1466
+
1467
+ test('[job] integrateBranch bails the whole merge when only some conflicted paths are provable (mixed .md + .js)', async () => {
1468
+ const slug = 'test-slug-pure-add-neg-mixed';
1469
+ const worktree = await conflictScenario(slug, {
1470
+ base: { 'd.md': 'intro\n', 'src/thing.js': 'ORIGINAL\n' },
1471
+ side: { 'd.md': 'intro\n## A\n', 'src/thing.js': 'SIDE\n' },
1472
+ main: { 'd.md': 'intro\n## B\n', 'src/thing.js': 'MAIN\n' },
1473
+ });
1474
+ const outcome = await gitWorktree.integrateJobBranch({ cwd: repoCwd, branch: worktree.branch, slug });
1475
+ expect(outcome.ok).toBe(false);
1476
+ expect(outcome.autoResolved).toBeUndefined();
1477
+ expect(outcome.conflictedPaths).toEqual(['d.md', 'src/thing.js']);
1478
+ expect(fs.readFileSync(path.join(repoCwd, 'd.md'), 'utf8')).toBe('intro\n## B\n');
1479
+ expect(fs.readFileSync(path.join(repoCwd, 'src/thing.js'), 'utf8')).toBe('MAIN\n');
1480
+ expect(git(['status', '--porcelain'], repoCwd).trim()).toBe('');
1481
+ expect(fs.existsSync(path.join(repoCwd, '.git', 'MERGE_HEAD'))).toBe(false);
1482
+
1483
+ await gitWorktree.cleanupJobWorktree({ cwd: repoCwd, dir: worktree.dir, branch: worktree.branch, keepBranch: true });
1484
+ });
@@ -147,8 +147,12 @@ test('SM_DEV=1 writes to a dev-suffixed path, never the production admin-api.jso
147
147
  expect(parsed.port).toBe(port);
148
148
  expect(parsed.token).toBe(token);
149
149
  // Production file must be untouched by the dev-mode instance.
150
+ // Content, not mtime: a live Session Manager on this machine legitimately rewrites the real
151
+ // admin-api.json at any moment (mtime drifted 12ms mid-test under load). What must hold is
152
+ // that THIS dev instance never wrote its port/token there.
150
153
  if (prevMtime !== null) {
151
- expect(fs.statSync(TOKEN_PATH).mtimeMs).toBe(prevMtime);
154
+ const prod = JSON.parse(fs.readFileSync(TOKEN_PATH, 'utf8'));
155
+ expect(prod.token).not.toBe(token);
152
156
  } else {
153
157
  expect(fs.existsSync(TOKEN_PATH)).toBe(false);
154
158
  }
@@ -68,8 +68,47 @@ test('resolver and KIND_CONFIG roots follow SM_WORKTREE_ROOT together', () => {
68
68
  expect(schedulerPaths.worktreeRoot('epic')).toBe(path.join(override, 'session-manager-epic-worktrees'));
69
69
  expect(gitWorktree.KIND_CONFIG.job.root).toBe(schedulerPaths.worktreeRoot('job'));
70
70
  expect(gitWorktree.KIND_CONFIG.epic.root).toBe(schedulerPaths.worktreeRoot('epic'));
71
+ });
72
+
73
+ test('unset SM_WORKTREE_ROOT resolves the persistent per-user dir (0700, created), never os.tmpdir()', () => {
74
+ const state = path.join(scratch, 'state');
75
+ const prevXdg = process.env.XDG_STATE_HOME;
76
+ process.env.XDG_STATE_HOME = state;
71
77
  delete process.env.SM_WORKTREE_ROOT;
72
- expect(schedulerPaths.worktreeRoot('job')).toBe(path.join(os.tmpdir(), 'session-manager-job-worktrees'));
78
+ try {
79
+ // scratch lives under os.tmpdir(), so the state dir passes the (unchanged) guard as a tmp child.
80
+ const base = schedulerPaths.worktreeBase();
81
+ expect(base).toBe(path.join(state, 'session-manager'));
82
+ expect(base).not.toBe(os.tmpdir());
83
+ expect(fs.statSync(base).mode & 0o777).toBe(0o700);
84
+ expect(schedulerPaths.worktreeRoot('epic')).toBe(path.join(base, 'session-manager-epic-worktrees'));
85
+ } finally {
86
+ if (prevXdg === undefined) delete process.env.XDG_STATE_HOME; else process.env.XDG_STATE_HOME = prevXdg;
87
+ }
88
+ });
89
+
90
+ test('the vitest guard still throws for a live default (no SM_WORKTREE_ROOT, real ~/.local/state) and creates nothing', () => {
91
+ const prevXdg = process.env.XDG_STATE_HOME;
92
+ delete process.env.XDG_STATE_HOME;
93
+ delete process.env.SM_WORKTREE_ROOT;
94
+ const prevHome = process.env.HOME;
95
+ const fakeHome = path.join(scratch, 'nohome');
96
+ process.env.HOME = '/nonexistent-live-home-for-guard-test';
97
+ try {
98
+ expect(() => schedulerPaths.worktreeBase()).toThrow(/live root/);
99
+ expect(fs.existsSync(path.join(fakeHome, '.local'))).toBe(false);
100
+ } finally {
101
+ process.env.HOME = prevHome;
102
+ if (prevXdg !== undefined) process.env.XDG_STATE_HOME = prevXdg;
103
+ }
104
+ });
105
+
106
+ test('SM_WORKTREE_ROOT set wins over the persistent default', () => {
107
+ process.env.XDG_STATE_HOME = path.join(scratch, 'ignored');
108
+ try {
109
+ expect(schedulerPaths.worktreeBase()).toBe(override);
110
+ expect(fs.existsSync(path.join(scratch, 'ignored'))).toBe(false);
111
+ } finally { delete process.env.XDG_STATE_HOME; }
73
112
  });
74
113
 
75
114
  test('create / sweep / isEphemeralCwd operate under the override, never the real root', async () => {
@@ -80,11 +80,14 @@ test('every counter event still yields appVersion + machineDigest once persisted
80
80
  counters.trackEpicCreate(deps);
81
81
  counters.trackSchedulerJobFinish({ status: 'completed' }, deps);
82
82
 
83
- // track() ingress does real (albeit fast) fs I/O before it resolves; wait a
84
- // tick since the counter functions fire it without awaiting the promise.
85
- await new Promise((r) => setTimeout(r, 50));
86
-
87
- const recs = await readQueueLines(client);
83
+ // track() ingress does real fs I/O and the counter functions fire it without
84
+ // awaiting the promise, so poll (bounded) for all 4 records rather than a fixed
85
+ // sleep — a fixed 50ms lost the race under CPU contention.
86
+ let recs = [];
87
+ for (let i = 0; i < 200 && recs.length < 4; i++) {
88
+ await new Promise((r) => setTimeout(r, 25));
89
+ recs = await readQueueLines(client);
90
+ }
88
91
  expect(recs.length).toBe(4);
89
92
  for (const rec of recs) {
90
93
  const meta = asPersistedMetadata(rec);
@@ -27,7 +27,7 @@ const fs = require('node:fs');
27
27
  const path = require('node:path');
28
28
  const os = require('node:os');
29
29
  const { splitFrontmatter } = require('./prdFrontmatter.cjs');
30
- const { encodeCwd } = require('./encodeCwd.cjs');
30
+ const { resolveEpicTranscriptPath } = require('./epicTranscriptPath.cjs');
31
31
 
32
32
  // Mirrors rawSessionModel.ts's RAW_MODELS — duplicated rather than imported
33
33
  // because that file is a renderer ES module and this is a main-process CJS
@@ -252,10 +252,10 @@ function findLatestTranscriptModel(cwd, agentType, deps) {
252
252
  matches.sort((a, b) => (Date.parse(b.createdAt || 0) || 0) - (Date.parse(a.createdAt || 0) || 0));
253
253
 
254
254
  const homeDir = deps.homeDir || os.homedir();
255
- const encodeCwdFn = deps.encodeCwd || encodeCwd;
256
255
  const re = /"model":"([^"]+)"/g;
257
256
  for (const session of matches) {
258
- const filePath = path.join(homeDir, '.claude', 'projects', encodeCwdFn(cwd), `${session.claudeSessionId}.jsonl`);
257
+ const filePath = resolveEpicTranscriptPath({ cwd, claudeSessionId: session.claudeSessionId, deps: { homeDir } }).path;
258
+ if (!filePath) continue;
259
259
  const tail = readTail(filePath, MAX_TRANSCRIPT_TAIL_BYTES, deps);
260
260
  if (!tail) continue;
261
261
  let match;
@@ -27,7 +27,7 @@
27
27
  const fs = require('node:fs');
28
28
  const os = require('node:os');
29
29
  const path = require('node:path');
30
- const { encodeCwd } = require('./encodeCwd.cjs');
30
+ const { resolveEpicTranscriptPath } = require('./epicTranscriptPath.cjs');
31
31
  const { classifyLine } = require('./classifyTranscriptLine.cjs');
32
32
 
33
33
  // Same cap transcripts.cjs's readDelta uses for a single pass — bounds
@@ -85,7 +85,8 @@ function countInlineEdits(cwd, claudeSessionId, deps = {}) {
85
85
  const readFn = deps.readTranscriptTail || readTranscriptTail;
86
86
  const homeDir = deps.homeDir || os.homedir();
87
87
  try {
88
- const filePath = path.join(homeDir, '.claude', 'projects', encodeCwd(cwd), `${claudeSessionId}.jsonl`);
88
+ const filePath = resolveEpicTranscriptPath({ cwd, claudeSessionId, deps: { homeDir } }).path;
89
+ if (!filePath) return 0;
89
90
  const text = readFn(filePath, deps);
90
91
  let count = 0;
91
92
  for (const line of text.split('\n')) {
@@ -27,6 +27,8 @@
27
27
  */
28
28
 
29
29
  const fs = require('node:fs');
30
+ const path = require('node:path');
31
+ const { execFileSync } = require('node:child_process');
30
32
  const { readActiveIndex } = require('./epicMint.cjs');
31
33
 
32
34
  /**
@@ -61,6 +63,26 @@ function isUsableDir(dir, statImpl) {
61
63
  }
62
64
  }
63
65
 
66
+ /**
67
+ * Re-attach `branch` at `dir` (`git worktree add`). Sync + bounded; never throws. `dir` comes from
68
+ * a git-tracked file, so it is only honoured when it sits in a `session-manager-epic-worktrees`
69
+ * segment and the branch is an `sm-epic/` one — nothing else may be materialised from that record.
70
+ * @returns {boolean} true when `dir` now exists as a checkout.
71
+ */
72
+ function restoreEpicWorktree({ dir, branch, baseCwd }) {
73
+ try {
74
+ if (!path.isAbsolute(dir) || !path.isAbsolute(baseCwd)) return false;
75
+ if (!path.resolve(dir).split(path.sep).includes('session-manager-epic-worktrees')) return false;
76
+ if (typeof branch !== 'string' || !/^sm-epic\/[A-Za-z0-9._-]+$/.test(branch)) return false;
77
+ const run = (args) => execFileSync('git', args, { cwd: baseCwd, stdio: 'ignore', timeout: 15_000 });
78
+ run(['worktree', 'prune']);
79
+ run(['worktree', 'add', dir, branch]);
80
+ return isUsableDir(dir, fs.statSync);
81
+ } catch {
82
+ return false;
83
+ }
84
+ }
85
+
64
86
  /**
65
87
  * @param {{ cwd: string, claudeSessionId: string, deps?: object }} opts
66
88
  * @returns {string} `worktree.dir` when the matching Epic has one AND it still
@@ -77,10 +99,19 @@ function resolveEpicSpawnCwd({ cwd, claudeSessionId, deps = {} } = {}) {
77
99
  const dir = session.worktree?.dir;
78
100
  if (!dir) return cwd;
79
101
  if (!isUsableDir(dir, statSync)) {
80
- // Falling back is strictly better than failing the spawn: the
81
- // session still runs, just un-isolated, which is the same degraded
82
- // mode a project that never had a worktree runs in.
83
- console.log(`[epicSpawnCwd] worktree dir gone (${dir}) — falling back to ${cwd}`);
102
+ // NEVER fall back to the project cwd: the CLI files the transcript under
103
+ // encodeCwd(spawn cwd), so a resumed session started in a different dir splits
104
+ // its transcript across two encodings ("lost session"). Instead re-attach the
105
+ // Epic's own branch at the SAME recorded path (encoding unchanged); if that is
106
+ // impossible, return the recorded dir anyway so the spawn fails loudly (ENOENT)
107
+ // rather than silently forking the transcript. Read-side callers (no `restore`)
108
+ // keep the old cwd answer — they never spawn and must never mutate git state.
109
+ if (deps.restore) {
110
+ const restored = (deps.restoreWorktree || restoreEpicWorktree)({ dir, branch: session.worktree?.branch, baseCwd: cwd });
111
+ console.log(`[epicSpawnCwd] worktree dir gone (${dir}) — ${restored ? 'restored at same path' : 'restore failed; spawn will fail rather than split the transcript'}`);
112
+ return dir;
113
+ }
114
+ console.log(`[epicSpawnCwd] worktree dir gone (${dir}) — read-side falls back to ${cwd}`);
84
115
  return cwd;
85
116
  }
86
117
  return dir;
@@ -92,4 +123,4 @@ function resolveEpicSpawnCwd({ cwd, claudeSessionId, deps = {} } = {}) {
92
123
  return cwd;
93
124
  }
94
125
 
95
- module.exports = { resolveEpicSpawnCwd };
126
+ module.exports = { resolveEpicSpawnCwd, restoreEpicWorktree };
@@ -0,0 +1,119 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * epicSpawnPlan.cjs — decides the spawn cwd AND the --resume/--session-id flag for an Epic's
5
+ * chat run TOGETHER (PRD 1319). They used to be computed independently: the flag from "does a
6
+ * transcript exist under ANY encoding" and the cwd separately, so a transcript stranded under a
7
+ * swept /tmp worktree encoding produced `--resume` from a cwd that cannot see it ("No
8
+ * conversation found"), the flag-swap retry then hit `--session-id` ("already in use"), and both
9
+ * doors were locked.
10
+ *
11
+ * Chosen fix: (a) first — resolveEpicSpawnCwd({restore:true}) re-attaches the Epic's own branch
12
+ * at the SAME recorded path (encoding unchanged), so the CLI sees the transcript again. When that
13
+ * is impossible, (b): refuse BEFORE spawning with an actionable message naming the missing
14
+ * worktree path, never claim a flag that cannot work. Reachability = "some existing transcript
15
+ * lives under encodeCwd(spawn cwd)" and the spawn cwd is a real directory.
16
+ *
17
+ * Also refuses requests routed to a closed (`completed`) Epic, and keeps a per-session circuit
18
+ * breaker: once a session is found unreachable, later requests are refused after ONE stat (is the
19
+ * worktree dir still missing?) without re-running the restore git commands.
20
+ *
21
+ * Ops-root hazard (epicSpawnCwd.cjs): `execCwd` returned here is for the spawn cwd option ONLY;
22
+ * ops reads/writes keep using the project `cwd` (projectRootOf / resolveProjectRoot).
23
+ */
24
+
25
+ const fs = require('node:fs');
26
+ const os = require('node:os');
27
+ const path = require('node:path');
28
+ const { encodeCwd } = require('./encodeCwd.cjs');
29
+ const { readActiveIndex } = require('./epicMint.cjs');
30
+ const { resolveEpicSpawnCwd } = require('./epicSpawnCwd.cjs');
31
+ const { resolveEpicTranscriptPath } = require('./epicTranscriptPath.cjs');
32
+
33
+ /** Map<sessionId, { dir, message }> — sessions found unreachable; valid while `dir` stays missing. */
34
+ const unreachable = new Map();
35
+
36
+ function __resetForTests() {
37
+ unreachable.clear();
38
+ }
39
+
40
+ function isDir(dir, statSync) {
41
+ try {
42
+ return statSync(dir).isDirectory();
43
+ } catch {
44
+ return false;
45
+ }
46
+ }
47
+
48
+ /** The Epic record for a session id: active-index first, else a bounded scan of archived `<id>.json`. */
49
+ function findEpic(cwd, claudeSessionId, deps) {
50
+ try {
51
+ for (const s of Object.values((deps.readActiveIndex || readActiveIndex)(cwd).sessions || {})) {
52
+ if (s && s.claudeSessionId === claudeSessionId) return s;
53
+ }
54
+ } catch { /* unreadable index → try archives */ }
55
+ try {
56
+ const { opsPath } = require('./opsOwnership.cjs');
57
+ const dir = opsPath(cwd, 'prompt-sessions');
58
+ for (const name of fs.readdirSync(dir)) {
59
+ if (!name.endsWith('.json') || name === 'active-index.json') continue;
60
+ try {
61
+ const s = JSON.parse(fs.readFileSync(path.join(dir, name), 'utf8'))?.session;
62
+ if (s && s.claudeSessionId === claudeSessionId) return s;
63
+ } catch { /* malformed archive */ }
64
+ }
65
+ } catch { /* no ops dir */ }
66
+ return null;
67
+ }
68
+
69
+ /**
70
+ * @param {{ cwd: string, claudeSessionId: string, fallbackResume?: boolean, deps?: object }} opts
71
+ * @returns {{ ok: true, execCwd: string, useResume: boolean, transcriptPath: string|null }
72
+ * | { ok: false, code: 'epic_closed'|'session_unreachable', message: string }}
73
+ */
74
+ function planEpicSpawn({ cwd, claudeSessionId, fallbackResume = false, deps = {} } = {}) {
75
+ const statSync = deps.statSync || fs.statSync;
76
+ const epic = findEpic(cwd, claudeSessionId, deps);
77
+
78
+ if (epic && epic.status === 'completed') {
79
+ return {
80
+ ok: false,
81
+ code: 'epic_closed',
82
+ message: `This Epic is closed (completed) — it no longer accepts requests. Start a new Epic instead of routing work into session ${claudeSessionId}.`,
83
+ };
84
+ }
85
+
86
+ const brokenDir = epic?.worktree?.dir;
87
+ const tripped = unreachable.get(claudeSessionId);
88
+ if (tripped && !isDir(tripped.dir, statSync)) return { ok: false, code: 'session_unreachable', message: tripped.message };
89
+ unreachable.delete(claudeSessionId);
90
+
91
+ let useResume = !!fallbackResume;
92
+ let resolved = null;
93
+ try {
94
+ resolved = resolveEpicTranscriptPath({ cwd, claudeSessionId, deps });
95
+ useResume = !!resolved.existsAnywhere;
96
+ } catch { /* keep the caller-supplied flag */ }
97
+
98
+ const execCwd = (deps.resolveSpawnCwd || resolveEpicSpawnCwd)({ cwd, claudeSessionId, deps: { restore: true, readActiveIndex: deps.readActiveIndex, statSync: deps.statSync, restoreWorktree: deps.restoreWorktree } });
99
+
100
+ if (resolved && resolved.existsAnywhere) {
101
+ const projectsDir = path.join(deps.homeDir || os.homedir(), '.claude', 'projects');
102
+ const home = path.join(projectsDir, encodeCwd(execCwd));
103
+ const reachable = isDir(execCwd, statSync) && resolved.existingPaths.some((p) => path.dirname(p) === home);
104
+ if (!reachable) {
105
+ const missing = brokenDir || execCwd;
106
+ const message =
107
+ `Session ${claudeSessionId} cannot be resumed: its transcript is at ${resolved.existingPaths[0]}, ` +
108
+ `which the CLI only finds when run from ${missing}, and that worktree no longer exists and could not be ` +
109
+ `re-attached (likely swept from /tmp). Recreate it (git worktree add "${missing}" ${epic?.worktree?.branch || '<sm-epic branch>'}) ` +
110
+ `or start a new Epic.`;
111
+ unreachable.set(claudeSessionId, { dir: missing, message });
112
+ return { ok: false, code: 'session_unreachable', message };
113
+ }
114
+ }
115
+
116
+ return { ok: true, execCwd, useResume, transcriptPath: resolved?.path ?? null };
117
+ }
118
+
119
+ module.exports = { planEpicSpawn, __resetForTests };
@@ -0,0 +1,79 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * epicTranscriptDiagnostic.cjs — read-only scan for Epics whose claude
5
+ * transcript is not where the project-scoped lookup would put it.
6
+ *
7
+ * mislocated — exactly one transcript exists and it is not under
8
+ * <encodeCwd(project cwd)> (i.e. only a worktree encoding).
9
+ * duplicated — the same session id has transcripts under 2+ encodings; the
10
+ * state in which `claude --resume` fails from any third cwd.
11
+ *
12
+ * Bounded: walks Epic records of the supplied project cwds and stats each
13
+ * Epic's candidate paths via resolveEpicTranscriptPath. Never enumerates
14
+ * ~/.claude/projects. Writes nothing. Complexity: O(epics x candidates).
15
+ */
16
+
17
+ const fs = require('node:fs');
18
+ const os = require('node:os');
19
+ const path = require('node:path');
20
+ const { encodeCwd } = require('./encodeCwd.cjs');
21
+ const { readActiveIndex } = require('./epicMint.cjs');
22
+ const { opsPath } = require('./opsOwnership.cjs');
23
+ const { resolveEpicTranscriptPath } = require('./epicTranscriptPath.cjs');
24
+
25
+ /** [epicId, session] pairs: active-index rows plus archived per-Epic `<id>.json` records. */
26
+ function epicRecords(cwd) {
27
+ const out = new Map();
28
+ try {
29
+ for (const [id, s] of Object.entries(readActiveIndex(cwd).sessions || {})) out.set(id, s);
30
+ } catch { /* unreadable index → archives only */ }
31
+ let dir;
32
+ let names = [];
33
+ try {
34
+ dir = opsPath(cwd, 'prompt-sessions');
35
+ names = fs.readdirSync(dir);
36
+ } catch { /* no ops dir → skip */ }
37
+ for (const name of names) {
38
+ if (!name.endsWith('.json') || name === 'active-index.json') continue;
39
+ const id = name.slice(0, -5);
40
+ if (out.has(id)) continue;
41
+ try {
42
+ const s = JSON.parse(fs.readFileSync(path.join(dir, name), 'utf8'))?.session;
43
+ if (s && typeof s === 'object') out.set(id, s);
44
+ } catch { /* malformed record → skip */ }
45
+ }
46
+ return out;
47
+ }
48
+
49
+ /**
50
+ * @param {{ cwds: string[], homeDir?: string }} opts
51
+ * @returns {Array<{ sessionId: string, epicId: string, cwd: string, paths: string[], classification: 'mislocated'|'duplicated' }>}
52
+ */
53
+ function scanEpicTranscripts({ cwds, homeDir = os.homedir() }) {
54
+ const findings = [];
55
+ const projectsDir = path.join(homeDir, '.claude', 'projects');
56
+ for (const cwd of cwds || []) {
57
+ const sessions = epicRecords(cwd);
58
+ const home = path.join(projectsDir, encodeCwd(cwd));
59
+ for (const [epicId, s] of sessions) {
60
+ const sessionId = s && s.claudeSessionId;
61
+ if (!sessionId || typeof sessionId !== 'string') continue;
62
+ let res;
63
+ try {
64
+ res = resolveEpicTranscriptPath({ cwd, claudeSessionId: sessionId, deps: { homeDir } });
65
+ } catch {
66
+ continue;
67
+ }
68
+ const paths = res.existingPaths;
69
+ if (paths.length > 1) {
70
+ findings.push({ sessionId, epicId, cwd, paths, classification: 'duplicated' });
71
+ } else if (paths.length === 1 && path.dirname(paths[0]) !== home) {
72
+ findings.push({ sessionId, epicId, cwd, paths, classification: 'mislocated' });
73
+ }
74
+ }
75
+ }
76
+ return findings;
77
+ }
78
+
79
+ module.exports = { scanEpicTranscripts };