claude-code-session-manager 0.51.0 → 0.53.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.
package/dist/index.html CHANGED
@@ -7,10 +7,10 @@
7
7
  <link rel="preconnect" href="https://fonts.googleapis.com">
8
8
  <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
9
9
  <link href="https://fonts.googleapis.com/css2?family=Newsreader:ital,opsz,wght@0,6..72,400;0,6..72,500;0,6..72,600;0,6..72,700;1,6..72,400&family=Geist:wght@300;400;500;600;700&family=IBM+Plex+Mono:wght@400;500;600&display=swap" rel="stylesheet">
10
- <script type="module" crossorigin src="./assets/index-DPY5vv68.js"></script>
10
+ <script type="module" crossorigin src="./assets/index-D1v4VU6k.js"></script>
11
11
  <link rel="modulepreload" crossorigin href="./assets/monaco-editor-BW5C4Iv1.js">
12
12
  <link rel="stylesheet" crossorigin href="./assets/monaco-editor-BTnBOi8r.css">
13
- <link rel="stylesheet" crossorigin href="./assets/index-BXzDjHNE.css">
13
+ <link rel="stylesheet" crossorigin href="./assets/index-BoOiJJOr.css">
14
14
  </head>
15
15
  <body class="bg-bg text-fg font-sans antialiased">
16
16
  <div id="root"></div>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-code-session-manager",
3
- "version": "0.51.0",
3
+ "version": "0.53.0",
4
4
  "description": "Local cockpit for the Claude Code CLI — multi-tab terminal, full config surface, scheduler, voice dictation, and live observability.",
5
5
  "type": "module",
6
6
  "main": "src/main/index.cjs",
@@ -28,6 +28,8 @@
28
28
  "dev:renderer": "vite",
29
29
  "dev:electron": "wait-on http://localhost:5173 && SM_DEV=1 electron .",
30
30
  "build": "vite build",
31
+ "build:project-pages": "node scripts/build-project-pages-renderer.mjs",
32
+ "build:project-pages-logic": "node scripts/build-project-pages-logic.mjs",
31
33
  "typecheck": "tsc --noEmit",
32
34
  "start": "electron .",
33
35
  "postinstall": "node scripts/postinstall.cjs",
@@ -124,6 +126,7 @@
124
126
  "@vitejs/plugin-react": "^4.3.3",
125
127
  "autoprefixer": "^10.4.20",
126
128
  "concurrently": "^9.1.0",
129
+ "esbuild": "^0.25.12",
127
130
  "jsdom": "^29.1.1",
128
131
  "postcss": "^8.4.49",
129
132
  "react": "^18.3.1",
@@ -200,6 +200,116 @@ test('findJoinableEpic falls through to the similarity check when preferEpicId p
200
200
  expect(joined).toEqual({ epicId: sibling.epicId, matchedBy: 'similarity', score: expect.any(Number) });
201
201
  });
202
202
 
