claude-code-session-manager 0.86.0 → 0.87.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 (128) hide show
  1. package/dist/assets/AgentLibrary-DyLWzZDf.js +3 -0
  2. package/dist/assets/{DataModel-Q4jhl24R.js → DataModel--mISIJ6h.js} +1 -1
  3. package/dist/assets/{History-Cj2FejEo.js → History-C2ahUXTg.js} +2 -2
  4. package/dist/assets/{Hooks-CaelQI6t.js → Hooks-BiC6oyR2.js} +3 -3
  5. package/dist/assets/{HostBilko--v7cMR8I.js → HostBilko-BPleEOld.js} +1 -1
  6. package/dist/assets/{Library-DgI9oCCZ.js → Library-Dc8Qst1R.js} +1 -1
  7. package/dist/assets/{ListDetail-DYUZN-x-.js → ListDetail-DIXh-OLX.js} +1 -1
  8. package/dist/assets/MarkdownEditor-C90bkLXK.js +1 -0
  9. package/dist/assets/{McpServers-ypCYURh3.js → McpServers-DqcbLOLZ.js} +2 -2
  10. package/dist/assets/{Memory-C2qYp-3M.js → Memory-CW62MXlh.js} +4 -4
  11. package/dist/assets/{Panel-Cj2kw-Zv.js → Panel-Bw1FhRuF.js} +1 -1
  12. package/dist/assets/Permissions-BcUC-5y8.js +3 -0
  13. package/dist/assets/{Plugins-C1Vj8_dU.js → Plugins-BnKx9flD.js} +2 -2
  14. package/dist/assets/{ProvenanceBadge-DczPNM5U.js → ProvenanceBadge-Bw5vNVPT.js} +1 -1
  15. package/dist/assets/{SaveBar-Cd_7U6Gb.js → SaveBar-CWr0O_w-.js} +1 -1
  16. package/dist/assets/Scheduler-DYdLuUqq.js +14 -0
  17. package/dist/assets/{ScopeSwitcher-DVSyI44-.js → ScopeSwitcher-CrBLbg8s.js} +1 -1
  18. package/dist/assets/Settings-DluB-vN1.js +3 -0
  19. package/dist/assets/{SkillReferenceGraph-CUv1_Q2c.js → SkillReferenceGraph-CHLSseay.js} +1 -1
  20. package/dist/assets/{Skills-C_YHkAy-.js → Skills-gNdo_HNK.js} +2 -2
  21. package/dist/assets/{SystemPrompt-B8R7T9xn.js → SystemPrompt-Cru05-Ia.js} +1 -1
  22. package/dist/assets/{TagLibrary-dj9YHWyy.js → TagLibrary-DNHY0xou.js} +1 -1
  23. package/dist/assets/{TiptapBody-DnSBUjHE.js → TiptapBody-I4lmbCgP.js} +1 -1
  24. package/dist/assets/{Toggle-CjV_BJn6.js → Toggle-bWMHjmRh.js} +1 -1
  25. package/dist/assets/{index-CDo9xBR9.css → index-DV3PorRY.css} +1 -1
  26. package/dist/assets/{index-CXFQIPhO.js → index-fc_JjdxL.js} +724 -724
  27. package/dist/assets/settingsSchema-BfhtZnGD.js +3 -0
  28. package/dist/index.html +2 -2
  29. package/package.json +14 -14
  30. package/plugins/CLAUDE.md +61 -0
  31. package/plugins/session-manager-dev/.claude-plugin/plugin.json +1 -1
  32. package/plugins/session-manager-dev/skills/builder/4-manual/SKILL.md +1 -1
  33. package/plugins/session-manager-dev/skills/ops-sweep/SKILL.md +1 -1
  34. package/scripts/scheduler-mcp-server.cjs +7 -0
  35. package/src/main/__tests__/agentModelResolve.test.cjs +100 -9
  36. package/src/main/__tests__/broadcastCoalescer.test.cjs +18 -0
  37. package/src/main/__tests__/epicMint.test.cjs +2 -2
  38. package/src/main/__tests__/health-delegation-chain.test.cjs +2 -1
  39. package/src/main/__tests__/needsReviewLedger.test.cjs +162 -0
  40. package/src/main/__tests__/opsErrorLogTelemetryTap.test.cjs +3 -3
  41. package/src/main/__tests__/pollLoop-dispatch-on-failure.test.cjs +15 -1
  42. package/src/main/__tests__/prdCreateDisposition.test.cjs +201 -0
  43. package/src/main/__tests__/prdFrontmatterDisposition.test.cjs +125 -0
  44. package/src/main/__tests__/prdLocations.test.cjs +100 -2
  45. package/src/main/__tests__/prdLocationsArchived.test.cjs +43 -1
  46. package/src/main/__tests__/prdSetDisposition.test.cjs +222 -0
  47. package/src/main/__tests__/queue-health-verdict.test.cjs +170 -0
  48. package/src/main/__tests__/queue-starvation-per-project.test.cjs +14 -2
  49. package/src/main/__tests__/queueHistory.test.cjs +63 -0
  50. package/src/main/__tests__/reconcileTiming.test.cjs +135 -0
  51. package/src/main/__tests__/scheduleJobTransitions.test.cjs +101 -1
  52. package/src/main/__tests__/scheduler-boot-orphans.test.cjs +2 -2
  53. package/src/main/__tests__/scheduler-broadcast-reconcile.test.cjs +121 -0
  54. package/src/main/__tests__/scheduler-cross-project-batch.test.cjs +43 -0
  55. package/src/main/__tests__/scheduler-guard-verdict-autoresolve.test.cjs +344 -0
  56. package/src/main/__tests__/scheduler-job-budget.test.cjs +172 -0
  57. package/src/main/__tests__/scheduler-looks-done.test.cjs +93 -3
  58. package/src/main/__tests__/scheduler-porcelain-rename.test.cjs +164 -0
  59. package/src/main/__tests__/scheduler-reap-dead-running-jobs.test.cjs +160 -0
  60. package/src/main/__tests__/scheduler-reaper-helpers-basics.test.cjs +87 -0
  61. package/src/main/__tests__/scheduler-shared-tree-guard.test.cjs +88 -0
  62. package/src/main/__tests__/scheduler-starve-escalation.test.cjs +14 -4
  63. package/src/main/__tests__/scheduler-worktree-cap-defer.test.cjs +14 -0
  64. package/src/main/chatRunner.cjs +8 -5
  65. package/src/main/health.cjs +1 -1
  66. package/src/main/historyAggregator.cjs +5 -0
  67. package/src/main/index.cjs +95 -44
  68. package/src/main/ipcSchemas.cjs +47 -0
  69. package/src/main/lib/__tests__/active-sessions.test.cjs +251 -0
  70. package/src/main/lib/__tests__/bootSelfHeal.test.cjs +107 -0
  71. package/src/main/lib/__tests__/delegationReadiness.test.cjs +322 -43
  72. package/src/main/lib/__tests__/effectiveModelInfo.test.cjs +239 -0
  73. package/src/main/lib/__tests__/gitWorktree.test.cjs +89 -0
  74. package/src/main/lib/__tests__/guardShims.test.cjs +151 -0
  75. package/src/main/lib/__tests__/opsRootAbsoluteCwd.test.cjs +5 -5
  76. package/src/main/lib/__tests__/prdDisposition.test.cjs +224 -0
  77. package/src/main/lib/__tests__/reaperHelpers.test.cjs +179 -1
  78. package/src/main/lib/__tests__/usageCircuit.test.cjs +224 -0
  79. package/src/main/lib/__tests__/watchdog-helpers.test.cjs +312 -0
  80. package/src/main/lib/__tests__/watchdog-relaunch.test.cjs +193 -0
  81. package/{scripts → src/main}/lib/activeSessions.cjs +50 -4
  82. package/src/main/lib/agentModelResolve.cjs +65 -27
  83. package/src/main/lib/bootSelfHeal.cjs +88 -0
  84. package/src/main/lib/delegationReadiness.cjs +290 -225
  85. package/src/main/lib/effectiveModelInfo.cjs +333 -0
  86. package/src/main/lib/ephemeralCwd.cjs +1 -1
  87. package/src/main/lib/epicMint.cjs +3 -3
  88. package/src/main/lib/gitWorktree.cjs +42 -12
  89. package/src/main/lib/guardShims.cjs +156 -0
  90. package/src/main/lib/jobDirtFilter.cjs +7 -2
  91. package/src/main/lib/launchFailure.cjs +2 -1
  92. package/src/main/lib/mcpToolCatalog.cjs +4 -1
  93. package/src/main/lib/needsReviewLedger.cjs +205 -0
  94. package/src/main/lib/opsErrorLog.cjs +1 -1
  95. package/src/main/lib/opsOwnership.cjs +1 -1
  96. package/src/main/lib/prdCreate.cjs +56 -1
  97. package/src/main/lib/prdDisposition.cjs +199 -0
  98. package/src/main/lib/prdFrontmatter.cjs +8 -2
  99. package/src/main/lib/prdLocations.cjs +167 -45
  100. package/src/main/lib/projectHomeAdminRoutes.cjs +4 -4
  101. package/src/main/lib/projectPageSummarySchema.cjs +1 -1
  102. package/src/main/lib/projectRootResolve.cjs +1 -1
  103. package/src/main/lib/queueHistory.cjs +19 -1
  104. package/src/main/lib/queueStore.cjs +6 -1
  105. package/src/main/lib/reaperHelpers.cjs +181 -15
  106. package/src/main/lib/scheduleJobSchema.cjs +8 -0
  107. package/src/main/lib/scheduleJobTransitions.cjs +33 -0
  108. package/src/main/lib/schedulerConfig.cjs +24 -0
  109. package/src/main/lib/usageCircuit.cjs +159 -0
  110. package/{scripts → src/main}/lib/watchdogHelpers.cjs +1 -1
  111. package/src/main/scheduler/prdParser.cjs +13 -0
  112. package/src/main/scheduler.cjs +1285 -143
  113. package/src/main/templates/PRD_AUTHORING.md +50 -0
  114. package/src/main/templates/project-pages-catalog.json +1 -1
  115. package/src/main/usage.cjs +21 -3
  116. package/src/preload/api.d.ts +92 -1
  117. package/src/preload/index.cjs +10 -0
  118. package/web/README.md +41 -0
  119. package/{scripts/render-project-pages.cjs → web/project-pages/render.cjs} +4 -4
  120. package/{scripts/render-project-pages → web/project-pages/renderer}/dist/renderer.cjs +1 -1
  121. package/{scripts/validate-project-pages-summary.cjs → web/project-pages/validate-summary.cjs} +5 -5
  122. package/dist/assets/AgentLibrary-DTFL7y8G.js +0 -3
  123. package/dist/assets/MarkdownEditor-DCIubYWf.js +0 -1
  124. package/dist/assets/Permissions-BiYZNGYW.js +0 -3
  125. package/dist/assets/Scheduler-DcLBiJBq.js +0 -14
  126. package/dist/assets/Settings-Cv-pRyms.js +0 -3
  127. package/dist/assets/settingsSchema-BJVciriw.js +0 -3
  128. /package/{scripts/project-pages-logic → web/project-pages/logic}/dist/logic.cjs +0 -0
