claude-code-session-manager 0.75.2 → 0.76.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 (102) hide show
  1. package/dist/assets/{AgentLibrary-s1jRGOji.js → AgentLibrary-CBx9l4zN.js} +1 -1
  2. package/dist/assets/{DataModel-DNefcWbk.js → DataModel-Bf0EIE_t.js} +1 -1
  3. package/dist/assets/{History-4sMk80yI.js → History-CpdtWhC8.js} +1 -1
  4. package/dist/assets/{Hooks-CNUVwH5P.js → Hooks-DyUbMDmg.js} +1 -1
  5. package/dist/assets/{HostBilko-BhkyvIhw.js → HostBilko-By-wIpry.js} +1 -1
  6. package/dist/assets/{Library-BpockanA.js → Library-CQmo4QVC.js} +1 -1
  7. package/dist/assets/{ListDetail-Cre7h3U0.js → ListDetail-BQMd6NOm.js} +1 -1
  8. package/dist/assets/{MarkdownEditor-x-FzvaXm.js → MarkdownEditor-DEp43FXX.js} +1 -1
  9. package/dist/assets/{McpServers-CXAcQLMB.js → McpServers-CLarzwqA.js} +1 -1
  10. package/dist/assets/{Memory-CGBv1Rco.js → Memory-B0sCdIy1.js} +1 -1
  11. package/dist/assets/{Panel-czEs21N2.js → Panel-BhWPVOCD.js} +1 -1
  12. package/dist/assets/{Permissions-rGkLOpqI.js → Permissions-Ddlq8T_O.js} +1 -1
  13. package/dist/assets/{Plugins-BTmB_vgU.js → Plugins-D2oA_2Jl.js} +2 -2
  14. package/dist/assets/{ProvenanceBadge-dmCivDq5.js → ProvenanceBadge-DgAgavUM.js} +1 -1
  15. package/dist/assets/{SaveBar-D80TNkAx.js → SaveBar-Qvc4Ek-H.js} +1 -1
  16. package/dist/assets/{Scheduler-C6wHSZP7.js → Scheduler-BmYJvNzK.js} +1 -1
  17. package/dist/assets/{ScopeSwitcher-DsaiYTgl.js → ScopeSwitcher-C_zWEtIl.js} +1 -1
  18. package/dist/assets/{Settings-m2fyU8Q5.js → Settings-2Vx3X5SI.js} +1 -1
  19. package/dist/assets/{SkillReferenceGraph-yS-lgBFU.js → SkillReferenceGraph-BDEUjlTQ.js} +1 -1
  20. package/dist/assets/{Skills-DtQgCs9-.js → Skills-Cmrz_LeN.js} +1 -1
  21. package/dist/assets/{SystemPrompt-CLuVdoMA.js → SystemPrompt-DVA1eYDP.js} +1 -1
  22. package/dist/assets/{TagLibrary-DQXoI752.js → TagLibrary-DYJGAKZu.js} +1 -1
  23. package/dist/assets/{TiptapBody-BGKVXrvq.js → TiptapBody-DmPc3amD.js} +1 -1
  24. package/dist/assets/{Toggle-RFnEV71m.js → Toggle-zfd5LJkK.js} +1 -1
  25. package/dist/assets/{index-Ddrw7J7O.js → index-B_4PNh9T.js} +676 -676
  26. package/dist/assets/{index-CMLnzdZC.css → index-DIjnPkRN.css} +1 -1
  27. package/dist/assets/{settingsSchema-LwuJXKD1.js → settingsSchema-B9es6fdA.js} +1 -1
  28. package/dist/index.html +2 -2
  29. package/package.json +1 -1
  30. package/scripts/lib/activeSessions.cjs +146 -4
  31. package/scripts/scheduler-mcp-server.cjs +154 -95
  32. package/src/main/__tests__/epicStatusMirror.test.cjs +110 -0
  33. package/src/main/__tests__/health-delegation-chain.test.cjs +105 -0
  34. package/src/main/__tests__/prdAdminRoutes.test.cjs +295 -0
  35. package/src/main/__tests__/prdCreate.test.cjs +109 -0
  36. package/src/main/__tests__/scheduler-autofix-select.test.cjs +15 -3
  37. package/src/main/__tests__/scheduler-commit-guard-noop.test.cjs +41 -0
  38. package/src/main/__tests__/scheduler-reap-dead-running-jobs.test.cjs +60 -1
  39. package/src/main/__tests__/scheduler-stranded-investigation.test.cjs +185 -0
  40. package/src/main/__tests__/seedSchedulerMcp.test.cjs +66 -0
  41. package/src/main/__tests__/uniquePrdNumbers.test.cjs +14 -5
  42. package/src/main/bilkoHost.cjs +4 -3
  43. package/src/main/chatRunner.cjs +6 -1
  44. package/src/main/config.cjs +22 -33
  45. package/src/main/health.cjs +153 -2
  46. package/src/main/index.cjs +56 -4
  47. package/src/main/ipcSchemas.cjs +18 -1
  48. package/src/main/lib/__tests__/activeIndexRebuild.test.cjs +179 -0
  49. package/src/main/lib/__tests__/childWithLog.test.cjs +63 -0
  50. package/src/main/lib/__tests__/delegationReadiness.test.cjs +241 -42
  51. package/src/main/lib/__tests__/ephemeralCwd.test.cjs +91 -0
  52. package/src/main/lib/__tests__/epicWorktreeMint.test.cjs +1 -1
  53. package/src/main/lib/__tests__/gitWorktree.test.cjs +14 -2
  54. package/src/main/lib/__tests__/gitWorktreeSalvage.test.cjs +107 -0
  55. package/src/main/lib/__tests__/jobWorktree.test.cjs +2 -2
  56. package/src/main/lib/__tests__/loadGate.test.cjs +159 -0
  57. package/src/main/lib/__tests__/mcpToolCatalog.test.cjs +101 -0
  58. package/src/main/lib/__tests__/opsRootAbsoluteCwd.test.cjs +202 -0
  59. package/src/main/lib/__tests__/opsRootNestedWrite.test.cjs +51 -0
  60. package/src/main/lib/__tests__/opsRootResolve.test.cjs +149 -0
  61. package/src/main/lib/__tests__/projectRootResolve.test.cjs +148 -0
  62. package/src/main/lib/__tests__/reaperHelpers.test.cjs +112 -0
  63. package/src/main/lib/__tests__/schedulerBatchDepends.test.cjs +19 -9
  64. package/src/main/lib/__tests__/schedulerBatchFairness.test.cjs +213 -0
  65. package/src/main/lib/__tests__/schedulerBatchProjectCap.test.cjs +127 -0
  66. package/src/main/lib/__tests__/schedulerMcpServerHelp.test.cjs +217 -0
  67. package/src/main/lib/activeIndexMerge.cjs +15 -0
  68. package/src/main/lib/activeIndexRebuild.cjs +133 -0
  69. package/src/main/lib/buildTarget.cjs +3 -2
  70. package/src/main/lib/childWithLog.cjs +33 -1
  71. package/src/main/lib/crossProjectFeedback.cjs +8 -1
  72. package/src/main/lib/delegationReadiness.cjs +408 -26
  73. package/src/main/lib/ephemeralCwd.cjs +78 -0
  74. package/src/main/lib/epicDelegationStats.cjs +2 -1
  75. package/src/main/lib/epicMint.cjs +17 -1
  76. package/src/main/lib/epicStatusMirror.cjs +95 -0
  77. package/src/main/lib/epicValidationHook.cjs +2 -1
  78. package/src/main/lib/gitWorktree.cjs +56 -2
  79. package/src/main/lib/jobWorktree.cjs +1 -0
  80. package/src/main/lib/loadGate.cjs +134 -0
  81. package/src/main/lib/mcpToolCatalog.cjs +285 -0
  82. package/src/main/lib/opsErrorLog.cjs +12 -1
  83. package/src/main/lib/opsOwnership.cjs +94 -0
  84. package/src/main/lib/prdAdminRoutes.cjs +43 -3
  85. package/src/main/lib/prdCreate.cjs +46 -14
  86. package/src/main/lib/prdLocations.cjs +13 -6
  87. package/src/main/lib/projectRootResolve.cjs +134 -0
  88. package/src/main/lib/promptSessionSchema.cjs +7 -0
  89. package/src/main/lib/queueStore.cjs +70 -8
  90. package/src/main/lib/rcaReport.cjs +1 -1
  91. package/src/main/lib/reaperHelpers.cjs +47 -1
  92. package/src/main/lib/schedulerBatch.cjs +171 -29
  93. package/src/main/lib/schedulerConfig.cjs +80 -0
  94. package/src/main/projectBrief.cjs +3 -2
  95. package/src/main/projectPages.cjs +2 -1
  96. package/src/main/promptSessionTranscript.cjs +0 -0
  97. package/src/main/pty.cjs +5 -0
  98. package/src/main/queueOps.cjs +15 -8
  99. package/src/main/scheduler.cjs +346 -49
  100. package/src/main/seedSchedulerMcp.cjs +58 -4
  101. package/src/preload/api.d.ts +69 -1
  102. package/src/preload/index.cjs +2 -0