203
+ test('ensureEpic mints with status "proposed" by default', async () => {
204
+ const cwd = await mkCwd();
205
+
206
+ const minted = await ensureEpic(cwd, { goalText: 'default status epic' });
207
+
208
+ const index = readActiveIndex(cwd);
209
+ expect(index.sessions[minted.epicId].status).toBe('proposed');
210
+ });
211
+
212
+ test('ensureEpic throws when a mint is requested with status "active" (born-proposed law), and appends an audit event', async () => {
213
+ const cwd = await mkCwd();
214
+ const auditLog = require('../lib/auditLog.cjs');
215
+
216
+ await expect(
217
+ ensureEpic(cwd, { goalText: 'should never mint active', status: 'active' }),
218
+ ).rejects.toThrow(/born 'proposed'/);
219
+
220
+ const index = readActiveIndex(cwd);
221
+ expect(Object.keys(index.sessions)).toHaveLength(0);
222
+
223
+ const records = fs.readFileSync(auditLog.AUDIT_LOG_PATH, 'utf8')
224
+ .split('\n')
225
+ .filter(Boolean)
226
+ .map((line) => JSON.parse(line))
227
+ .filter((r) => r.cwd === cwd);
228
+ expect(records).toHaveLength(1);
229
+ expect(records[0].kind).toBe('epic_mint_refused');
230
+ expect(records[0].status).toBe('active');
231
+ });
232
+
233
+ test('ensureEpic still joins an existing active Epic via preferEpicId even when status:"active" is requested', async () => {
234
+ const cwd = await mkCwd();
235
+ const first = await ensureEpic(cwd, { goalText: 'already running epic' });
236
+ const index = readActiveIndex(cwd);
237
+ index.sessions[first.epicId].status = 'active';
238
+ fs.writeFileSync(
239
+ path.join(cwd, 'session-manager-operations', 'prompt-sessions', 'active-index.json'),
240
+ JSON.stringify(index, null, 2),
241
+ );
242
+
243
+ const second = await ensureEpic(cwd, { goalText: 'unrelated text', epicId: first.epicId, status: 'active' });
244
+
245
+ expect(second.created).toBe(false);
246
+ expect(second.epicId).toBe(first.epicId);
247
+ });
248
+
249
+ test('ensureEpic falls through to a "proposed" mint when explicit epicId is archived/completed, never throwing', async () => {
250
+ const cwd = await mkCwd();
251
+ const first = await ensureEpic(cwd, { goalText: 'completed epic' });
252
+ const index = readActiveIndex(cwd);
253
+ index.sessions[first.epicId].status = 'completed';
254
+ fs.writeFileSync(
255
+ path.join(cwd, 'session-manager-operations', 'prompt-sessions', 'active-index.json'),
256
+ JSON.stringify(index, null, 2),
257
+ );
258
+
259
+ const second = await ensureEpic(cwd, { goalText: 'totally unrelated goal text here', epicId: first.epicId });
260
+
261
+ expect(second.created).toBe(true);
262
+ expect(second.epicId).not.toBe(first.epicId);
263
+ const after = readActiveIndex(cwd);
264
+ expect(after.sessions[second.epicId].status).toBe('proposed');
265
+ });
266
+
267
+ function readAuditRecordsForCwd(auditLog, cwd) {
268
+ if (!fs.existsSync(auditLog.AUDIT_LOG_PATH)) return [];
269
+ return fs.readFileSync(auditLog.AUDIT_LOG_PATH, 'utf8')
270
+ .split('\n')
271
+ .filter(Boolean)
272
+ .map((line) => {
273
+ try {
274
+ return JSON.parse(line);
275
+ } catch {
276
+ return null;
277
+ }
278
+ })
279
+ .filter((record) => record && record.cwd === cwd);
280
+ }
281
+
282
+ test('mintIfMissing:false refusal appends an epic_mint_refused audit event', async () => {
283
+ const cwd = await mkCwd();
284
+ const auditLog = require('../lib/auditLog.cjs');
285
+
286
+ await expect(ensureEpic(cwd, { goalText: 'no epic yet', mintIfMissing: false })).rejects.toThrow();
287
+
288
+ const records = readAuditRecordsForCwd(auditLog, cwd);
289
+ expect(records).toHaveLength(1);
290
+ expect(records[0].kind).toBe('epic_mint_refused');
291
+ expect(records[0].cwd).toBe(cwd);
292
+ });
293
+
294
+ test('not-open explicit-epicId fall-through appends an epic_mint_refused audit event', async () => {
295
+ const cwd = await mkCwd();
296
+ const first = await ensureEpic(cwd, { goalText: 'to be completed' });
297
+ const index = readActiveIndex(cwd);
298
+ index.sessions[first.epicId].status = 'completed';
299
+ fs.writeFileSync(
300
+ path.join(cwd, 'session-manager-operations', 'prompt-sessions', 'active-index.json'),
301
+ JSON.stringify(index, null, 2),
302
+ );
303
+ const auditLog = require('../lib/auditLog.cjs');
304
+
305
+ await ensureEpic(cwd, { goalText: 'unrelated fresh goal xyz', epicId: first.epicId });
306
+
307
+ const records = readAuditRecordsForCwd(auditLog, cwd);
308
+ const refusal = records.find((r) => r.kind === 'epic_mint_refused');
309
+ expect(refusal).toBeDefined();
310
+ expect(refusal.epicId).toBe(first.epicId);
311
+ });
312
+
203
313
  test('ensureEpic with forceNewEpic:true mints even when a near-identical open Epic exists', async () => {
204
314
  const cwd = await mkCwd();
205
315
 
@@ -532,6 +532,11 @@ test('resolveSourcePromptIdFromClaudeSession finds the Epic whose claudeSessionI
532
532
  }
533
533
  });
534
534
 
535
+ // Real-homedir-rooted like the chat-only-epic test above (see its comment at
536
+ // line ~449): validatePath's read boundary is home-dir-wide, and this
537
+ // exercises createPrd's real config.addAllowedRoot/writeTextAtomic path. Safe
538
+ // because the random suffix is never a registered project the live scheduler
539
+ // scans for PRDs, and it's removed in `finally`.
535
540
  test('createPrd resolves sourcePromptId from originClaudeSessionId when the caller omitted sourcePromptId', async () => {
536
541
  const root = await fsp.mkdtemp(path.join(os.homedir(), '.sm-origin-session-epic-'));
537
542
  config.addAllowedRoot(root);
@@ -555,6 +560,7 @@ test('createPrd resolves sourcePromptId from originClaudeSessionId when the call
555
560
  }
556
561
  });
557
562
 