@@ -0,0 +1,87 @@
1
+ 'use strict';
2
+
3
+ // Ported from the retired top-level test-root directory (a node:test suite,
4
+ // never runnable by this repo's vitest-only setup — see CLAUDE.md's Commands
5
+ // section). The existing src/main/lib/__tests__/reaperHelpers.test.cjs
6
+ // exercises classifyRunOutcome extensively via selectReapableJobs and the
7
+ // rate-limit paths, but never asserts claudePidAlive directly (only ever
8
+ // injects a stand-in `pidAlive` function), and never pins the bare
9
+ // no-result/empty-file/malformed-event edge cases below. These fill that
10
+ // gap.
11
+ //
12
+ // Run: timeout 120 npx vitest run src/main/__tests__/scheduler-reaper-helpers-basics.test.cjs
13
+
14
+ const assert = require('node:assert/strict');
15
+ const os = require('node:os');
16
+ const path = require('node:path');
17
+ const fs = require('node:fs');
18
+ const { claudePidAlive, classifyRunOutcome } = require('../lib/reaperHelpers.cjs');
19
+
20
+ // ─────────────────────────────────────────── claudePidAlive
21
+
22
+ test('claudePidAlive: nonexistent pid (999999) returns false', () => {
23
+ assert.equal(claudePidAlive(999999), false);
24
+ });
25
+
26
+ test('claudePidAlive: pid 0 returns false', () => {
27
+ assert.equal(claudePidAlive(0), false);
28
+ });
29
+
30
+ test('claudePidAlive: pid 1 returns false', () => {
31
+ assert.equal(claudePidAlive(1), false);
32
+ });
33
+
34
+ test('claudePidAlive: null returns false', () => {
35
+ assert.equal(claudePidAlive(null), false);
36
+ });
37
+
38
+ test('claudePidAlive: undefined returns false', () => {
39
+ assert.equal(claudePidAlive(undefined), false);
40
+ });
41
+
42
+ // ─────────────────────────────────────────── classifyRunOutcome
43
+
44
+ function writeTempLog(content) {
45
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'reaper-basics-test-'));
46
+ const filePath = path.join(dir, 'test.log');
47
+ fs.writeFileSync(filePath, content, 'utf8');
48
+ return filePath;
49
+ }
50
+
51
+ test('classifyRunOutcome: success without an is_error field → success', () => {
52
+ const p = writeTempLog('{"type":"result","subtype":"success","result":"ok"}\n');
53
+ assert.equal(classifyRunOutcome(p), 'success');
54
+ });
55
+
56
+ test('classifyRunOutcome: subtype success but is_error:true → failed (error wins)', () => {
57
+ const p = writeTempLog('{"type":"result","subtype":"success","is_error":true}\n');
58
+ assert.equal(classifyRunOutcome(p), 'failed');
59
+ });
60
+
61
+ test('classifyRunOutcome: no result event in the tail → no_result', () => {
62
+ const log = [
63
+ '{"type":"assistant","message":"working"}',
64
+ '{"type":"tool_use","name":"bash"}',
65
+ 'some plain text log line',
66
+ ].join('\n');
67
+ const p = writeTempLog(log);
68
+ assert.equal(classifyRunOutcome(p), 'no_result');
69
+ });
70
+
71
+ test('classifyRunOutcome: empty file → no_result', () => {
72
+ const p = writeTempLog('');
73
+ assert.equal(classifyRunOutcome(p), 'no_result');
74
+ });
75
+
76
+ test('classifyRunOutcome: last result event wins when multiple exist', () => {
77
+ const log = [
78
+ '{"type":"result","subtype":"success"}',
79
+ '{"type":"result","subtype":"error","is_error":true}',
80
+ ].join('\n');
81
+ const p = writeTempLog(log);
82
+ assert.equal(classifyRunOutcome(p), 'failed');
83
+ });
84
+
85
+ test('classifyRunOutcome: nonexistent file → no_result (readTail swallows I/O errors)', () => {
86
+ assert.equal(classifyRunOutcome('/nonexistent/path/that/cannot/exist.log'), 'no_result');
87
+ });
@@ -16,6 +16,7 @@
16
16
  'use strict';