@@ -9,13 +9,16 @@ const fsp = require('node:fs/promises');
9
9
  const path = require('node:path');
10
10
  const os = require('node:os');
11
11
  const { execFileSync } = require('node:child_process');
12
- const { POLL_INTERVAL_MS } = require('./lib/schedulerConfig.cjs');
12
+ const { POLL_INTERVAL_MS, loadGateThreshold } = require('./lib/schedulerConfig.cjs');
13
+ const { opsPath } = require('./lib/opsOwnership.cjs');
13
14
  const { checkPersonaImports } = require('./lib/personaImportHealth.cjs');
15
+ const { checkDelegationReadiness } = require('./lib/delegationReadiness.cjs');
14
16
  const { resolvePrdsDirs } = require('./lib/prdLocations.cjs');
15
17
  const { migratePrds } = require('./lib/prdMigration.cjs');
16
18
  const queueStore = require('./lib/queueStore.cjs');
17
19
  const { computeStallSummary } = require('./scheduler.cjs');
18
20
  const { DEFAULT_RUNS_DIR, computeReport, isRetentionEnabled, liveKeysFromJobs } = require('./lib/runLogRetention.cjs');
21
+ const { allProjectCwds } = require('../../scripts/lib/activeSessions.cjs');
19
22
 
20
23
  const MAX_LOG_AGE_MS = 5 * 60_000; // 5 min — warn if no logs this old
21
24
  const PROJECT_ROOT = path.resolve(__dirname, '../..');
@@ -225,6 +228,113 @@ function evaluatePrdMigrationHealth(migrationResult, legacyPrdsDir) {
225
228
  };
226
229
  }
227
230
 