563
+ // Same real-homedir rationale as the two tests above.
558
564
  test('createPrd leaves sourcePromptId unset when originClaudeSessionId matches no known Epic (plain SessionTab chat)', async () => {
559
565
  const root = await fsp.mkdtemp(path.join(os.homedir(), '.sm-origin-session-no-match-'));
560
566
  config.addAllowedRoot(root);
@@ -0,0 +1,152 @@
1
+ /**
2
+ * prdLocationsArchived.test.cjs — unit tests for the archived-PRD discovery
3
+ * that backs `schedule:list-prds` including archived PRDs (PRD 926): an
4
+ * Epic's "PRDs" tab count must reflect its REAL historical PRD count, not
5
+ * just whatever is still sitting in its live `prds/` dir — a completed
6
+ * PRD's source .md moves to the sibling `prds-archived/` dir the instant its
7
+ * scheduler job finishes (scheduler.cjs's archiveCompletedPrd), which the
8
+ * old list-prds handler never read.
9
+ *
10
+ * Covers:
11
+ * - resolveArchivedPrdsDirs finds an Epic's own `prds-archived/` dir
12
+ * alongside the project's flat sibling one.
13
+ * - deriveEpicIdFromPrdPath resolves an epicId for a file under
14
+ * `prds-archived/`, not just the live `prds/` dir.
15
+ * - scheduler.cjs's candidateArchivedPrdsDirs + candidatePrdsDirs
16
+ * together surface both a still-pending PRD and an already-archived one
17
+ * for the same Epic, without double-counting.
18
+ *
19
+ * Run: timeout 120 npx vitest run src/main/__tests__/prdLocationsArchived.test.cjs
20
+ */
21
+
22
+ 'use strict';
23
+
24
+ import { test, expect, afterEach } from 'vitest';
25
+ const fs = require('node:fs');
26
+ const fsp = require('node:fs/promises');
27
+ const os = require('node:os');
28
+ const path = require('node:path');
29
+ const {
30
+ resolveArchivedPrdsDirs,
31
+ resolvePrdsDirs,
32
+ resolveEpicPrdWriteDir,
33
+ deriveEpicIdFromPrdPath,
34
+ } = require('../lib/prdLocations.cjs');
35
+ const { candidatePrdsDirs, candidateArchivedPrdsDirs, resolveArchivedPrdStatus } = require('../scheduler.cjs');
36
+
37
+ const tmpDirs = [];
38
+ afterEach(async () => {
39
+ while (tmpDirs.length) {
40
+ const d = tmpDirs.pop();
41
+ await fsp.rm(d, { recursive: true, force: true });
42
+ }
43
+ });
44
+
45
+ function mkEpicProject() {
46
+ return fsp.mkdtemp(path.join(os.tmpdir(), 'sm-prdloc-archived-cwd-'));
47
+ }
48
+
49
+ test('resolveArchivedPrdsDirs finds an Epic\'s own prds-archived/ dir for an active project', async () => {
50
+ const projectsDir = await fsp.mkdtemp(path.join(os.tmpdir(), 'sm-prdloc-archived-projects-'));
51
+ tmpDirs.push(projectsDir);
52
+ const projectCwd = await mkEpicProject();
53
+ tmpDirs.push(projectCwd);
54
+
55
+ const epicPrdsDir = resolveEpicPrdWriteDir(projectCwd, 'epic-1');
56
+ const epicArchiveDir = path.join(epicPrdsDir, '..', 'prds-archived');
57
+ fs.mkdirSync(epicArchiveDir, { recursive: true });
58
+
59
+ const projDir = path.join(projectsDir, 'epic-project');
60
+ fs.mkdirSync(projDir, { recursive: true });
61
+ const transcript = path.join(projDir, 'session1.jsonl');
62
+ fs.writeFileSync(transcript, `${JSON.stringify({ cwd: projectCwd })}\n`);
63
+
64
+ const dirs = resolveArchivedPrdsDirs(90, { projectsDir });
65
+ expect(dirs).toContain(path.resolve(epicArchiveDir));
66
+ });
67
+
68
+ test('deriveEpicIdFromPrdPath resolves epicId for a file under prds-archived/, not just prds/', async () => {
69
+ const projectCwd = await mkEpicProject();
70
+ tmpDirs.push(projectCwd);
71
+
72
+ const epicPrdsDir = resolveEpicPrdWriteDir(projectCwd, 'epic-42');
73
+ const epicArchiveDir = path.join(epicPrdsDir, '..', 'prds-archived');
74
+ fs.mkdirSync(epicArchiveDir, { recursive: true });
75
+
76
+ const archivedFile = path.join(epicArchiveDir, '917-example.md');
77
+ fs.writeFileSync(archivedFile, '---\ntitle: example\n---\n\n# Goal\n');
78
+
79
+ expect(deriveEpicIdFromPrdPath(archivedFile)).toBe('epic-42');
80
+ // Still works for the live dir too — no regression.
81
+ const liveFile = path.join(epicPrdsDir, '918-example.md');
82
+ fs.mkdirSync(epicPrdsDir, { recursive: true });
83
+ fs.writeFileSync(liveFile, '---\ntitle: example\n---\n\n# Goal\n');
84
+ expect(deriveEpicIdFromPrdPath(liveFile)).toBe('epic-42');
85
+ });
86
+
87
+ test('deriveEpicIdFromPrdPath returns null for an unrelated dir name', () => {
88
+ expect(deriveEpicIdFromPrdPath('/some/random/dir/917-example.md')).toBeNull();
89
+ });
90
+
91
+ test('resolvePrdsDirs + resolveArchivedPrdsDirs together surface a pending PRD dir and an archived PRD dir for the same Epic, with no overlap', async () => {
92
+ const projectsDir = await fsp.mkdtemp(path.join(os.tmpdir(), 'sm-prdloc-both-projects-'));
93
+ tmpDirs.push(projectsDir);
94
+ const projectCwd = await mkEpicProject();
95
+ tmpDirs.push(projectCwd);
96
+
97
+ const epicId = `test-epic-${process.pid}-${Math.floor(Math.random() * 1e6)}`;
98
+ const epicPrdsDir = resolveEpicPrdWriteDir(projectCwd, epicId);
99
+ const epicArchiveDir = path.join(epicPrdsDir, '..', 'prds-archived');
100
+ fs.mkdirSync(epicPrdsDir, { recursive: true });
101
+ fs.mkdirSync(epicArchiveDir, { recursive: true });
102
+
103
+ const pendingSlug = `926-pending-${process.pid}`;
104
+ const archivedSlug = `917-archived-${process.pid}`;
105
+ fs.writeFileSync(path.join(epicPrdsDir, `${pendingSlug}.md`), `---\ntitle: pending\ncwd: ${projectCwd}\n---\n\n# Goal\n`);
106
+ fs.writeFileSync(path.join(epicArchiveDir, `${archivedSlug}.md`), `---\ntitle: archived\ncwd: ${projectCwd}\n---\n\n# Goal\n`);
107
+
108
+ const projDir = path.join(projectsDir, 'both-project');
109
+ fs.mkdirSync(projDir, { recursive: true });
110
+ fs.writeFileSync(path.join(projDir, 'session1.jsonl'), `${JSON.stringify({ cwd: projectCwd })}\n`);
111
+
112
+ const liveDirs = resolvePrdsDirs(90, { projectsDir });
113
+ const archivedDirs = resolveArchivedPrdsDirs(90, { projectsDir });
114
+ expect(liveDirs).toContain(epicPrdsDir);
115
+ expect(archivedDirs.map((d) => path.resolve(d))).toContain(path.resolve(epicArchiveDir));
116
+
117
+ // No overlap between the two dir sets for this Epic — an archived PRD's
118
+ // dir is never also scanned as a live one, and vice versa.
119
+ expect(liveDirs).not.toContain(epicArchiveDir);
120
+ expect(archivedDirs).not.toContain(epicPrdsDir);
121
+ });
122
+
123
+ test('resolveArchivedPrdStatus prefers the live queue.json row over history', () => {
124
+ const liveStatusBySlug = new Map([['917-slug', 'failed']]);
125
+ const histBySlug = new Map([['917-slug', { status: 'completed' }]]);
126
+ expect(resolveArchivedPrdStatus('917-slug', liveStatusBySlug, histBySlug)).toBe('failed');
127
+ });
128
+
129
+ test('resolveArchivedPrdStatus falls back to history.jsonl when there is no live row', () => {
130
+ const liveStatusBySlug = new Map();
131
+ const histBySlug = new Map([['917-slug', { status: 'failed' }]]);
132
+ expect(resolveArchivedPrdStatus('917-slug', liveStatusBySlug, histBySlug)).toBe('failed');
133
+ });
134
+
135
+ test('resolveArchivedPrdStatus defaults to completed when neither source has the slug', () => {
136
+ expect(resolveArchivedPrdStatus('917-slug', new Map(), new Map())).toBe('completed');
137
+ });
138
+
139
+ test('resolveArchivedPrdStatus normalizes any non-failed status (e.g. needs_review) to completed', () => {
140
+ const liveStatusBySlug = new Map([['917-slug', 'needs_review']]);
141
+ expect(resolveArchivedPrdStatus('917-slug', liveStatusBySlug, new Map())).toBe('completed');
142
+ });
143
+
144
+ test('scheduler.cjs exports candidateArchivedPrdsDirs alongside candidatePrdsDirs', () => {
145
+ expect(typeof candidatePrdsDirs).toBe('function');
146
+ expect(typeof candidateArchivedPrdsDirs).toBe('function');
147
+ // Both scan the real machine's projects — just assert they run without
148
+ // throwing and return arrays; per-Epic coverage is exercised above via
149
+ // the opts-injectable prdLocations functions directly.
150
+ expect(Array.isArray(candidatePrdsDirs())).toBe(true);
151
+ expect(Array.isArray(candidateArchivedPrdsDirs())).toBe(true);
152
+ });
@@ -105,6 +105,11 @@ test('migratePrds ignores non-.md and dotfiles, and no-ops on a missing legacy d
105
105
 
106
106
  test('migratePrds expands a tilde-prefixed cwd and moves the file', async () => {
107
107
  const legacyDir = await mkLegacyDir();
108
+ // Deliberately rooted under the REAL os.homedir() (not os.tmpdir()) — the
109
+ // assertion under test is tilde-expansion relative to the actual home dir,
110
+ // so a real homedir path is load-bearing here, not incidental. Still safe:
111
+ // the random suffix is never a registered project the live scheduler scans
112
+ // for PRDs, and it's removed in afterEach via tmpDirs/fsp.rm.
108
113
  const projectCwd = await fsp.mkdtemp(path.join(os.homedir(), '.sm-prdmigration-tilde-'));
109
114
  tmpDirs.push(projectCwd);
110
115
  const tildeCwd = path.join('~', path.relative(os.homedir(), projectCwd));
@@ -6,20 +6,53 @@
6
6
  * `<cwd>/session-manager-operations/scheduler/prds/`) must still resolve,
7
7
  * since findPrdDir's candidatePrdsDirs() searches the legacy dir first.
8
8
  *
9
- * Touches the real legacy PRDS_DIR (scheduler.cjs hard-codes it from
10
- * os.homedir(), same as other scheduler unit tests that exercise
11
- * global-path-backed exports) with a unique slug, and cleans up after itself.
9
+ * HOME-isolated (PRD 812 incident fix): scheduler.cjs derives PRDS_DIR from
10
+ * os.homedir() at module load. This test used to write straight into the
11
+ * REAL `~/.claude/session-manager/scheduled-plans/prds/` — the live
12
+ * scheduler's `reconcile()` scans that dir every 60s and enqueued the
13
+ * fixture as a phantom job (job 812-test-legacy-only-1975929-233060) before
14
+ * this test's own cleanup ran. Now HOME is stubbed to a mkdtemp dir BEFORE
15
+ * scheduler.cjs is first required, so PRDS_DIR resolves under the temp dir
16
+ * and nothing here can reach the real scheduler root. The
17
+ * `PRDS_DIR.startsWith(tmpHome)` guard is the regression test for that
18
+ * incident: if module-load ordering ever changes so the real homedir gets
19
+ * snapshotted first, this throws loudly instead of silently poisoning the
20
+ * live queue again.
12
21
  *
13
22
  * Run: timeout 120 npx vitest run src/main/__tests__/scheduler-find-prd-dir.test.cjs
14
23
  */
15
24
 
16
25
  'use strict';
17
26
 
18
- import { test, expect } from 'vitest';
27
+ import { test, expect, beforeAll, afterAll } from 'vitest';
19
28
  const fs = require('node:fs');
20
29
  const os = require('node:os');
21
30
  const path = require('node:path');
22
- const { findPrdDir, PRDS_DIR } = require('../scheduler.cjs');
31
+
32
+ let tmpHome;
33
+ let originalHome;
34
+ let findPrdDir;
35
+ let PRDS_DIR;
36
+
37
+ beforeAll(() => {
38
+ originalHome = process.env.HOME;
39
+ tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), 'sm-find-prd-dir-'));
40
+ process.env.HOME = tmpHome;
41
+
42
+ // Load AFTER HOME is stubbed — scheduler.cjs snapshots os.homedir() into
43
+ // PRDS_DIR at module load, not lazily.
44
+ ({ findPrdDir, PRDS_DIR } = require('../scheduler.cjs'));
45
+
46
+ // Hard guard: never let this test write into the real scheduler root.
47
+ if (!PRDS_DIR.startsWith(tmpHome)) {
48
+ throw new Error(`refusing to run: PRDS_DIR (${PRDS_DIR}) is not under the temp HOME (${tmpHome})`);
49
+ }
50
+ });
51
+
52
+ afterAll(() => {
53
+ process.env.HOME = originalHome;
54
+ fs.rmSync(tmpHome, { recursive: true, force: true });
55
+ });
23
56
 
24
57
  test('findPrdDir resolves a slug that exists only in the legacy global PRDs dir', async () => {
25
58
  const slug = `812-test-legacy-only-${process.pid}-${Math.floor(Math.random() * 1e6)}`;
@@ -0,0 +1,89 @@
1
+ /**
2
+ * scheduler-prd-missing-skip.test.cjs — regression test for PRD 812's
3
+ * failure-classification fix: a queued job whose PRD source has disappeared
4
+ * between enqueue and dispatch (e.g. a test fixture that leaked into the
5
+ * live queue and was cleaned up before the job ran) must be retired as a
6
+ * stale row (exitCode: 0, skipped: 'prd-missing'), not treated as a hard
7
+ * job failure (exitCode: -1). A genuinely unreadable/unparseable PRD (a
8
+ * real, non-ENOENT error) must still fail with exitCode: -1.
9
+ *
10
+ * HOME-isolated per the same incident: stub HOME to a mkdtemp dir before
11
+ * requiring scheduler.cjs, and never write into the real scheduler root.
12
+ *
13
+ * Run: timeout 120 npx vitest run src/main/__tests__/scheduler-prd-missing-skip.test.cjs
14
+ */
15
+
16
+ 'use strict';
17
+
18
+ import { test, expect, beforeAll, afterAll } from 'vitest';
19
+ const fs = require('node:fs');
20
+ const os = require('node:os');
21
+ const path = require('node:path');
22
+
23
+ let tmpHome;
24
+ let originalHome;
25
+ let executeJob;
26
+ let PRDS_DIR;
27
+
28
+ beforeAll(() => {
29
+ originalHome = process.env.HOME;
30
+ tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), 'sm-prd-missing-skip-'));
31
+ process.env.HOME = tmpHome;
32
+
33
+ ({ executeJob, PRDS_DIR } = require('../scheduler.cjs'));
34
+
35
+ if (!PRDS_DIR.startsWith(tmpHome)) {
36
+ throw new Error(`refusing to run: PRDS_DIR (${PRDS_DIR}) is not under the temp HOME (${tmpHome})`);
37
+ }
38
+ });
39
+
40
+ afterAll(() => {
41
+ process.env.HOME = originalHome;
42
+ fs.rmSync(tmpHome, { recursive: true, force: true });
43
+ });
44
+
45
+ test('a job whose PRD source is gone everywhere is retired, not failed', async () => {
46
+ const projectCwd = fs.mkdtempSync(path.join(os.tmpdir(), 'sm-prd-missing-project-'));
47
+ // config.cjs's writeJsonSync (used for the job's meta.json) restricts
48
+ // writes to WRITE_PREFIXES, which includes `<home>/.claude` — so runDir
49
+ // must live under the stubbed tmpHome's .claude/, not the system tmpdir.
50
+ fs.mkdirSync(path.join(tmpHome, '.claude'), { recursive: true });
51
+ const runDir = fs.mkdtempSync(path.join(tmpHome, '.claude', 'sm-prd-missing-run-'));
52
+ try {
53
+ const slug = `812-test-missing-${process.pid}-${Math.floor(Math.random() * 1e6)}`;
54
+ const job = { slug, cwd: projectCwd };
55
+
56
+ const result = await executeJob(job, runDir, projectCwd, () => {});
57
+
58
+ expect(result.exitCode).toBe(0);
59
+ expect(result.skipped).toBe('prd-missing');
60
+ } finally {
61
+ fs.rmSync(projectCwd, { recursive: true, force: true });
62
+ fs.rmSync(runDir, { recursive: true, force: true });
63
+ }
64
+ });
65
+
66
+ test('a job whose PRD source exists but is unreadable for a non-ENOENT reason still fails', async () => {
67
+ const projectCwd = fs.mkdtempSync(path.join(os.tmpdir(), 'sm-prd-toobig-project-'));
68
+ fs.mkdirSync(path.join(tmpHome, '.claude'), { recursive: true });
69
+ const runDir = fs.mkdtempSync(path.join(tmpHome, '.claude', 'sm-prd-toobig-run-'));
70
+ try {
71
+ const slug = `812-test-toobig-${process.pid}-${Math.floor(Math.random() * 1e6)}`;
72
+ const prdsDir = path.join(projectCwd, 'session-manager-operations', 'scheduler', 'prds');
73
+ fs.mkdirSync(prdsDir, { recursive: true });
74
+ // parsePrd rejects any PRD over 1MB with a plain Error (no .code) — this
75
+ // is the "found but unparseable" case, distinct from ENOENT, and must
76
+ // stay a real failure.
77
+ const oversized = '---\ntitle: too big\n---\n\n' + 'x'.repeat(1024 * 1024 + 1);
78
+ fs.writeFileSync(path.join(prdsDir, `${slug}.md`), oversized, 'utf8');
79
+
80
+ const job = { slug, cwd: projectCwd };
81
+ const result = await executeJob(job, runDir, projectCwd, () => {});
82
+
83
+ expect(result.exitCode).toBe(-1);
84
+ expect(result.skipped).toBeUndefined();
85
+ } finally {
86
+ fs.rmSync(projectCwd, { recursive: true, force: true });
87
+ fs.rmSync(runDir, { recursive: true, force: true });
88
+ }
89
+ });
@@ -0,0 +1,103 @@
1
+ /**
2
+ * scheduler-reap-dead-running-jobs.test.cjs — regression cover for PRD 935
3
+ * (reapDeadRunningJobs runningSet desync).
4
+ *
5
+ * spawnJob()'s completion path can write terminal run artifacts to disk and
6
+ * have its child process fully exit, yet still throw between executeJob()
7
+ * resolving and the completion mutate() finishing (e.g. writeQueue's
8
+ * unreadable guard). That throw is swallowed by spawnJob()'s catch, but its
9
+ * `finally` unconditionally deletes the job's slug from the in-memory
10
+ * runningSet — leaving queue.json stuck at status:"running" with a dead pid
11
+ * while runningSet no longer names it. reapDeadRunningJobs() used to gate its
12
+ * entire body on `runningSet.size === 0`, so this exact state made it return
13
+ * before ever reading queue.json — permanently invisible to reconciliation.
14
+ *
15
+ * This test reproduces that desync directly: a project queue.json job row
16
+ * with status:"running" and a dead pid, while runningSet (freshly loaded,
17
+ * never populated) does not contain its slug. It asserts the job still gets
18
+ * reconciled to a terminal status.
19
+ *
20
+ * HOME is overridden to a tmp dir BEFORE requiring scheduler.cjs, since every
21
+ * path this code touches (queueStore's MACHINE_STATE_PATH, activeSessions'
22
+ * project scan root, scheduler.cjs's ROOT/RUNS_DIR, schedulerBatch's
23
+ * DEFAULT_PROJECT_CWD) is baked into a top-level const from os.homedir() at
24
+ * require time — this test must never be able to read or write real state.
25
+ *
26
+ * Run: timeout 120 npx vitest run src/main/__tests__/scheduler-reap-dead-running-jobs.test.cjs
27
+ */
28
+
29
+ 'use strict';
30
+
31
+ const assert = require('node:assert/strict');
32
+ const fs = require('node:fs');
33
+ const os = require('node:os');
34
+ const path = require('node:path');
35
+
36
+ const tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), 'reap-dead-running-jobs-test-'));
37
+ process.env.HOME = tmpHome;
38
+
39
+ const { reapDeadRunningJobs } = require('../scheduler.cjs');
40
+
41
+ function registerActiveProject(cwd) {
42
+ const projectsDir = path.join(tmpHome, '.claude', 'projects');
43
+ const slugDir = path.join(projectsDir, 'fake-project-slug');
44
+ fs.mkdirSync(slugDir, { recursive: true });
45
+ fs.writeFileSync(path.join(slugDir, 'transcript.jsonl'), JSON.stringify({ cwd }) + '\n');
46
+ }
47
+
48
+ function writeProjectQueue(cwd, jobs) {
49
+ const stateDir = path.join(cwd, 'session-manager-operations', 'scheduler', 'state');
50
+ fs.mkdirSync(stateDir, { recursive: true });
51
+ fs.writeFileSync(path.join(stateDir, 'queue.json'), JSON.stringify({ jobs }, null, 2));
52
+ return path.join(stateDir, 'queue.json');
53
+ }
54
+
55
+ function writeRunLog(runId, slug, lines) {
56
+ const runDir = path.join(tmpHome, '.claude', 'session-manager', 'scheduled-plans', 'runs', runId);
57
+ fs.mkdirSync(runDir, { recursive: true });
58
+ fs.writeFileSync(path.join(runDir, `${slug}.log`), lines.join('\n') + '\n');
59
+ }
60
+
61
+ test('reapDeadRunningJobs reconciles a queue.json row stuck at status:running with a dead pid, even when runningSet does not name its slug (desync repro)', async () => {
62
+ const projectCwd = path.join(tmpHome, 'a-project');
63
+ fs.mkdirSync(projectCwd, { recursive: true });
64
+ registerActiveProject(projectCwd);
65
+
66
+ const queuePath = writeProjectQueue(projectCwd, [
67
+ {
68
+ slug: 'desynced-job',
69
+ status: 'running',
70
+ cwd: projectCwd,
71
+ runId: 'run-desynced',
72
+ runtime: { pid: 999999 }, // guaranteed-dead pid (see reaperHelpers tests)
73
+ },
74
+ ]);
75
+ writeRunLog('run-desynced', 'desynced-job', [
76
+ '{"type":"result","subtype":"success","result":"done","is_error":false}',
77
+ ]);
78
+
79
+ // runningSet is a fresh module-level Set with nothing in it — this is the
80
+ // exact desynced state spawnJob()'s finally block can leave behind.
81
+ await reapDeadRunningJobs();
82
+
83
+ const jobs = JSON.parse(fs.readFileSync(queuePath, 'utf8')).jobs;
84
+ assert.equal(jobs.length, 1);
85
+ assert.equal(jobs[0].status, 'completed', 'job must be reconciled from disk state, not skipped via the runningSet gate');
86
+ assert.equal(jobs[0].exitCode, 0);
87
+ assert.equal(jobs[0].runtime, undefined);
88
+ });
89
+
90
+ test('reapDeadRunningJobs is a no-op when no job is actually running in queue.json', async () => {
91
+ const projectCwd = path.join(tmpHome, 'b-project');
92
+ fs.mkdirSync(projectCwd, { recursive: true });
93
+ registerActiveProject(projectCwd);
94
+ const queuePath = writeProjectQueue(projectCwd, [
95
+ { slug: 'already-done', status: 'completed', cwd: projectCwd, exitCode: 0 },
96
+ ]);
97
+
98
+ await assert.doesNotReject(() => reapDeadRunningJobs());
99
+
100
+ const jobs = JSON.parse(fs.readFileSync(queuePath, 'utf8')).jobs;
101
+ assert.equal(jobs.length, 1);
102
+ assert.equal(jobs[0].status, 'completed');
103
+ });
@@ -6,6 +6,14 @@ import fs from 'node:fs';
6
6
  import os from 'node:os';