17
17
 
18
18
  import { test, expect, vi, afterEach } from 'vitest';
19
+ const fs = require('node:fs');
19
20
  const scheduler = require('../scheduler.cjs');
20
21
  const { evaluateSharedTreeGuard, checkSharedTreeGuard } = scheduler;
21
22
 
@@ -210,3 +211,90 @@ test('checkSharedTreeGuard never throws when the underlying git calls reject', a
210
211
  });
211
212
  expect(result).toBeNull();
212
213
  });
214
+
215
+ // --- 2026-09-12 false-positive: an untracked path that merely became
216
+ // IGNORED during the run (a .gitignore edit) is not a revert. See the
217
+ // evaluateSharedTreeGuard doc comment for the full incident writeup. Each
218
+ // test below is written to FAIL against the pre-fix implementation (which
219
+ // computed `reverted = dirtyBefore - dirtyAfter - committed` with no
220
+ // existence/tracked-status distinction at all) and PASS after it.
221
+
222
+ test('evaluateSharedTreeGuard: an untracked path that disappears from git status but STILL EXISTS on disk is nowIgnored, not reverted', () => {
223
+ // Pre-fix behavior: this path would land in `reverted` (dirtyBefore minus
224
+ // dirtyAfter minus committed, no existence check at all) — FAILS pre-fix.
225
+ const result = evaluateSharedTreeGuard({
226
+ stashBefore: [],
227
+ stashAfter: [],
228
+ dirtyBefore: [{ code: '??', path: 'session-manager-operations/logs/errors-2026-09-12.jsonl' }],
229
+ dirtyAfter: [],
230
+ pathsCommittedDuringRun: ['.gitignore'],
231
+ existsAfter: ['session-manager-operations/logs/errors-2026-09-12.jsonl'],
232
+ });
233
+ expect(result.reverted).toEqual([]);
234
+ expect(result.nowIgnored).toEqual(['session-manager-operations/logs/errors-2026-09-12.jsonl']);
235
+ });
236
+
237
+ test('evaluateSharedTreeGuard: an untracked path that disappears from git status AND no longer exists on disk is still reverted', () => {
238
+ // Proves the fix is not a blanket "stop reporting reverted": a genuinely
239
+ // deleted untracked file (the guard's real purpose — catching a job that
240
+ // discarded uncommitted work) must still surface. If the fix had simply
241
+ // stopped reporting any untracked-and-missing path as reverted, this test
242
+ // would fail (reverted would come back empty instead of containing the
243
+ // path).
244
+ const result = evaluateSharedTreeGuard({
245
+ stashBefore: [],
246
+ stashAfter: [],
247
+ dirtyBefore: [{ code: '??', path: 'data/scratch.txt' }],
248
+ dirtyAfter: [],
249
+ pathsCommittedDuringRun: [],
250
+ existsAfter: [], // file no longer exists on disk
251
+ });
252
+ expect(result.reverted).toEqual(['data/scratch.txt']);
253
+ expect(result.nowIgnored).toEqual([]);
254
+ });
255
+
256
+ test('evaluateSharedTreeGuard: a TRACKED path clean after the run is still reverted even though it exists on disk', () => {
257
+ // A tracked file leaving the dirty set means its content was restored to
258
+ // HEAD — exactly the discard this guard exists to catch — regardless of
259
+ // whether the file is still present. Pre-fix this already worked by
260
+ // accident (no existence check existed at all); this test pins the
261
+ // behavior now that existence checks exist for the untracked branch, so a
262
+ // future change can't accidentally start trusting `existsAfter` for
263
+ // tracked paths too.
264
+ const result = evaluateSharedTreeGuard({
265
+ stashBefore: [],
266
+ stashAfter: [],
267
+ dirtyBefore: [{ code: ' M', path: 'src/config.js' }],
268
+ dirtyAfter: [],
269
+ pathsCommittedDuringRun: [],
270
+ existsAfter: ['src/config.js'], // file exists, but content was reset to HEAD
271
+ });
272
+ expect(result.reverted).toEqual(['src/config.js']);
273
+ expect(result.nowIgnored).toEqual([]);
274
+ });
275
+
276
+ test('checkSharedTreeGuard end-to-end: the real 2026-09-12 incident shape — untracked logs path ignored by a committed .gitignore change, file still on disk — must NOT verdict shared_tree_reverted', async () => {
277
+ // PRD 1181's exact shape: a bare `logs/` pattern lands in .gitignore during
278
+ // the run (committed), eleven untracked log paths vanish from `git status`,
279
+ // and the files are untouched on disk. Pre-fix, checkSharedTreeGuard's
280
+ // `reverted` would contain this path (no existsAfter computation existed),
281
+ // driving the caller's `verdict: 'shared_tree_reverted'` downgrade — FAILS
282
+ // pre-fix.
283
+ const logPath = 'session-manager-operations/logs/errors-2026-09-12.jsonl';
284
+ vi.spyOn(scheduler, 'stashList').mockResolvedValue([]);
285
+ vi.spyOn(scheduler, 'gitHead').mockResolvedValue('sha-after');
286
+ vi.spyOn(scheduler, 'pathsChangedSince').mockResolvedValue(['.gitignore']);
287
+ vi.spyOn(scheduler, 'uncommittedChanges').mockResolvedValue([]); // the log path no longer shows up at all
288
+ vi.spyOn(fs, 'existsSync').mockImplementation((p) => String(p).includes('errors-2026-09-12.jsonl'));
289
+
290
+ const result = await checkSharedTreeGuard({
291
+ cwd: '/repo',
292
+ stashBaseline: [],
293
+ dirtyBaseline: [{ code: '??', path: logPath }],
294
+ headBefore: 'sha-before',
295
+ slug: 'job-1181',
296
+ });
297
+
298
+ expect(result.reverted).toBeUndefined();
299
+ expect(result.nowIgnored).toEqual([logPath]);
300
+ });
@@ -12,21 +12,26 @@
12
12
  * Exercises selectStarveEscalations() (pure) directly, and
