claude-code-session-manager 0.52.0 → 0.54.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-hNso_ykJ.js"></script>
10
+ <script type="module" crossorigin src="./assets/index-BYOscSQK.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-CKP60_3d.css">
13
+ <link rel="stylesheet" crossorigin href="./assets/index-BEyUC3v9.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.52.0",
3
+ "version": "0.54.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",
@@ -0,0 +1,235 @@
1
+ /**
2
+ * activeIndexMerge.test.cjs — unit tests for the main-process read-merge-write
3
+ * of a cwd's prompt-sessions/active-index.json (lib/activeIndexMerge.cjs),
4
+ * moved here from the renderer (state/promptSessions.ts's old
5
+ * persistActiveIndex, commit 3d12e19) so the merge runs inside the same
6
+ * withPathLock instance epicMint.cjs's ensureEpic already serializes through.
7
+ *
8
+ * Covers the two holes a purely-renderer merge left open:
9
+ * - resurrection: a tombstoned id must never be written back by a later
10
+ * merge call whose memory contribution still thinks it's open.
11
+ * - TOCTOU: a mint (ensureEpic) interleaved with a merge call must survive,
12
+ * since both now go through the same lock.
13
+ *
14
+ * Run: timeout 300 npx vitest run src/main/__tests__/activeIndexMerge.test.cjs
15
+ */
16
+
17
+ 'use strict';
18
+
19
+ import { test, expect, afterEach } from 'vitest';
20
+ const fsp = require('node:fs/promises');
21
+ const os = require('node:os');
22
+ const path = require('node:path');
23
+ const { mergeActiveIndex } = require('../lib/activeIndexMerge.cjs');
24
+ const { ensureEpic, readActiveIndex } = require('../lib/epicMint.cjs');
25
+ const { appendResponseEventIfKnown } = require('../promptSessionEvents.cjs');
26
+ const config = require('../config.cjs');
27
+
28
+ const tmpDirs = [];
29
+ afterEach(async () => {
30
+ while (tmpDirs.length) {
31
+ const d = tmpDirs.pop();
32
+ await fsp.rm(d, { recursive: true, force: true });
33
+ }
34
+ });
35
+
36
+ async function mkCwd() {
37
+ const cwd = await fsp.mkdtemp(path.join(os.tmpdir(), 'sm-activeindexmerge-cwd-'));
38
+ // config.cjs's readJson/writeJson restrict to registered roots (home dir by
39
+ // default) — register this tmp dir so the merge's config.readJson/writeJson
40
+ // calls don't reject it as outside the allowed boundary.
41
+ config.addAllowedRoot(cwd);
42
+ tmpDirs.push(cwd);
43
+ return cwd;
44
+ }
45
+
46
+ function session(id, overrides = {}) {
47
+ return {
48
+ id,
49
+ cwd: overrides.cwd || '/proj',
50
+ goalText: overrides.goalText || `goal for ${id}`,
51
+ claudeSessionId: overrides.claudeSessionId || `claude-${id}`,
52
+ status: overrides.status || 'active',
53
+ createdAt: overrides.createdAt || '2026-08-02T13:48:00.000Z',
54
+ completedAt: overrides.completedAt ?? null,
55
+ };
56
+ }
57
+
58
+ function promptEvent(id, promptSessionId, at) {
59
+ return { id, promptSessionId, kind: 'prompt', causedByEventId: null, at, text: 'x' };
60
+ }
61
+
62
+ // Incident 2026-08-02: two Epics created by another (already-running) window
63
+ // sat on disk. A second window booted empty and, on its first mutation,
64
+ // persisted its own (empty-of-those-two) contribution straight over the file.
65
+ // The main-side merge must still preserve every pre-existing on-disk Epic.
66
+ test('a fresh caller contributing one Epic preserves pre-existing on-disk Epics untouched', async () => {
67
+ const cwd = await mkCwd();
68
+ const other = session('psess-other');
69
+ await mergeActiveIndex(cwd, {
70
+ sessions: { [other.id]: other },
71
+ events: { [other.id]: [promptEvent('evt-1', other.id, other.createdAt)] },
72
+ });
73
+
74
+ const fresh = session('psess-fresh', { createdAt: '2026-08-02T13:50:00.000Z' });
75
+ await mergeActiveIndex(cwd, {
76
+ sessions: { [fresh.id]: fresh },
77
+ events: { [fresh.id]: [promptEvent('evt-2', fresh.id, fresh.createdAt)] },
78
+ });
79
+
80
+ const index = readActiveIndex(cwd);
81
+ expect(index.sessions[other.id]).toEqual(other);
82
+ expect(index.sessions[fresh.id]).toEqual(fresh);
83
+ expect(index.events[other.id]).toHaveLength(1);
84
+ });
85
+
86
+ test('memory wins over disk on id collision', async () => {
87
+ const cwd = await mkCwd();
88
+ const original = session('psess-1', { goalText: 'Fresh title' });
89
+ await mergeActiveIndex(cwd, { sessions: { [original.id]: original }, events: {} });
90
+
91
+ const updated = { ...original, goalText: 'Updated title' };
92
+ await mergeActiveIndex(cwd, { sessions: { [updated.id]: updated }, events: {} });
93
+
94
+ const index = readActiveIndex(cwd);
95
+ expect(index.sessions[original.id].goalText).toBe('Updated title');
96
+ });
97
+
98
+ test('removedIds drops the row from the merged write and records a removal tombstone', async () => {
99
+ const cwd = await mkCwd();
100
+ const s = session('psess-1');
101
+ await mergeActiveIndex(cwd, { sessions: { [s.id]: s }, events: {} });
102
+
103
+ await mergeActiveIndex(cwd, { sessions: {}, events: {}, removedIds: [s.id] });
104
+
105
+ const index = readActiveIndex(cwd);
106
+ expect(index.sessions[s.id]).toBeUndefined();
107
+ expect(index.tombstones[s.id]).toBeTruthy();
108
+ });
109
+
110
+ // The resurrection hole this PRD closes: two simulated windows. Window A
111
+ // completes (markCompleted) and persists the removal. Window B never
112
+ // hydrated that removal — its in-memory snapshot still holds the Epic as
113
+ // 'active' — and persists afterward. The tombstone must win: the Epic stays
114
+ // gone, not resurrected.
115
+ test('regression: a later merge whose memory still holds a removed Epic as active does not resurrect it', async () => {
116
+ const cwd = await mkCwd();
117
+ const s = session('psess-1', { status: 'active' });
118
+ await mergeActiveIndex(cwd, { sessions: { [s.id]: s }, events: { [s.id]: [promptEvent('e1', s.id, s.createdAt)] } });
119
+
120
+ // Window A: markCompleted removes it.
121
+ await mergeActiveIndex(cwd, { sessions: {}, events: {}, removedIds: [s.id] });
122
+ expect(readActiveIndex(cwd).sessions[s.id]).toBeUndefined();
123
+
124
+ // Window B: stale in-memory snapshot still has it as 'active', persists
125
+ // its own (unrelated) mutation — a resurrection-prone merge.
126
+ const windowBOther = session('psess-2', { status: 'proposed' });
127
+ await mergeActiveIndex(cwd, {
128
+ sessions: { [s.id]: s, [windowBOther.id]: windowBOther },
129
+ events: { [s.id]: [promptEvent('e1', s.id, s.createdAt)] },
130
+ });
131
+
132
+ const index = readActiveIndex(cwd);
133
+ expect(index.sessions[s.id]).toBeUndefined();
134
+ expect(index.events[s.id]).toBeUndefined();
135
+ expect(index.sessions[windowBOther.id]).toEqual(windowBOther);
136
+ });
137
+
138
+ test('event union: disk-only events are appended and re-sorted chain-aware; memory-only events kept', async () => {
139
+ const cwd = await mkCwd();
140
+ const s = session('psess-1');
141
+ const e1 = promptEvent('e1', s.id, '2026-08-02T13:00:00.000Z');
142
+ await mergeActiveIndex(cwd, { sessions: { [s.id]: s }, events: { [s.id]: [e1] } });
143
+
144
+ // A scheduler job appended a response event directly to disk between our
145
+ // two merge calls (simulated by mutating the file out of band).
146
+ const midIndex = readActiveIndex(cwd);
147
+ const schedulerEvent = { id: 'e2', promptSessionId: s.id, kind: 'response', causedByEventId: 'e1', at: '2026-08-02T13:05:00.000Z', text: 'done' };
148
+ midIndex.events[s.id] = [e1, schedulerEvent];
149
+ await config.writeJson(path.join(cwd, 'session-manager-operations', 'prompt-sessions', 'active-index.json'), midIndex, { writer: 'epics' });
150
+
151
+ // This caller's memory still only knows about e1 plus its own optimistic
152
+ // append e3 (not yet on disk).
153
+ const e3 = { id: 'e3', promptSessionId: s.id, kind: 'prd_created', causedByEventId: 'e2', at: '2026-08-02T13:10:00.000Z', prdSlug: 'x' };
154
+ await mergeActiveIndex(cwd, { sessions: { [s.id]: s }, events: { [s.id]: [e1, e3] } });
155
+
156
+ const finalEvents = readActiveIndex(cwd).events[s.id];
157
+ expect(finalEvents.map((e) => e.id)).toEqual(['e1', 'e2', 'e3']);
158
+ });
159
+
160
+ // Electron's structured-clone IPC transport (unlike JSON.parse) can produce
161
+ // an own-enumerable "__proto__" key on a cloned object. A plain-object merge
162
+ // target's bracket assignment with that key would re-link its own prototype
163
+ // instead of storing a normal entry, silently dropping/corrupting the Epic
164
+ // and potentially breaking every later Object.keys/JSON.stringify over it.
165
+ test('a "__proto__"-keyed session id merges as an ordinary entry, not a prototype pollution', async () => {
166
+ const cwd = await mkCwd();
167
+ const polluted = session('__proto__', { goalText: 'polluted' });
168
+ // Computed key, not `{ __proto__: polluted }` — the latter is special-cased
169
+ // by object-literal syntax to set the prototype rather than create an own
170
+ // enumerable "__proto__" property, which wouldn't exercise this hazard.
171
+ await mergeActiveIndex(cwd, { sessions: { ['__proto__']: polluted }, events: {} });
172
+
173
+ const index = readActiveIndex(cwd);
174
+ expect(Object.prototype.hasOwnProperty.call(index.sessions, '__proto__')).toBe(true);
175
+ expect(index.sessions.__proto__).toEqual(polluted);
176
+ expect(Object.getPrototypeOf({})).toBe(Object.prototype);
177
+ });
178
+
179
+ test('falls back to writing memory state alone when the disk index is corrupt JSON', async () => {
180
+ const cwd = await mkCwd();
181
+ const filePath = path.join(cwd, 'session-manager-operations', 'prompt-sessions', 'active-index.json');
182
+ await fsp.mkdir(path.dirname(filePath), { recursive: true });
183
+ await fsp.writeFile(filePath, '{ not valid json', 'utf8');
184
+
185
+ const s = session('psess-1');
186
+ await mergeActiveIndex(cwd, { sessions: { [s.id]: s }, events: {} });
187
+
188
+ const index = readActiveIndex(cwd);
189
+ expect(index.sessions[s.id]).toEqual(s);
190
+ });
191
+
192
+ // TOCTOU: a mint interleaved with a merge call must survive, since both now
193
+ // serialize through the same withPathLock (epicMint.cjs exports it; this
194
+ // module requires it via the same module — Node's require cache means one
195
+ // lock instance, not two independent maps that could still interleave).
196
+ test('mint-during-persist: an ensureEpic mint interleaved with a merge call survives', async () => {
197
+ const cwd = await mkCwd();
198
+ const existing = session('psess-existing');
199
+ await mergeActiveIndex(cwd, { sessions: { [existing.id]: existing }, events: {} });
200
+
201
+ const [mintResult] = await Promise.all([
202
+ ensureEpic(cwd, { goalText: 'minted during a concurrent merge' }),
203
+ mergeActiveIndex(cwd, { sessions: { [existing.id]: existing }, events: {} }),
204
+ ]);
205
+
206
+ const index = readActiveIndex(cwd);
207
+ expect(index.sessions[mintResult.epicId]).toBeDefined();
208
+ expect(index.sessions[existing.id]).toBeDefined();
209
+ });
210
+
211
+ // The third writer: promptSessionEvents.cjs's appendResponseEventIfKnown
212
+ // (the scheduler's narrow 'scheduler'-delegation append) used to hold its
213
+ // OWN independent lock map — a merge call and a response-event append could
214
+ // still race past each other's stale read. It now imports activeIndexPath/
215
+ // withPathLock from epicMint.cjs (same module, same require-cache instance),
216
+ // so a response-event append interleaved with a merge call must survive too.
217
+ test('a scheduler response-event append interleaved with a merge call survives (third writer shares the same lock)', async () => {
218
+ const cwd = await mkCwd();
219
+ const s = session('psess-1', { status: 'active' });
220
+ await mergeActiveIndex(cwd, {
221
+ sessions: { [s.id]: s },
222
+ events: { [s.id]: [promptEvent('e1', s.id, s.createdAt)] },
223
+ });
224
+
225
+ const [, appended] = await Promise.all([
226
+ mergeActiveIndex(cwd, { sessions: { [s.id]: s }, events: { [s.id]: [promptEvent('e1', s.id, s.createdAt)] } }),
227
+ appendResponseEventIfKnown(cwd, s.id, 'PRD finished'),
228
+ ]);
229
+
230
+ expect(appended).toBe(true);
231
+ const index = readActiveIndex(cwd);
232
+ expect(index.sessions[s.id]).toBeDefined();
233
+ expect(index.events[s.id]).toHaveLength(2);
234
+ expect(index.events[s.id][1].kind).toBe('response');
235
+ });
@@ -66,7 +66,7 @@ test('removeEpic deletes a minted Epic from both sessions and events maps', asyn
66
66
  const minted = await ensureEpic(cwd, { goalText: 'to be rolled back' });
67
67
  expect(readActiveIndex(cwd).sessions[minted.epicId]).toBeDefined();
68
68
 
69
- const removed = removeEpic(cwd, minted.epicId);
69
+ const removed = await removeEpic(cwd, minted.epicId);
70
70
 
71
71
  expect(removed).toBe(true);
72
72
  const index = readActiveIndex(cwd);
@@ -78,7 +78,7 @@ test('removeEpic is a no-op (returns false) for an unknown epicId', async () =>
78
78
  const cwd = await mkCwd();
79
79
  await ensureEpic(cwd, { goalText: 'unrelated epic' });
80
80
 
81
- const removed = removeEpic(cwd, 'nonexistent-epic-id');
81
+ const removed = await removeEpic(cwd, 'nonexistent-epic-id');
82
82
 
83
83
  expect(removed).toBe(false);
84
84
  expect(Object.keys(readActiveIndex(cwd).sessions)).toHaveLength(1);
@@ -105,7 +105,7 @@ for (const pollutedKey of ['__proto__', 'constructor', 'toString', 'hasOwnProper
105
105
 
106
106
  test('removeEpic refuses to report success for the prototype-chain key "__proto__"', async () => {
107
107
  const cwd = await mkCwd();
108
- const removed = removeEpic(cwd, '__proto__');
108
+ const removed = await removeEpic(cwd, '__proto__');
109
109
  expect(removed).toBe(false);
110
110
  });
111
111
 
@@ -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);
@@ -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
+ });
@@ -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,8 +28,10 @@ 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');
34
+ const activeIndexMerge = require('./lib/activeIndexMerge.cjs');
33
35
  const agentLibrary = require('./agentLibrary.cjs');
34
36
  const { resolveBuildTarget } = require('./lib/buildTarget.cjs');
35
37
  const adminHttp = createAdminHttp();
@@ -788,6 +790,7 @@ registerHistoryAggregatorHandlers();
788
790
  registerHistoryDashboardHandlers();
789
791
  pluginInstall.registerPluginInstallHandlers();
790
792
  memoryTool.registerMemoryHandlers();
793
+ activeIndexMerge.registerActiveIndexMergeHandlers();
791
794
  registerMemoryAggregateIpc();
792
795
  registerProjectBriefIpc();
793
796
  registerProjectPagesIpc();
@@ -799,6 +802,12 @@ ipcMain.handle('promptSessionTranscript:read', validated(schemas.promptSessionTr
799
802
  const turns = await promptSessionTranscript.readTurns(cwd, epicId, { limit });
800
803
  return { turns };
801
804
  }));
805
+ // Renderer Epic lifecycle trace (PRD 940) — append-only, no read/list IPC.
806
+ // Kind allowlist enforced by schemas.auditLogAppend, not here.
807
+ ipcMain.handle('auditLog:append', validated(schemas.auditLogAppend, async ({ kind, cwd, epicId, source }) => {
808
+ appendAuditEvent(kind, { cwd, epicId, source });
809
+ return { ok: true };
810
+ }));
802
811
  agentMemory.registerAgentMemoryHandlers();
803
812
  git.register(ipcMain);
804
813
  filesIpc.registerFilesHandlers();