7
7
  import path from 'node:path';
8
8
 
9
+ // HOME stubbed BEFORE requiring scheduler.cjs (PRD 812 fix): the last
10
+ // describe block below calls scheduler.reconcile(), which internally calls
11
+ // ensureDirs() → fs.mkdirSync on PRDS_DIR/RUNS_DIR — paths baked from
12
+ // os.homedir() at require time. Without this stub that mkdirSync lands in
13
+ // the real `~/.claude/session-manager/scheduled-plans/` tree.
14
+ const tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), 'sm-832-home-'));
15
+ process.env.HOME = tmpHome;
16
+
9
17
  const prdParser = require('../scheduler/prdParser.cjs');
10
18
  const { pickForProject } = require('../lib/schedulerBatch.cjs');
11
19
 
@@ -28,6 +28,7 @@ const voiceWizard = require('./voiceWizard.cjs');
28
28
  const scheduler = require('./scheduler.cjs');
29
29
  const { createAdminHttp } = require('./lib/localAdminHttp.cjs');
30
30
  const prdCreate = require('./lib/prdCreate.cjs');
31
+ const { appendAuditEvent } = require('./lib/auditLog.cjs');
31
32
  const chatRunner = require('./chatRunner.cjs');
32
33
  const promptSessionEvents = require('./promptSessionEvents.cjs');