13
13
  * runStarveEscalationSweep() (the side-effecting wrapper) against a real,
14
14
  * scratch audit-log.jsonl and a fake attached window — matching
15
- * queue-starvation-per-project.test.cjs's pattern of setting HOME before
16
- * require() since every state path is baked from os.homedir() at load.
15
+ * pollLoop-dispatch-on-failure.test.cjs's pattern of setting HOME before
16
+ * require() (beforeAll runs AFTER module evaluation) since every state path
17
+ * is baked from os.homedir() at load, and restoring it in afterAll.
17
18
  *
18
19
  * Run: timeout 180 npx vitest run src/main/__tests__/scheduler-starve-escalation.test.cjs
19
20
  */
20
21
 
21
22
  'use strict';
22
23
 
23
- import { test, beforeEach } from 'vitest';
24
+ import { test, beforeEach, afterAll } from 'vitest';
24
25
  const assert = require('node:assert/strict');
25
26
  const fs = require('node:fs');
26
27
  const os = require('node:os');
27
28
  const path = require('node:path');
28
29
 
29
- process.env.HOME = fs.mkdtempSync(path.join(os.tmpdir(), 'starve-escalation-test-'));
30
+ const originalHome = process.env.HOME;
31
+ const tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), 'starve-escalation-test-'));
32
+ process.env.HOME = tmpHome;
33
+ fs.mkdirSync(path.join(tmpHome, '.claude', 'session-manager'), { recursive: true });
34
+ fs.mkdirSync(path.join(tmpHome, '.claude', 'projects'), { recursive: true });
30
35
 