231
+ // Pure shaping of checkDelegationReadiness()'s result (delegationReadiness.cjs)
232
+ // into a health component + issue lines — kept separate from the fs-touching
233
+ // check() call site so it's directly unit-testable, matching
234
+ // evaluatePrdMigrationHealth's pattern. delegationReadiness.cjs already wraps
235
+ // every filesystem read (readJsonSafe / try-catch), so a missing/unreadable
236
+ // config surfaces here as one of `checks` with ok:false, never a throw.
237
+ function evaluateDelegationChainHealth(delegationResult) {
238
+ const component = { ok: delegationResult.ok, checks: delegationResult.checks };
239
+ const issues = delegationResult.checks
240
+ .filter((c) => !c.ok)
241
+ .map((c) => `Delegation readiness: ${c.label} failed — ${c.detail}${c.fix ? ` (fix: ${c.fix})` : ''}`);
242
+ return { component, issues };
243
+ }
244
+
245
+ // computeEpicIndexDrift(cwd) → { orphan_rows, orphan_files, unmirrored,
246
+ // orphanRowIds, orphanFileIds, unmirroredFiles }
247
+ //
248
+ // One project's drift between active-index.json (the index) and prompt-
249
+ // sessions/<id>.json (the status mirror epicStatusMirror.cjs writes on every
250
+ // status-changing write path). Pure filesystem read, never throws — an
251
+ // unreadable index or prompt-sessions dir degrades to empty rather than
252
+ // aborting the whole health check (matches evaluatePrdMigrationHealth's
253
+ // fail-open-on-read spirit).
254
+ function computeEpicIndexDrift(cwd) {
255
+ let dir;
256
+ try {
257
+ dir = opsPath(cwd, 'prompt-sessions');
258
+ } catch {
259
+ return { orphan_rows: 0, orphan_files: 0, unmirrored: 0 }; // unusable cwd degrades to empty, same as an unreadable index below
260
+ }
261
+ const indexPath = path.join(dir, 'active-index.json');
262
+ let sessions = {};
263
+ let tombstones = {};
264
+ try {
265
+ const parsed = JSON.parse(fs.readFileSync(indexPath, 'utf8'));
266
+ sessions = parsed && typeof parsed.sessions === 'object' && parsed.sessions ? parsed.sessions : {};
267
+ tombstones = parsed && typeof parsed.tombstones === 'object' && parsed.tombstones ? parsed.tombstones : {};
268
+ } catch { /* missing/unreadable index — treat as empty, not a throw */ }
269
+
270
+ let files = [];
271
+ try {
272
+ files = fs.readdirSync(dir).filter((f) => f.endsWith('.json') && f !== 'active-index.json');
273
+ } catch { /* prompt-sessions/ doesn't exist yet — nothing to compare */ }
274
+
275
+ const fileIds = new Set(files.map((f) => f.slice(0, -'.json'.length)));
276
+ const orphanRowIds = Object.keys(sessions).filter((id) => !fileIds.has(id));
277
+
278
+ const orphanFileIds = [];
279
+ const unmirroredFiles = [];
280
+ for (const file of files) {
281
+ const id = file.slice(0, -'.json'.length);
282
+ let parsed;
283
+ try {
284
+ parsed = JSON.parse(fs.readFileSync(path.join(dir, file), 'utf8'));
285
+ } catch {
286
+ continue; // unreadable file — neither orphan nor unmirrored classification applies
287
+ }
288
+ if (!parsed || typeof parsed !== 'object' || !parsed.status) {
289
+ unmirroredFiles.push(file);
290
+ continue;
291
+ }
292
+ const looksLive = (parsed.status === 'proposed' || parsed.status === 'active') && !parsed.archivedAt;
293
+ const hasRow = Object.prototype.hasOwnProperty.call(sessions, id);
294
+ const isTombstoned = Object.prototype.hasOwnProperty.call(tombstones, id);
295
+ if (looksLive && !hasRow && !isTombstoned) orphanFileIds.push(file);
296
+ }
297
+
298
+ return {
299
+ orphan_rows: orphanRowIds.length,
300
+ orphan_files: orphanFileIds.length,
301
+ unmirrored: unmirroredFiles.length,
302
+ orphanRowIds,
303
+ orphanFileIds,
304
+ unmirroredFiles,
305
+ };
306
+ }
307
+
308
+ // evaluateEpicIndexHealth(cwds) → { component, issues }
309
+ //
310
+ // Shapes computeEpicIndexDrift's per-project result into a health component +
311
+ // issue lines, matching evaluateDelegationChainHealth's pattern. Only
312
+ // orphan_rows (an index row whose Epic file is gone — the actual data-loss
313
+ // signal) is an issue; orphan_files and unmirrored are informational drift,
314
+ // not failures — a fresh install or an Epic mid-migration legitimately has
315
+ // unmirrored files.
316
+ function evaluateEpicIndexHealth(cwds) {
317
+ const byProject = {};
318
+ const issues = [];
319
+ let anyOrphanRows = false;
320
+ for (const cwd of cwds) {
321
+ const drift = computeEpicIndexDrift(cwd);
322
+ byProject[cwd] = {
323
+ orphan_rows: drift.orphan_rows,
324
+ orphan_files: drift.orphan_files,
325
+ unmirrored: drift.unmirrored,
326
+ };
327
+ if (drift.orphan_rows > 0) {
328
+ anyOrphanRows = true;
329
+ issues.push(
330
+ `Epic index drift: ${cwd} has ${drift.orphan_rows} active-index.json row(s) with no matching `
331
+ + `prompt-sessions/<id>.json file (${drift.orphanRowIds.join(', ')})`
332
+ );
333
+ }
334
+ }
335
+ return { component: { ok: !anyOrphanRows, byProject }, issues };
336
+ }
337
+
228
338
  async function check() {
229
339
  const start = Date.now();
230
340
  const status = {
@@ -340,6 +450,15 @@ async function check() {
340
450
  byProject: computeProjectProblemCounts(queueState.jobs),
341
451
  perProjectStall,
342
452
  tickLiveness: liveness.reason,
453
+ // Informational only (PRD 1085): current 1-min loadavg per core vs the
454
+ // launch-gate threshold, so a "nothing is launching" report can be
455
+ // read next to the reason without opening the app.
456
+ loadRatio: (() => {
457
+ const cores = (os.cpus() || []).length;
458
+ const l1 = os.loadavg()[0];
459
+ return cores > 0 && Number.isFinite(l1) ? Number((l1 / cores).toFixed(3)) : null;
460
+ })(),
461
+ loadGateThreshold: loadGateThreshold(),
343
462
  };
344
463
  if (liveness.stalled) {
345
464
  const ageMin = Math.round(liveness.tickAgeMs / 60_000);
@@ -505,6 +624,22 @@ async function check() {
505
624
  }
506
625
  }
507
626
 
627
+ // 6.55. Delegation-chain readiness — answers "can this machine actually
628
+ // delegate work to the scheduler?" (see delegationReadiness.cjs's header:
629
+ // when the scheduler MCP isn't registered, scheduler_create_prd is simply
630
+ // absent from the agent's tool list — no error to catch, so an agent asked
631
+ // to delegate just implements inline instead). Critical: a failing check
632
+ // here means Epics silently stop queueing PRDs on this machine.
633
+ try {
634
+ const delegation = await checkDelegationReadiness({ cwd: PROJECT_ROOT });
635
+ const { component, issues: delegationIssues } = evaluateDelegationChainHealth(delegation);
636
+ status.components.delegation_chain = component;
637
+ status.issues.push(...delegationIssues);
638
+ } catch (e) {
639
+ status.components.delegation_chain = { ok: false, error: e.message };
640
+ status.issues.push(`Delegation readiness check failed: ${e.message}`);
641
+ }
642
+
508
643
  // 6.6. Run-log retention report (informational only — never blocks health).
509
644
  // Read-only against the REAL scheduled-plans/runs/ dir: reports current
510
645
  // usage + what the configured policy (if any) would remove. Nothing is
@@ -536,11 +671,24 @@ async function check() {
536
671
  status.components.run_log_retention = { ok: true, error: e.message };
537
672
  }
538
673
 
674
+ // 6.65. Epic index drift — orphan_rows/orphan_files/unmirrored across every
675
+ // project this machine has ever opened (allProjectCwds, no recency filter
676
+ // — a quiet project still owns its Epics). See computeEpicIndexDrift's
677
+ // header.
678
+ try {
679
+ const { component, issues: epicIndexIssues } = evaluateEpicIndexHealth(allProjectCwds());
680
+ status.components.epic_index = component;
681
+ status.issues.push(...epicIndexIssues);
682
+ } catch (e) {
683
+ status.components.epic_index = { ok: false, error: e.message };
684
+ status.issues.push(`Epic index health check failed: ${e.message}`);
685
+ }
686
+
539
687
  // 7. Summary scoring: ok if all critical components pass.
540
688
  // Critical: nodejs, config dir, typescript, build artifact, test infrastructure.
541
689
  // Non-fatal: scheduler/transcripts dirs may not exist on fresh install.
542
690
  // Informational: app log age (shows if app is running, but not blocking).
543
- const criticalComponents = ['nodejs', 'config_dir', 'typescript', 'build_artifact', 'test_infrastructure', 'scheduler_queue', 'prd_migration', 'claude_md_budget'];
691
+ const criticalComponents = ['nodejs', 'config_dir', 'typescript', 'build_artifact', 'test_infrastructure', 'scheduler_queue', 'prd_migration', 'claude_md_budget', 'delegation_chain'];
544
692
  status.ok = criticalComponents.every((c) => status.components[c]?.ok !== false);
545
693
 
546
694
  status.elapsedMs = Date.now() - start;
@@ -561,6 +709,9 @@ module.exports = {
561
709
  evaluateTickLiveness,
562
710
  readFreshHeartbeat,
563
711
  evaluatePrdMigrationHealth,
712
+ evaluateDelegationChainHealth,
713
+ computeEpicIndexDrift,
714
+ evaluateEpicIndexHealth,
564
715
  computeProjectProblemCounts,
565
716
  evaluatePerProjectStall,
566
717
  parseClaudeMdBudget,
@@ -36,7 +36,8 @@ const epicWorktreeMint = require('./lib/epicWorktreeMint.cjs');
36
36
  const epicWorktreeMerge = require('./lib/epicWorktreeMerge.cjs');
37
37
  const epicWorktreeProjectConfig = require('./lib/epicWorktreeProjectConfig.cjs');
38
38
  const agentLibrary = require('./agentLibrary.cjs');
39
- const { checkDelegationReadiness } = require('./lib/delegationReadiness.cjs');
39
+ const { checkDelegationReadiness, installPrdWriteGuard } = require('./lib/delegationReadiness.cjs');
40
+ const { MCP_TOOL_CATALOG, MCP_RECIPES } = require('./lib/mcpToolCatalog.cjs');
40
41
  const { sendIfAlive } = require('./lib/sendToRenderer.cjs');
41
42
  const { resolveBuildTarget } = require('./lib/buildTarget.cjs');
42
43
  const crossProjectFeedback = require('./lib/crossProjectFeedback.cjs');
@@ -81,6 +82,9 @@ const { listExchanges } = require('./exchanges.cjs');
81
82
  const { resolveClaudeBin } = require('./lib/claudeBin.cjs');
82
83
  const { checkInsideHome, assertInsideHome } = require('./lib/insideHome.cjs');
83
84
  const { openInEditor, openFileInEditor, openInFinder, openInTerminal } = require('./lib/openExternalApp.cjs');
85
+ const { rebuildActiveIndex } = require('./lib/activeIndexRebuild.cjs');
86
+ const { activeIndexPath: promptSessionsActiveIndexPath } = require('./lib/epicMint.cjs');
87
+ const { allProjectCwds } = require('../../scripts/lib/activeSessions.cjs');
84
88
 
85
89
  let mainWindow = null;
86
90
  let rebooting = false;
@@ -456,6 +460,11 @@ ipcMain.handle('app:launch-mode', () => ({
456
460
  // `claude mcp list`. Read-only, single in-flight call — no polling.
457
461
  ipcMain.handle('mcp:status', () => probeMcpStatus());
458
462
 
463
+ // Project Home's "Agent tools" block (PhAgentTools): the same catalog data
464
+ // GET /admin/mcp/catalog serves to external callers, read in-process here so
465
+ // the renderer never makes a direct HTTP call. Static data, no cwd needed.
466
+ ipcMain.handle('mcp:catalog', () => ({ ok: true, tools: MCP_TOOL_CATALOG, recipes: MCP_RECIPES }));
467
+
459
468
  // Agent Library nav page (Home face only): global `~/.claude/agents/*.md`
460
469
  // personas plus, per currently-open project tab, whether that project
461
470
  // overlays the same agent name at `<cwd>/.claude/agents/<name>.md`. Read-only.
@@ -494,12 +503,19 @@ ipcMain.handle('agents:get-persona-body', validated(schemas.agentsGetPersonaBody
494
503
  agentLibrary.getPersonaBody(payload)));
495
504
 
496
505
  // "Can this project actually delegate?" probe (PRD: delegation-readiness).
497
- // Structured data over the four preconditions for scheduler_create_prd being
498
- // in an agent's tool list at all — surfacing it in the UI is a sibling PRD.
499
- ipcMain.handle('app:delegation-readiness', validated(schemas.delegationReadinessCwd, (payload) =>
506
+ // Structured data over the preconditions for scheduler_create_prd being in an
507
+ // agent's tool list at all — surfacing it in the UI is a sibling PRD.
508
+ ipcMain.handle('app:delegation-readiness', validated(schemas.delegationReadinessCwd, async (payload) =>
500
509
  checkDelegationReadiness(payload)
501
510
  ));
502
511
 
512
+ // The one readiness check with a sanctioned one-press install. Session Manager
513
+ // standardizes on the REFERENCE approach (absolute path to this repo's guard
514
+ // script, never a vendored copy) — see installPrdWriteGuard's header.
515
+ ipcMain.handle('app:install-prd-write-guard', validated(schemas.delegationReadinessCwd, (payload) =>
516
+ installPrdWriteGuard(payload)
517
+ ));
518
+
503
519
  ipcMain.handle('app:engage-rules-path', () => process.env.SESSION_MANAGER_ENGAGE_RULES || null);
504
520
 
505
521
  // Boot diagnostics — renderer polls these to surface toasts when `claude` isn't
@@ -1193,6 +1209,42 @@ app.whenReady().then(async () => {
1193
1209
  seedSchedulerMcp({ logger: console, writeLog: logs.writeLine }).catch((e) => {
1194
1210
  logs.writeLine({ scope: 'seed-scheduler-mcp', level: 'error', message: 'seed failed', meta: { error: e?.message } });
1195
1211
  });
1212
+ // Epic index self-heal: a project whose active-index.json is MISSING or
1213
+ // fails to parse has lost every open Epic's status unless the per-Epic
1214
+ // status mirror (epicStatusMirror.cjs) can rebuild it. Runs once per boot,
1215
+ // fire-and-forget, and only touches a project whose index reads unclean —
1216
+ // a clean index is never rewritten here. See activeIndexRebuild.cjs.
1217
+ try {
1218
+ for (const projectCwd of allProjectCwds()) {
1219
+ const indexPath = promptSessionsActiveIndexPath(projectCwd);
1220
+ let unclean = false;
1221
+ let parseError = null;
1222
+ if (!fs.existsSync(indexPath)) {
1223
+ unclean = true;
1224
+ } else {
1225
+ try {
1226
+ JSON.parse(fs.readFileSync(indexPath, 'utf8'));
1227
+ } catch (e) {
1228
+ unclean = true;
1229
+ parseError = e.message;
1230
+ }
1231
+ }
1232
+ if (!unclean) continue;
1233
+ try {
1234
+ const { rows, skipped } = rebuildActiveIndex(projectCwd);
1235
+ logs.writeLine({
1236
+ scope: 'active-index-rebuild',
1237
+ level: 'info',
1238
+ message: 'rebuilt active-index.json from status mirrors',
1239
+ meta: { cwd: projectCwd, parseError, restored: rows.length, skipped: skipped.length },
1240
+ });
1241
+ } catch (e) {
1242
+ logs.writeLine({ scope: 'active-index-rebuild', level: 'error', message: 'rebuild failed', meta: { cwd: projectCwd, error: e?.message } });
1243
+ }
1244
+ }
1245
+ } catch (e) {
1246
+ logs.writeLine({ scope: 'active-index-rebuild', level: 'error', message: 'boot sweep failed', meta: { error: e?.message } });
1247
+ }
1196
1248
  // History rollup finalize pass: deferred 30s past boot so it never competes
1197
1249
  // with first-paint, fire-and-forget (cron/offline refresh is PRD 651 — this
1198
1250
  // is just the one-shot in-app pass).
@@ -301,7 +301,12 @@ const PRD_CREATE_SLUG_RE = /^[a-z0-9]+(-[a-z0-9]+)*$/;
301
301
  const NO_NEWLINE_RE = /^[^\r\n]*$/;
302
302
  const schedulerCreatePrd = z.object({
303
303
  title: z.string().min(1).max(200).regex(NO_NEWLINE_RE, 'must not contain newlines'),
304
- cwd: z.string().min(1).max(4096).regex(NO_NEWLINE_RE, 'must not contain newlines'),
304
+ // Optional (PRD: worktree-cwd Epic-lookup hazard) — when omitted, prdCreate.cjs's
305
+ // createPrd resolves the real project cwd server-side via
306
+ // projectRootResolve.cjs's resolveProjectContext, using originClaudeSessionId/
307
+ // sourcePromptId/originProjectRoot. A call with none of those still fails with
308
+ // a clear "cwd is required" error.
309
+ cwd: z.string().min(1).max(4096).regex(NO_NEWLINE_RE, 'must not contain newlines').optional(),
305
310
  estimateMinutes: z.number().int().min(1).max(100000),
306
311
  goal: z.string().min(1).max(20000),
307
312
  acceptanceCriteria: z.array(z.string().min(1).max(2000)).min(1).max(100),
@@ -333,6 +338,13 @@ const schedulerCreatePrd = z.object({
333
338
  // within an Epic's own chat session joins that Epic even if the tool call
334
339
  // forgot to pass sourcePromptId explicitly.
335
340
  originClaudeSessionId: z.string().min(1).max(128).regex(NO_NEWLINE_RE, 'must not contain newlines').optional(),
341
+ // Trusted project-root hint (SM_PROJECT_ROOT env, stamped by chatRunner.cjs/
342
+ // pty.cjs/scheduler.cjs's job spawn onto the child that ultimately calls this
343
+ // route, forwarded by scheduler-mcp-server.cjs as originProjectRoot) — never
344
+ // written to frontmatter; projectRootResolve.cjs's resolveProjectContext
345
+ // treats it as a higher-priority hint than the caller-supplied cwd, but
346
+ // still loses to a resolved Epic's own project cwd.
347
+ originProjectRoot: z.string().min(1).max(4096).regex(NO_NEWLINE_RE, 'must not contain newlines').optional(),
336
348
  // User-selected work-type tag (PRD 774; independence from the parent
337
349
  // Epic's own tag confirmed PRD 1041) — deterministic, never LLM-classified.
338
350
  // Epic tag and PRD tag draw on the same WorkType vocabulary
@@ -343,8 +355,12 @@ const schedulerCreatePrd = z.object({
343
355
  });
344
356
 
345
357
  // Bulk archive: slug list, capped to limit unbounded retag/archive payloads.
358
+ // Optional cwd narrows every slug's directory search to one project (same
359
+ // role as scheduler_get_prd/scheduler_update_prd's cwd hint) — omitted, the
360
+ // search still spans every candidate PRD dir machine-wide.
346
361
  const scheduleArchivePrd = z.object({
347
362
  slugs: z.array(z.string().regex(SCHEDULE_SLUG_RE)).min(1).max(500),
363
+ cwd: z.string().min(1).max(4096).optional(),
348
364
  });
349
365
 
350
366
  const scheduleRetagItem = z.object({
@@ -425,6 +441,7 @@ const adminUpdatePrd = z.object({
425
441
 
426
442
  const adminCancelJob = z.object({
427
443
  slug: z.string().regex(SCHEDULE_SLUG_RE),
444
+ cwd: z.string().min(1).max(4096).optional(),
428
445
  });
429
446
 
430
447
  // ──────────────────────────────────────────── Projects
@@ -0,0 +1,179 @@
1
+ /**
2
+ * activeIndexRebuild.test.cjs — rebuildActiveIndex reconstructs
3
+ * active-index.json's `sessions` map from the per-Epic status mirrors
4
+ * epicStatusMirror.cjs writes, honoring tombstones and archivedAt exactly
5
+ * the way the domain model requires (never resurrecting either).
6
+ *
7
+ * Run: timeout 120 npx vitest run src/main/lib/__tests__/activeIndexRebuild.test.cjs
8
+ */
9
+
10
+ 'use strict';
11
+
12
+ import { test, expect, afterEach } from 'vitest';
13
+ const fs = require('node:fs');
14
+ const fsp = require('node:fs/promises');
15
+ const os = require('node:os');
16
+ const path = require('node:path');
17
+ const { rebuildActiveIndex } = require('../activeIndexRebuild.cjs');
18
+ const { activeIndexPath, readActiveIndex } = require('../epicMint.cjs');
19
+
20
+ const tmpDirs = [];
21
+ afterEach(async () => {
22
+ while (tmpDirs.length) {
23
+ const d = tmpDirs.pop();
24
+ await fsp.rm(d, { recursive: true, force: true });
25
+ }
26
+ });
27
+
28
+ async function mkCwd() {
29
+ const cwd = await fsp.mkdtemp(path.join(os.tmpdir(), 'sm-rebuild-cwd-'));
30
+ tmpDirs.push(cwd);
31
+ return cwd;
32
+ }
33
+
34
+ function promptSessionsDir(cwd) {
35
+ return path.join(cwd, 'session-manager-operations', 'prompt-sessions');
36
+ }
37
+
38
+ function writeMirrorFile(cwd, id, data) {
39
+ const dir = promptSessionsDir(cwd);
40
+ fs.mkdirSync(dir, { recursive: true });
41
+ fs.writeFileSync(path.join(dir, `${id}.json`), JSON.stringify(data, null, 2));
42
+ }
43
+
44
+ function writeIndex(cwd, index) {
45
+ const dir = promptSessionsDir(cwd);
46
+ fs.mkdirSync(dir, { recursive: true });
47
+ fs.writeFileSync(path.join(dir, 'active-index.json'), JSON.stringify(index, null, 2));
48
+ }
49
+
50
+ function liveMirror(id, cwd, overrides = {}) {
51
+ return {
52
+ id,
53
+ cwd,
54
+ goalText: `goal for ${id}`,
55
+ claudeSessionId: `sess-${id}`,
56
+ status: 'proposed',
57
+ createdAt: '2026-01-01T00:00:00.000Z',
58
+ completedAt: null,
59
+ archivedAt: null,
60
+ indexedAt: '2026-01-01T00:00:01.000Z',
61
+ ...overrides,
62
+ };
63
+ }
64
+
65
+ test('restores a row for every mirrored file with a live status', async () => {
66
+ const cwd = await mkCwd();
67
+ writeMirrorFile(cwd, 'e-1', liveMirror('e-1', cwd, { status: 'proposed' }));
68
+ writeMirrorFile(cwd, 'e-2', liveMirror('e-2', cwd, { status: 'active' }));
69
+
70
+ const { rows, skipped, reasons } = await rebuildActiveIndex(cwd);
71
+
72
+ expect(rows.map((r) => r.id).sort()).toEqual(['e-1', 'e-2']);
73
+ expect(skipped).toEqual([]);
74
+ expect(reasons).toEqual({});
75
+
76
+ const onDisk = readActiveIndex(cwd);
77
+ expect(onDisk.sessions['e-1'].status).toBe('proposed');
78
+ expect(onDisk.sessions['e-2'].status).toBe('active');
79
+ });
80
+
81
+ test('respects tombstones — a tombstoned id is never resurrected even if its file says active', async () => {
82
+ const cwd = await mkCwd();
83
+ writeIndex(cwd, { sessions: {}, events: {}, tombstones: { 'e-dead': '2026-01-02T00:00:00.000Z' } });
84
+ writeMirrorFile(cwd, 'e-dead', liveMirror('e-dead', cwd, { status: 'active' }));
85
+ writeMirrorFile(cwd, 'e-alive', liveMirror('e-alive', cwd, { status: 'proposed' }));
86
+
87
+ const { rows, skipped, reasons } = await rebuildActiveIndex(cwd);
88
+
89
+ expect(rows.map((r) => r.id)).toEqual(['e-alive']);
90
+ expect(skipped).toContain('e-dead.json');
91
+ expect(reasons['e-dead.json']).toMatch(/tombstoned/);
92
+
93
+ const onDisk = readActiveIndex(cwd);
94
+ expect(onDisk.sessions['e-dead']).toBeUndefined();
95
+ expect(onDisk.tombstones['e-dead']).toBe('2026-01-02T00:00:00.000Z');
96
+ });
97
+
98
+ test('respects archivedAt — a completed Epic file is never restored as a live row', async () => {
99
+ const cwd = await mkCwd();
100
+ writeMirrorFile(cwd, 'e-done', {
101
+ ...liveMirror('e-done', cwd, { status: 'completed' }),
102
+ archivedAt: '2026-01-03T00:00:00.000Z',
103
+ });
104
+ writeMirrorFile(cwd, 'e-open', liveMirror('e-open', cwd, { status: 'active' }));
105
+
106
+ const { rows, skipped, reasons } = await rebuildActiveIndex(cwd);
107
+
108
+ expect(rows.map((r) => r.id)).toEqual(['e-open']);
109
+ expect(skipped).toContain('e-done.json');
110
+ expect(reasons['e-done.json']).toMatch(/archivedAt/);
111
+ });
112
+
113
+ test('is idempotent — running it twice on a clean project yields a byte-identical index', async () => {
114
+ const cwd = await mkCwd();
115
+ writeMirrorFile(cwd, 'e-1', liveMirror('e-1', cwd));
116
+ writeMirrorFile(cwd, 'e-2', liveMirror('e-2', cwd, { status: 'active' }));
117
+
118
+ await rebuildActiveIndex(cwd);
119
+ const first = fs.readFileSync(activeIndexPath(cwd), 'utf8');
120
+
121
+ await rebuildActiveIndex(cwd);
122
+ const second = fs.readFileSync(activeIndexPath(cwd), 'utf8');
123
+
124
+ expect(second).toBe(first);
125
+ });
126
+
127
+ test('skips an unreadable Epic file, naming it in reasons, without aborting the rebuild', async () => {
128
+ const cwd = await mkCwd();
129
+ const dir = promptSessionsDir(cwd);
130
+ fs.mkdirSync(dir, { recursive: true });
131
+ fs.writeFileSync(path.join(dir, 'e-corrupt.json'), '{ this is not json');
132
+ writeMirrorFile(cwd, 'e-good', liveMirror('e-good', cwd));
133
+
134
+ const { rows, skipped, reasons } = await rebuildActiveIndex(cwd);
135
+
136
+ expect(rows.map((r) => r.id)).toEqual(['e-good']);
137
+ expect(skipped).toContain('e-corrupt.json');
138
+ expect(reasons['e-corrupt.json']).toMatch(/unreadable/);
139
+ });
140
+
141
+ test('a file with no mirrored status is skipped and listed with a reason, never guessed at', async () => {
142
+ const cwd = await mkCwd();
143
+ const dir = promptSessionsDir(cwd);
144
+ fs.mkdirSync(dir, { recursive: true });
145
+ fs.writeFileSync(path.join(dir, 'e-legacy.json'), JSON.stringify({ id: 'e-legacy', cwd }));
146
+
147
+ const { rows, skipped, reasons } = await rebuildActiveIndex(cwd);
148
+
149
+ expect(rows).toEqual([]);
150
+ expect(skipped).toContain('e-legacy.json');
151
+ expect(reasons['e-legacy.json']).toMatch(/no status mirror/);
152
+ });
153
+
154
+ test('preserves the existing events and tombstones maps untouched', async () => {
155
+ const cwd = await mkCwd();
156
+ const events = { 'e-1': [{ id: 'evt-1', promptSessionId: 'e-1', kind: 'prompt', causedByEventId: null, at: '2026-01-01T00:00:00.000Z', text: 'go' }] };
157
+ const tombstones = { 'e-old': '2026-01-01T00:00:00.000Z' };
158
+ writeIndex(cwd, { sessions: {}, events, tombstones });
159
+ writeMirrorFile(cwd, 'e-1', liveMirror('e-1', cwd));
160
+
161
+ await rebuildActiveIndex(cwd);
162
+
163
+ const onDisk = readActiveIndex(cwd);
164
+ expect(onDisk.events).toEqual(events);
165
+ expect(onDisk.tombstones).toEqual(tombstones);
166
+ });
167
+
168
+ test('dryRun previews without writing anything to disk', async () => {
169
+ const cwd = await mkCwd();
170
+ writeIndex(cwd, { sessions: {}, events: {}, tombstones: {} });
171
+ writeMirrorFile(cwd, 'e-1', liveMirror('e-1', cwd));
172
+
173
+ const before = fs.readFileSync(activeIndexPath(cwd), 'utf8');
174
+ const { rows } = await rebuildActiveIndex(cwd, { dryRun: true });
175
+ const after = fs.readFileSync(activeIndexPath(cwd), 'utf8');
176
+
177
+ expect(rows.map((r) => r.id)).toEqual(['e-1']);
178
+ expect(after).toBe(before);
179
+ });
@@ -0,0 +1,63 @@
1
+ // PRD 1065 — post-exit process-group sweep. A detached job's descendants must
2
+ // not outlive the job (2026-08-31 starry-night-ships incident: orphaned test
3
+ // batteries reparented to init after their job exited normally).
4
+ import { describe, it, expect, afterEach } from 'vitest';
5
+ import fs from 'node:fs';
6
+ import os from 'node:os';
7
+ import path from 'node:path';
8
+
9
+ const { openLog, withChildAndLog, POST_EXIT_GROUP_SWEEP_GRACE_MS } = require('../childWithLog.cjs');
10
+
11
+ let dir;
12
+
13
+ afterEach(() => {
14
+ if (dir) fs.rmSync(dir, { recursive: true, force: true });
15
+ dir = undefined;
16
+ });
17
+
18
+ describe('childWithLog post-exit group sweep (PRD 1065)', () => {
19
+ it.skipIf(process.platform !== 'linux')(
20
+ 'kills a detached child\'s backgrounded grandchild after exit + grace',
21
+ async () => {
22
+ dir = fs.mkdtempSync(path.join(os.tmpdir(), 'sm-child-sweep-'));
23
+ const logPath = path.join(dir, 'job.log');
24
+ const pidFile = path.join(dir, 'grandchild.pid');
25
+ const { fd, safeLog, closeFd } = openLog(logPath);
26
+
27
+ const exited = new Promise((resolve) => {
28
+ withChildAndLog({
29
+ fd,
30
+ logPath,
31
+ safeLog,
32
+ closeFd,
33
+ spawn: {
34
+ command: 'sh',
35
+ args: [
36
+ '-c',
37
+ `sleep 30 & echo $! > ${pidFile}; exec sleep 0.2`,
38
+ ],
39
+ options: { detached: true },
40
+ },
41
+ onExit: () => resolve(),
42
+ });
43
+ });
44
+
45
+ await exited;
46
+
47
+ // Grandchild pid file is written by a backgrounded subshell that may
48
+ // race the parent's own exit; give it a moment to land.
49
+ for (let i = 0; i < 20 && !fs.existsSync(pidFile); i++) {
50
+ await new Promise((r) => setTimeout(r, 50));
51
+ }
52
+ expect(fs.existsSync(pidFile)).toBe(true);
53
+ const grandchildPid = Number(fs.readFileSync(pidFile, 'utf8').trim());
54
+ expect(Number.isInteger(grandchildPid) && grandchildPid > 1).toBe(true);
55
+
56
+ // Wait past the SIGTERM->SIGKILL grace window.
57
+ await new Promise((r) => setTimeout(r, POST_EXIT_GROUP_SWEEP_GRACE_MS + 1000));
58
+
59
+ expect(() => process.kill(grandchildPid, 0)).toThrow();
60
+ },
61
+ 15000,
62
+ );
63
+ });