33
34
  const agentLibrary = require('./agentLibrary.cjs');
@@ -58,6 +59,7 @@ const schedulerConfig = require('./lib/schedulerConfig.cjs');
58
59
  const memoryTool = require('./memoryTool.cjs');
59
60
  const { registerMemoryAggregateIpc } = require('./memoryAggregate.cjs');
60
61
  const { registerProjectBriefIpc } = require('./projectBrief.cjs');
62
+ const { registerProjectPagesIpc } = require('./projectPages.cjs');
61
63
  const promptSessionTranscript = require('./promptSessionTranscript.cjs');
62
64
  const agentMemory = require('./agentMemory.cjs');
63
65
  const git = require('./git.cjs');
@@ -789,6 +791,7 @@ pluginInstall.registerPluginInstallHandlers();
789
791
  memoryTool.registerMemoryHandlers();
790
792
  registerMemoryAggregateIpc();
791
793
  registerProjectBriefIpc();
794
+ registerProjectPagesIpc();
792
795
  ipcMain.handle('promptSessionTranscript:append', validated(schemas.promptSessionTranscriptAppend, async ({ cwd, epicId, role, text, at, eventId }) => {
793
796
  const ok = await promptSessionTranscript.appendTurn(cwd, epicId, { role, text, at, eventId });
794
797
  return { ok };
@@ -797,6 +800,12 @@ ipcMain.handle('promptSessionTranscript:read', validated(schemas.promptSessionTr
797
800
  const turns = await promptSessionTranscript.readTurns(cwd, epicId, { limit });
798
801
  return { turns };
799
802
  }));
803
+ // Renderer Epic lifecycle trace (PRD 940) — append-only, no read/list IPC.
804
+ // Kind allowlist enforced by schemas.auditLogAppend, not here.
805
+ ipcMain.handle('auditLog:append', validated(schemas.auditLogAppend, async ({ kind, cwd, epicId, source }) => {
806
+ appendAuditEvent(kind, { cwd, epicId, source });
807
+ return { ok: true };
808
+ }));
800
809
  agentMemory.registerAgentMemoryHandlers();
801
810
  git.register(ipcMain);
802
811
  filesIpc.registerFilesHandlers();