31
36
  const {
32
37
  selectStarveEscalations,
@@ -36,6 +41,11 @@ const {
36
41
  } = require('../scheduler.cjs');
37
42
  const { AUDIT_LOG_PATH } = require('../lib/auditLog.cjs');
38
43
 
44
+ afterAll(() => {
45
+ process.env.HOME = originalHome;
46
+ fs.rmSync(tmpHome, { recursive: true, force: true });
47
+ });
48
+
39
49
  const CWD_A = '/home/bilko/Projects/Bilko';
40
50
  const CWD_B = '/home/bilko/Projects/session-manager';
41
51
 
@@ -187,6 +187,20 @@ test('a non-git cwd still falls back to running in place (unchanged behaviour
187
187
  expect(row.worktreeFallbackReason).toMatch(/not a git repository/);
188
188
  // Ran IN PLACE — the marker landed directly in projectCwd, not a worktree.
189
189
  expect(fs.existsSync(path.join(projectCwd, 'ran-here.marker'))).toBe(true);
190
+
191
+ // A job losing worktree isolation must never be a silent downgrade
192
+ // discoverable only by reading queue.json afterwards — it must also land
193
+ // as a warn line in the durable ops error log, naming the slug, cwd, and
194
+ // reason.
195
+ const { todayFile } = require('../lib/opsErrorLog.cjs');
196
+ const errorLogPath = todayFile(projectCwd);
197
+ expect(fs.existsSync(errorLogPath)).toBe(true);
198
+ const logLines = fs.readFileSync(errorLogPath, 'utf8').trim().split('\n').map((l) => JSON.parse(l));
199
+ const fallbackLine = logLines.find((l) => l.scope === 'scheduler' && l.level === 'warn' && l.message.includes(slug));
200
+ expect(fallbackLine).toBeTruthy();
201
+ expect(fallbackLine.message).toMatch(/SHARED working tree/);
202
+ expect(fallbackLine.message).toMatch(/not a git repository/);
203
+ expect(fallbackLine.meta).toMatchObject({ slug, cwd: projectCwd, reason: expect.stringMatching(/not a git repository/) });
190
204
  } finally {
191
205
  fs.rmSync(projectCwd, { recursive: true, force: true });
192
206
  fs.rmSync(runDir, { recursive: true, force: true });
@@ -530,11 +530,14 @@ function executeRun({ tabId, sessionId, prompt, cwd, resume, silent, onSilentRes
530
530
  const fullPrompt = STOP_SIGNAL_INSTRUCTION + CHAT_MODE_TRUTH_INSTRUCTION + prompt;
531
531
 
532
532
  // sessionId is the Epic's claudeSessionId for an Epic-backed tab (see
533
- // comment above childEnv) — resolve that Epic's agentType persona model
534
- // so Chat and Terminal views of the same Epic launch with the same
535
- // model. Never throws; falls back to the hardcoded 'sonnet' literal when
536
- // there's no Epic, no agentType, or the persona has no model/'inherit' —
537
- // --model must never be left unpinned (CLAUDE.md model-pinning rule).
533
+ // comment above childEnv) — resolve that Epic's agentType persona model,
534
+ // project-overlay-then-global (agentModelResolve.cjs's
535
+ // readOverlayAwarePersonaModel). Terminal (EpicTerminalPane.tsx) calls
536
+ // this SAME function via the agents:resolve-epic-model IPC, so the two
537
+ // views of one Epic agree by construction, not by convention. Never
538
+ // throws; falls back to the hardcoded 'sonnet' literal when there's no
539
+ // Epic, no agentType, or the persona has no model/'inherit' — --model
540
+ // must never be left unpinned (CLAUDE.md model-pinning rule).
538
541
  const model = agentModelResolve.resolveEpicModel({ cwd, claudeSessionId: sessionId });
539
542
 
540
543
  // Build argv as an array — no shell: true, no string interpolation
@@ -20,7 +20,7 @@ const { computeStallSummary, SCHEDULER_STATE_PATH, FAILURE_STREAK_WARN_THRESHOLD
20
20
  const { findStarvedProjects } = require('./lib/schedulerBatch.cjs');
21
21
  const { AUDIT_LOG_PATH } = require('./lib/auditLog.cjs');
22
22
  const { DEFAULT_RUNS_DIR, computeReport, isRetentionEnabled, liveKeysFromJobs } = require('./lib/runLogRetention.cjs');
23
- const { allProjectCwds } = require('../../scripts/lib/activeSessions.cjs');
23
+ const { allProjectCwds } = require('./lib/activeSessions.cjs');
24
24
 
25
25
  const MAX_LOG_AGE_MS = 5 * 60_000; // 5 min — warn if no logs this old
26
26
  const PROJECT_ROOT = path.resolve(__dirname, '../..');
@@ -21,10 +21,14 @@ const CACHE_MAX = 50_000;
21
21
  // tokens are priced far below input because they're served from
22
22
  // Anthropic's prompt cache, not re-processed — this is what makes the
23
23
  // cache-savings figure in the dashboard real money, not a vanity stat.
24
+ // fable: Claude Fable 5.1 (claude-fable-5-1) — $10/$50 per MTok input/output,
25
+ // cache reads at $0.25/MTok (the `claude-api` skill's model reference and its
26
+ // Claude Fable 5.1 migration notes, both cached 2026-06-24).
24
27
  const MODEL_PRICING = {
25
28
  opus: { i: 15, o: 75, c: 1.5 },
26
29
  sonnet: { i: 3, o: 15, c: 0.3 },
27
30
  haiku: { i: 0.8, o: 4, c: 0.08 },
31
+ fable: { i: 10, o: 50, c: 0.25 },
28
32
  };
29
33
  const DEFAULT_PRICING_KEY = 'sonnet'; // fallback for unrecognized model ids
30
34
 
@@ -40,6 +44,7 @@ function resolvePricingKey(modelId) {
40
44
  if (id.includes('opus')) return { key: 'opus', estimated: false };
41
45
  if (id.includes('sonnet')) return { key: 'sonnet', estimated: false };
42
46
  if (id.includes('haiku')) return { key: 'haiku', estimated: false };
47
+ if (id.includes('fable')) return { key: 'fable', estimated: false };
43
48
  return { key: DEFAULT_PRICING_KEY, estimated: true };
44
49
  }
45
50
 
@@ -37,7 +37,11 @@ const epicWorktreeMint = require('./lib/epicWorktreeMint.cjs');
37
37
  const epicWorktreeMerge = require('./lib/epicWorktreeMerge.cjs');
38
38
  const epicWorktreeProjectConfig = require('./lib/epicWorktreeProjectConfig.cjs');
39
39
  const agentLibrary = require('./agentLibrary.cjs');
40
- const { checkDelegationReadiness, installPrdWriteGuard, installDestructiveGitGuard, installInlineImplementationGuard } = require('./lib/delegationReadiness.cjs');
40
+ const agentModelResolve = require('./lib/agentModelResolve.cjs');
41
+ const { resolveEffectiveModelInfo } = require('./lib/effectiveModelInfo.cjs');
42
+ const { checkDelegationReadiness, ensureGuardsInstalled, installPrdWriteGuard, installDestructiveGitGuard, installInlineImplementationGuard } = require('./lib/delegationReadiness.cjs');
43
+ const { resolveProjectContext } = require('./lib/projectRootResolve.cjs');
44
+ const { writeGuardShims } = require('./lib/guardShims.cjs');
41
45
  const { MCP_TOOL_CATALOG, MCP_RECIPES } = require('./lib/mcpToolCatalog.cjs');
42
46
  const { sendIfAlive } = require('./lib/sendToRenderer.cjs');
43
47
  const { resolveBuildTarget } = require('./lib/buildTarget.cjs');
@@ -70,7 +74,7 @@ const telemetryConsent = require('./lib/telemetryConsent.cjs');
70
74
  const { registerHistoryAggregatorHandlers, finalizeClosedDays, refreshIntradayToday } = require('./historyAggregator.cjs');
71
75
  const runLogRetention = require('./lib/runLogRetention.cjs');
72
76
  const { registerHistoryDashboardHandlers } = require('./historyDashboard.cjs');
73
- const { tryAcquireLock, releaseLock, DEFAULT_LOCK_PATH } = require('../../scripts/lib/watchdogHelpers.cjs');
77
+ const { tryAcquireLock, releaseLock, DEFAULT_LOCK_PATH } = require('./lib/watchdogHelpers.cjs');
74
78
  const schedulerConfig = require('./lib/schedulerConfig.cjs');
75
79
  const memoryTool = require('./memoryTool.cjs');
76
80
  const { registerMemoryAggregateIpc } = require('./memoryAggregate.cjs');
@@ -90,7 +94,8 @@ const { checkInsideHome, assertInsideHome } = require('./lib/insideHome.cjs');
90
94
  const { openInEditor, openFileInEditor, openInFinder, openInTerminal } = require('./lib/openExternalApp.cjs');
91
95
  const { rebuildActiveIndex } = require('./lib/activeIndexRebuild.cjs');
92
96
  const { activeIndexPath: promptSessionsActiveIndexPath } = require('./lib/epicMint.cjs');
93
- const { allProjectCwds } = require('../../scripts/lib/activeSessions.cjs');
97
+ const { allProjectCwds } = require('./lib/activeSessions.cjs');
98
+ const { scheduleBootSelfHeal } = require('./lib/bootSelfHeal.cjs');
94
99
 
95
100
  let mainWindow = null;
96
101
  let rebooting = false;
@@ -520,12 +525,78 @@ ipcMain.handle('agents:remove-override', async (_e, payload) => {
520
525
  ipcMain.handle('agents:get-persona-body', validated(schemas.agentsGetPersonaBody, (payload) =>
521
526
  agentLibrary.getPersonaBody(payload)));
522
527
 
528
+ // "What will this Epic's launch actually use for --model?" — the SAME
529
+ // resolver chatRunner.cjs's Chat-view launch calls in-process
530
+ // (agentModelResolve.cjs's resolveEpicModel), reached here so Terminal's
531
+ // launch (EpicTerminalPane.tsx, a renderer module with no fs access) agrees
532
+ // with Chat by calling the identical function rather than a second one that
533
+ // is merely supposed to. Never throws (resolveEpicModel's own contract).
534
+ ipcMain.handle('agents:resolve-epic-model', validated(schemas.agentsResolveEpicModel, (payload) =>
535
+ agentModelResolve.resolveEpicModel(payload)));
536
+
537
+ // "What will this Epic actually run as?" (PRD: effective-model-resolver) —
538
+ // persona alias + evidence-based concrete model id + provenance, for a given
539
+ // (cwd, agentType). Read-only observer; never affects what a launch resolves.
540
+ ipcMain.handle('agents:resolve-model-info', validated(schemas.agentsResolveModelInfo, (payload) =>
541
+ resolveEffectiveModelInfo(payload)));
542
+
523
543
  // "Can this project actually delegate?" probe (PRD: delegation-readiness).
524
544
  // Structured data over the preconditions for scheduler_create_prd being in an
525
545
  // agent's tool list at all — surfacing it in the UI is a sibling PRD.
526
- ipcMain.handle('app:delegation-readiness', validated(schemas.delegationReadinessCwd, async (payload) =>
527
- checkDelegationReadiness(payload)
528
- ));
546
+ //
547
+ // Self-heals BEFORE computing the checks (PRD: guards-auto-install) — Fix It
548
+ // used to be the only call site for the three guard installers, so every
549
+ // project the human opened started red and demanded three manual clicks.
550
+ // ensureGuardsInstalled never rejects on its own, but it is still awaited
551
+ // inside a try/catch here so a future change to it can never turn this into
552
+ // a rejected IPC handler.
553
+ //
554
+ // `cwd` is normalized to its real project root via the SAME resolver
555
+ // prdAdminRoutes.cjs's session_manager_help readiness route already uses
556
+ // (projectRootResolve.cjs's resolveProjectContext) before either the install
557
+ // or the read-back check runs, so a worktree/ops-internal cwd can't install
558
+ // into one path while the banner reads another.
559
+ ipcMain.handle('app:delegation-readiness', validated(schemas.delegationReadinessCwd, async (payload) => {
560
+ const resolved = resolveProjectContext({ cwd: payload.cwd });
561
+ const cwd = resolved.cwd || payload.cwd;
562
+ try {
563
+ await ensureGuardsInstalled(cwd);
564
+ } catch { /* auto-install must never block the readiness probe itself */ }
565
+ return checkDelegationReadiness({ ...payload, cwd });
566
+ }));
567
+
568
+ // Every guard-install attempt is logged from MAIN, not the renderer — a
569
+ // press that never reaches this process (e.g. a renderer talking to an
570
+ // older main with no handler for the channel, Electron's own "No handler
571
+ // registered for ..." rejection) leaves no line here, which is exactly what
572
+ // makes it distinguishable from a press that reached main and failed. Uses
573
+ // opsErrorLog.appendError (the sole writer of the `logs` ops namespace) so
574
+ // the record lands in the project's own
575
+ // session-manager-operations/logs/errors-<date>.jsonl, same shape as every
576
+ // other structured error line.
577
+ function logGuardInstallAttempt(guard, cwd, outcome) {
578
+ const opsErrorLog = require('./lib/opsErrorLog.cjs');
579
+ opsErrorLog.appendError({
580
+ cwd,
581
+ scope: 'delegationReadinessGuardInstall',
582
+ level: outcome.ok ? 'info' : 'error',
583
+ message: outcome.ok
584
+ ? `${guard} install ${outcome.action}`
585
+ : `${guard} install failed: ${outcome.error || 'unknown error'}`,
586
+ meta: { guard, ...outcome },
587
+ });
588
+ }
589
+
590
+ async function handleGuardInstall(guard, installFn, payload) {
591
+ try {
592
+ const result = await installFn(payload);
593
+ logGuardInstallAttempt(guard, payload.cwd, result);
594
+ return result;
595
+ } catch (err) {
596
+ logGuardInstallAttempt(guard, payload.cwd, { ok: false, action: 'error', error: err?.message ?? String(err) });
597
+ throw err;
598
+ }
599
+ }
529
600
 
530
601
  // The three readiness checks with a sanctioned one-press install. Session
531
602
  // Manager standardizes on the REFERENCE approach (absolute path to this
@@ -536,13 +607,13 @@ ipcMain.handle('app:delegation-readiness', validated(schemas.delegationReadiness
536
607
  // header. inline-implementation-guard shares prd-write-guard's matcher, so
537
608
  // its installer appends into that SAME matcher's `hooks` array instead.
538
609
  ipcMain.handle('app:install-prd-write-guard', validated(schemas.delegationReadinessCwd, (payload) =>
539
- installPrdWriteGuard(payload)
610
+ handleGuardInstall('prd-write-guard', installPrdWriteGuard, payload)
540
611
  ));
541
612
  ipcMain.handle('app:install-destructive-git-guard', validated(schemas.delegationReadinessCwd, (payload) =>
542
- installDestructiveGitGuard(payload)
613
+ handleGuardInstall('destructive-git-guard', installDestructiveGitGuard, payload)
543
614
  ));
544
615
  ipcMain.handle('app:install-inline-implementation-guard', validated(schemas.delegationReadinessCwd, (payload) =>
545
- installInlineImplementationGuard(payload)
616
+ handleGuardInstall('inline-implementation-guard', installInlineImplementationGuard, payload)
546
617
  ));
547
618
 
548
619
  ipcMain.handle('app:engage-rules-path', () => process.env.SESSION_MANAGER_ENGAGE_RULES || null);
@@ -1274,42 +1345,22 @@ app.whenReady().then(async () => {
1274
1345
  seedSchedulerMcp({ logger: console, writeLog: logs.writeLine }).catch((e) => {
1275
1346
  logs.writeLine({ scope: 'seed-scheduler-mcp', level: 'error', message: 'seed failed', meta: { error: e?.message } });
1276
1347
  });
1348
+ // Rewrite the stable guard-hook shims (~/.claude/session-manager/hooks/) to
1349
+ // point at THIS running app's root, on every boot. An already-installed
1350
+ // project hook resolves through the shim at invocation time, so an app
1351
+ // upgrade (a new npx hash dir) just follows along with no re-install — see
1352
+ // guardShims.cjs's header.
1353
+ writeGuardShims().catch((e) => {
1354
+ logs.writeLine({ scope: 'guard-shims', level: 'error', message: 'writeGuardShims failed', meta: { error: e?.message } });
1355
+ });
1277
1356
  // Epic index self-heal: a project whose active-index.json is MISSING or
1278
1357
  // fails to parse has lost every open Epic's status unless the per-Epic
1279
- // status mirror (epicStatusMirror.cjs) can rebuild it. Runs once per boot,
1280
- // fire-and-forget, and only touches a project whose index reads unclean —
1281
- // a clean index is never rewritten here. See activeIndexRebuild.cjs.
1282
- try {
1283
- for (const projectCwd of allProjectCwds()) {
1284
- const indexPath = promptSessionsActiveIndexPath(projectCwd);
1285
- let unclean = false;
1286
- let parseError = null;
1287
- if (!fs.existsSync(indexPath)) {
1288
- unclean = true;
1289
- } else {
1290
- try {
1291
- JSON.parse(fs.readFileSync(indexPath, 'utf8'));
1292
- } catch (e) {
1293
- unclean = true;
1294
- parseError = e.message;
1295
- }
1296
- }
1297
- if (!unclean) continue;
1298
- try {
1299
- const { rows, skipped } = rebuildActiveIndex(projectCwd);
1300
- logs.writeLine({
1301
- scope: 'active-index-rebuild',
1302
- level: 'info',
1303
- message: 'rebuilt active-index.json from status mirrors',
1304
- meta: { cwd: projectCwd, parseError, restored: rows.length, skipped: skipped.length },
1305
- });
1306
- } catch (e) {
1307
- logs.writeLine({ scope: 'active-index-rebuild', level: 'error', message: 'rebuild failed', meta: { cwd: projectCwd, error: e?.message } });
1308
- }
1309
- }
1310
- } catch (e) {
1311
- logs.writeLine({ scope: 'active-index-rebuild', level: 'error', message: 'boot sweep failed', meta: { error: e?.message } });
1312
- }
1358
+ // status mirror (epicStatusMirror.cjs) can rebuild it. Runs once per boot
1359
+ // and only touches a project whose index reads unclean — a clean index is
1360
+ // never rewritten. Deferred until the window paints (did-finish-load) so
1361
+ // the ~270ms allProjectCwds() scan doesn't compete with the renderer's
1362
+ // first IPC round trips for the event loop — see bootSelfHeal.cjs.
1363
+ scheduleBootSelfHeal(mainWindow, { fs, allProjectCwds, promptSessionsActiveIndexPath, rebuildActiveIndex, logs });
1313
1364
  // History rollup finalize pass: deferred 30s past boot so it never competes
1314
1365
  // with first-paint, fire-and-forget (cron/offline refresh is PRD 651 — this
1315
1366
  // is just the one-shot in-app pass).
@@ -1340,7 +1391,7 @@ app.whenReady().then(async () => {
1340
1391
  }, 30_000);
1341
1392
  // Keep TODAY's rollup line current so the History dashboard never needs to
1342
1393
  // fall back to a live transcript scan. Shares the same O_EXCL lock file as
1343
- // the external watchdog's finalize pass (scripts/lib/watchdogHelpers.cjs)
1394
+ // the external watchdog's finalize pass (src/main/lib/watchdogHelpers.cjs)
1344
1395
  // so the two never interleave writes to the rollup; a contended lock just
1345
1396
  // means this tick's refresh is skipped (harmless — the next tick retries).
1346
1397
  const intradayTimer = setInterval(runIntradayRefresh, schedulerConfig.HISTORY_INTRADAY_REFRESH_MS);
@@ -320,6 +320,11 @@ const PrdAgentTypeSchema = z.string().regex(PERSONA_NAME_RE, 'agent name must be
320
320
  // call site since update also allows an explicit empty array to CLEAR the
321
321
  // dependency, which create has no reason to accept.
322
322
  const DepSlugSchema = z.string().min(1).max(160).regex(/^[A-Za-z0-9][\w.-]*$/);
323
+ // Wave-authoring decision (scheduler wave-disposition PRD), shared between
324
+ // schedulerCreatePrd (authoring time) and adminPrdFrontmatterPatch (the
325
+ // after-the-fact change path) so both can never disagree on what a valid
326
+ // disposition value is.
327
+ const PrdDispositionSchema = z.enum(['append', 'new-head']);
323
328
  const schedulerCreatePrd = z.object({
324
329
  title: z.string().min(1).max(200).regex(NO_NEWLINE_RE, 'must not contain newlines'),
325
330
  // Optional (PRD: worktree-cwd Epic-lookup hazard) — when omitted, prdCreate.cjs's
@@ -340,6 +345,12 @@ const schedulerCreatePrd = z.object({
340
345
  // Explicit ordering (PRD 832): slugs that must complete before this PRD
341
346
  // becomes eligible. Written to frontmatter as `dependsOn: [a, b]`.
342
347
  dependsOn: z.array(DepSlugSchema).max(20).optional(),
348
+ // Explicit wave-authoring decision (scheduler wave-disposition PRD) — only
349
+ // meaningful (and only stamped) when this PRD joins an Epic that already
350
+ // has incomplete PRDs and lands as a wave root (no dependsOn of its own);
351
+ // createPrd() requires it in that situation for an interactive caller and
352
+ // defaults to 'append' (logged as a default) for a non-interactive one.
353
+ disposition: PrdDispositionSchema.optional(),
343
354
  // An EXISTING Epic's promptSessionId (PRD 748) — NOT a PromptTicket.id.
344
355
  // ensureEpic() (epicMint.cjs) joins the Epic whose active-index.json
345
356
  // sessions key literally equals this value; the renderer only ever sends
@@ -387,6 +398,19 @@ const schedulerCreatePrd = z.object({
387
398
  quietMachine: z.boolean().optional(),
388
399
  });
389
400
 
401
+ // Renderer-facing counterpart to adminPrdFrontmatterPatch's dependsOn/
402
+ // disposition patch (scheduler wave-disposition PRD) — backs the Scheduler
403
+ // UI's "change disposition" action. `dependsOn` is the caller's already-
404
+ // computed target (from the SAME backlog tree the UI renders — see
405
+ // lib/backlogTree.ts); the main-process handler only validates the rewrite
406
+ // is safe (prdDisposition.cjs), never re-derives "the" terminal itself.
407
+ const scheduleSetPrdDisposition = z.object({
408
+ slug: z.string().regex(SCHEDULE_SLUG_RE),
409
+ cwd: z.string().min(1).max(4096).optional(),
410
+ disposition: PrdDispositionSchema,
411
+ dependsOn: z.array(DepSlugSchema).max(20).optional(),
412
+ });
413
+
390
414
  // Bulk archive: slug list, capped to limit unbounded retag/archive payloads.
391
415
  // Optional cwd narrows every slug's directory search to one project (same
392
416
  // role as scheduler_get_prd/scheduler_update_prd's cwd hint) — omitted, the
@@ -469,6 +493,11 @@ const adminPrdFrontmatterPatch = z.object({
469
493
  // (depSlugResolve.cjs) scheduler_create_prd uses, so update and create
470
494
  // can never disagree about what a dependsOn entry resolves to.
471
495
  dependsOn: z.array(DepSlugSchema).max(20).optional(),
496
+ // Patchable after creation too — the Scheduler UI's "change disposition"
497
+ // action (promote a wave to its own head, or attach it behind another
498
+ // chain) rewrites this alongside dependsOn via the same
499
+ // schedule:set-prd-disposition path (see prdDisposition.cjs).
500
+ disposition: PrdDispositionSchema.optional(),
472
501
  });
473
502
 
474
503
  const adminUpdatePrd = z.object({
@@ -623,6 +652,21 @@ const agentsGetPersonaBody = z.object({
623
652
  name: z.string().min(1).max(200),
624
653
  }).strict();
625
654
 
655
+ // ──────────────────────────────────────────── Effective model info (PRD: what-will-this-run-as)
656
+ const agentsResolveModelInfo = z.object({
657
+ cwd: z.string().min(1).max(4096),
658
+ agentType: z.string().min(1).max(200),
659
+ }).strict();
660
+
661
+ // ──────────────────────────────────────────── Epic launch model (PRD: unify-epic-model-resolution)
662
+ // Terminal's (EpicTerminalPane.tsx) call into agentModelResolve.cjs's
663
+ // resolveEpicModel — the same launch-model authority chatRunner.cjs's Chat
664
+ // path calls in-process, reached here over IPC since the renderer has no fs.
665
+ const agentsResolveEpicModel = z.object({
666
+ cwd: z.string().min(1).max(4096),
667
+ claudeSessionId: z.string().min(1).max(200),
668
+ }).strict();
669
+
626
670
  // ──────────────────────────────────────────── Project Pages (PRD 932)
627
671
  // Same validation split as projectBriefCwd above: real path validation is
628
672
  // config.cjs's validatePath at first fs access in projectPages.cjs.
@@ -1037,6 +1081,7 @@ module.exports = {
1037
1081
  scheduleReadLog,
1038
1082
  scheduleWritePrd,
1039
1083
  schedulerCreatePrd,
1084
+ scheduleSetPrdDisposition,
1040
1085
  scheduleArchivePrd,
1041
1086
  scheduleRetagPrd,
1042
1087
  adminListPrdsQuery,
@@ -1108,6 +1153,8 @@ module.exports = {
1108
1153
  exchangesList,
1109
1154
  agentsSavePersona,
1110
1155
  agentsGetPersonaBody,
1156
+ agentsResolveModelInfo,
1157
+ agentsResolveEpicModel,
1111
1158
  delegationReadinessCwd,
1112
1159
  telemetrySetConfig,
1113
1160
  },