claude-code-session-manager 0.53.0 → 0.55.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.
@@ -12,7 +12,7 @@ const fs = require('node:fs');
12
12
  const fsp = require('node:fs/promises');
13
13
  const os = require('node:os');
14
14
  const path = require('node:path');
15
- const { listPersonas, openProjects, parseTools } = require('../agentLibrary.cjs');
15
+ const { listPersonas, openProjects, parseTools, savePersona, deletePersona, removeOverride } = require('../agentLibrary.cjs');
16
16
 
17
17
  const tmpDirs = [];
18
18
  afterEach(async () => {
@@ -33,6 +33,15 @@ async function mkTmp(prefix) {
33
33
  // home directory config.cjs's validatePath would otherwise enforce.
34
34
  const identityValidatePath = (p) => p;
35
35
 
36
+ // savePersona/deletePersona/removeOverride take an injectable writeTextAtomic
37
+ // too, since the real config.cjs one enforces its own home-dir validatePath
38
+ // regardless of what's injected for the `validatePath` param here.
39
+ async function fakeWriteTextAtomic(abs, text) {
40
+ await fsp.mkdir(path.dirname(abs), { recursive: true });
41
+ await fsp.writeFile(abs, text, 'utf8');
42
+ return { ok: true, mtimeMs: Date.now() };
43
+ }
44
+
36
45
  test('parseTools splits + trims a comma-separated tools frontmatter value', () => {
37
46
  expect(parseTools('Read, Grep, Glob, Bash')).toEqual(['Read', 'Grep', 'Glob', 'Bash']);
38
47
  expect(parseTools('')).toEqual([]);
@@ -74,6 +83,7 @@ test('listPersonas parses frontmatter and reports overridingProjects for open ta
74
83
  'name: builder',
75
84
  'description: Watch git history and drive the next publish.',
76
85
  'tools: Read, Grep, Glob, Bash',
86
+ 'tags: feature, bug',
77
87
  '---',
78
88
  '',
79
89
  'You are the Builder agent.',
@@ -103,6 +113,8 @@ test('listPersonas parses frontmatter and reports overridingProjects for open ta
103
113
 
104
114
  const byName = Object.fromEntries(personas.map((p) => [p.name, p]));
105
115
  expect(byName.builder.description).toBe('Watch git history and drive the next publish.');
116
+ expect(byName.builder.tags).toEqual(['feature', 'bug']);
117
+ expect(byName.debugger.tags).toEqual([]);
106
118
  expect(byName.builder.tools).toEqual(['Read', 'Grep', 'Glob', 'Bash']);
107
119
  expect(byName.builder.body).toContain('You are the Builder agent.');
108
120
  expect(byName.builder.overridingProjects).toEqual([path.basename(projectWithOverlay)]);
@@ -131,3 +143,73 @@ test('listPersonas skips a project cwd that validatePath rejects, rather than th
131
143
  expect(personas).toHaveLength(1);
132
144
  expect(personas[0].overridingProjects).toEqual([]);
133
145
  });
146
+
147
+ test('savePersona writes frontmatter + body, and rejects a non-slug name', async () => {
148
+ const globalDir = await mkTmp('sm-agent-library-save-');
149
+ await savePersona({
150
+ name: 'my-agent',
151
+ description: 'Does a thing.',
152
+ tools: ['Read', 'Grep'],
153
+ model: 'opus',
154
+ color: 'blue',
155
+ tags: ['feature', 'bug'],
156
+ body: 'You are my-agent.',
157
+ globalDir,
158
+ validatePath: identityValidatePath,
159
+ writeTextAtomic: fakeWriteTextAtomic,
160
+ });
161
+ const text = await fsp.readFile(path.join(globalDir, 'my-agent.md'), 'utf8');
162
+ expect(text).toContain('name: my-agent');
163
+ expect(text).toContain('description: Does a thing.');
164
+ expect(text).toContain('tools: Read, Grep');
165
+ expect(text).toContain('model: opus');
166
+ expect(text).toContain('color: blue');
167
+ expect(text).toContain('tags: feature, bug');
168
+ expect(text).toContain('You are my-agent.');
169
+
170
+ await expect(
171
+ savePersona({ name: 'Not Valid', body: '', globalDir, validatePath: identityValidatePath, writeTextAtomic: fakeWriteTextAtomic }),
172
+ ).rejects.toThrow(/lowercase, hyphenated/);
173
+ });
174
+
175
+ test('savePersona with originalName renames — writes the new file and removes the old one', async () => {
176
+ const globalDir = await mkTmp('sm-agent-library-rename-');
177
+ await fsp.writeFile(path.join(globalDir, 'old-name.md'), '---\nname: old-name\n---\nBody.\n');
178
+ await savePersona({
179
+ name: 'new-name',
180
+ originalName: 'old-name',
181
+ description: '',
182
+ tools: [],
183
+ model: 'inherit',
184
+ color: '',
185
+ body: 'Body.',
186
+ globalDir,
187
+ validatePath: identityValidatePath,
188
+ writeTextAtomic: fakeWriteTextAtomic,
189
+ });
190
+ expect(fs.existsSync(path.join(globalDir, 'new-name.md'))).toBe(true);
191
+ expect(fs.existsSync(path.join(globalDir, 'old-name.md'))).toBe(false);
192
+ });
193
+
194
+ test('deletePersona removes the file, and is a no-op when it is already gone', async () => {
195
+ const globalDir = await mkTmp('sm-agent-library-delete-');
196
+ await fsp.writeFile(path.join(globalDir, 'gone-soon.md'), '---\nname: gone-soon\n---\nBody.\n');
197
+ await deletePersona({ name: 'gone-soon', globalDir, validatePath: identityValidatePath });
198
+ expect(fs.existsSync(path.join(globalDir, 'gone-soon.md'))).toBe(false);
199
+ await expect(deletePersona({ name: 'gone-soon', globalDir, validatePath: identityValidatePath })).resolves.toEqual({ ok: true });
200
+ });
201
+
202
+ test('removeOverride deletes a project overlay resolved by project name, and rejects an unknown project', async () => {
203
+ const project = await mkTmp('sm-agent-library-override-');
204
+ await fsp.mkdir(path.join(project, '.claude', 'agents'), { recursive: true });
205
+ const overlayPath = path.join(project, '.claude', 'agents', 'builder.md');
206
+ await fsp.writeFile(overlayPath, 'overlay\n');
207
+ const loadSessions = async () => ({ tabs: [{ cwd: project }] });
208
+
209
+ await removeOverride({ name: 'builder', projectName: path.basename(project), loadSessions, validatePath: identityValidatePath });
210
+ expect(fs.existsSync(overlayPath)).toBe(false);
211
+
212
+ await expect(
213
+ removeOverride({ name: 'builder', projectName: 'not-open', loadSessions, validatePath: identityValidatePath }),
214
+ ).rejects.toThrow(/project not open/);
215
+ });
@@ -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
 
@@ -31,6 +31,101 @@ function parseTools(raw) {
31
31
  return raw.split(',').map((t) => t.trim()).filter(Boolean);
32
32
  }
33
33
 
34
+ /** Filename-safe persona name: lowercase, hyphenated, matches the `.md` files on disk. */
35
+ const PERSONA_NAME_RE = /^[a-z][a-z0-9-]*$/;
36
+
37
+ /**
38
+ * Serializes a persona's frontmatter + body back to `.md` file text.
39
+ * `tags` is a Claude-Code-agnostic extension of the frontmatter (Claude Code
40
+ * itself never reads it) — the Epic intent tags (tagLibrary.ts's TAG_LIBRARY)
41
+ * this persona is associated with, so Agent Library and Tag Library can each
42
+ * assign/remove the relationship from either side. Stored the same
43
+ * comma-list way as `tools` for one parser to cover both.
44
+ */
45
+ function serializePersona({ name, description, tools, model, color, tags, body }) {
46
+ const lines = ['---', `name: ${name}`];
47
+ if (description) lines.push(`description: ${description}`);
48
+ if (tools && tools.length) lines.push(`tools: ${tools.join(', ')}`);
49
+ if (model && model !== 'inherit') lines.push(`model: ${model}`);
50
+ if (color) lines.push(`color: ${color}`);
51
+ if (tags && tags.length) lines.push(`tags: ${tags.join(', ')}`);
52
+ lines.push('---', '');
53
+ return lines.join('\n') + (body || '').trim() + '\n';
54
+ }
55
+
56
+ /**
57
+ * Writes a global persona `.md` file — `~/.claude/agents/<name>.md`. Pass
58
+ * `originalName` when renaming an existing persona so the old file is
59
+ * removed once the new one is written (no-op if names match).
60
+ */
61
+ async function savePersona({
62
+ name,
63
+ originalName,
64
+ description,
65
+ tools,
66
+ model,
67
+ color,
68
+ tags,
69
+ body,
70
+ globalDir = path.join(os.homedir(), '.claude', 'agents'),
71
+ validatePath = configMgr.validatePath,
72
+ writeTextAtomic = configMgr.writeTextAtomic,
73
+ } = {}) {
74
+ if (!PERSONA_NAME_RE.test(name || '')) {
75
+ throw new Error('agent name must be lowercase, hyphenated (e.g. "my-agent")');
76
+ }
77
+ const target = validatePath(path.join(globalDir, `${name}.md`));
78
+ const text = serializePersona({ name, description, tools, model, color, tags, body });
79
+ await writeTextAtomic(target, text);
80
+ if (originalName && originalName !== name) {
81
+ const oldReal = validatePath(path.join(globalDir, `${originalName}.md`));
82
+ try {
83
+ await fsp.unlink(oldReal);
84
+ } catch (e) {
85
+ if (e.code !== 'ENOENT') throw e;
86
+ }
87
+ }
88
+ return { ok: true, path: target };
89
+ }
90
+
91
+ /** Deletes a global persona `.md` file. Idempotent — missing file is not an error. */
92
+ async function deletePersona({
93
+ name,
94
+ globalDir = path.join(os.homedir(), '.claude', 'agents'),
95
+ validatePath = configMgr.validatePath,
96
+ } = {}) {
97
+ const target = validatePath(path.join(globalDir, `${name}.md`));
98
+ try {
99
+ await fsp.unlink(target);
100
+ } catch (e) {
101
+ if (e.code !== 'ENOENT') throw e;
102
+ }
103
+ return { ok: true };
104
+ }
105
+
106
+ /**
107
+ * Drops a project's local override (`<cwd>/.claude/agents/<name>.md`) so the
108
+ * global definition applies there again. `projectName` is resolved against
109
+ * currently-open tabs (same source `listPersonas` uses for overridingProjects).
110
+ */
111
+ async function removeOverride({
112
+ name,
113
+ projectName,
114
+ loadSessions = sessionsStore.load,
115
+ validatePath = configMgr.validatePath,
116
+ } = {}) {
117
+ const projects = await openProjects({ loadSessions });
118
+ const project = projects.find((p) => p.name === projectName);
119
+ if (!project) throw new Error(`project not open: ${projectName}`);
120
+ const target = validatePath(path.join(project.cwd, '.claude', 'agents', `${name}.md`));
121
+ try {
122
+ await fsp.unlink(target);
123
+ } catch (e) {
124
+ if (e.code !== 'ENOENT') throw e;
125
+ }
126
+ return { ok: true };
127
+ }
128
+
34
129
  /**
35
130
  * Currently-open project tabs, deduped by cwd, as `{ cwd, name }` — `name`
36
131
  * is the last path segment (matches the `projectNameFromCwd` convention used
@@ -103,6 +198,9 @@ async function listPersonas({
103
198
  name: fm.name || fallbackName,
104
199
  description: fm.description || null,
105
200
  tools: parseTools(fm.tools),
201
+ model: fm.model || null,
202
+ color: fm.color || null,
203
+ tags: parseTools(fm.tags),
106
204
  path: real,
107
205
  body: body.trim(),
108
206
  overridingProjects,
@@ -112,4 +210,12 @@ async function listPersonas({
112
210
  return personas;
113
211
  }
114
212
 
115
- module.exports = { listPersonas, openProjects, parseTools };
213
+ module.exports = {
214
+ listPersonas,
215
+ openProjects,
216
+ parseTools,
217
+ savePersona,
218
+ deletePersona,
219
+ removeOverride,
220
+ PERSONA_NAME_RE,
221
+ };
@@ -31,6 +31,7 @@ const prdCreate = require('./lib/prdCreate.cjs');
31
31
  const { appendAuditEvent } = require('./lib/auditLog.cjs');
32
32
  const chatRunner = require('./chatRunner.cjs');
33
33
  const promptSessionEvents = require('./promptSessionEvents.cjs');
34
+ const activeIndexMerge = require('./lib/activeIndexMerge.cjs');
34
35
  const agentLibrary = require('./agentLibrary.cjs');
35
36
  const { resolveBuildTarget } = require('./lib/buildTarget.cjs');
36
37
  const adminHttp = createAdminHttp();
@@ -453,6 +454,13 @@ ipcMain.handle('mcp:status', () => probeMcpStatus());
453
454
  // personas plus, per currently-open project tab, whether that project
454
455
  // overlays the same agent name at `<cwd>/.claude/agents/<name>.md`. Read-only.
455
456
  ipcMain.handle('agents:list-personas', () => agentLibrary.listPersonas());
457
+ // Agent Library CRUD — global personas only (`~/.claude/agents/*.md`); a
458
+ // project's local overlay can only be removed here (removeOverride), never
459
+ // created, since Agent Library is a Home-face, machine-wide surface with no
460
+ // per-tab cwd context of its own.
461
+ ipcMain.handle('agents:save-persona', (_e, payload) => agentLibrary.savePersona(payload));
462
+ ipcMain.handle('agents:delete-persona', (_e, payload) => agentLibrary.deletePersona(payload));
463
+ ipcMain.handle('agents:remove-override', (_e, payload) => agentLibrary.removeOverride(payload));
456
464
 
457
465
  ipcMain.handle('app:engage-rules-path', () => process.env.SESSION_MANAGER_ENGAGE_RULES || null);
458
466
 
@@ -789,6 +797,7 @@ registerHistoryAggregatorHandlers();
789
797
  registerHistoryDashboardHandlers();
790
798
  pluginInstall.registerPluginInstallHandlers();
791
799
  memoryTool.registerMemoryHandlers();
800
+ activeIndexMerge.registerActiveIndexMergeHandlers();
792
801
  registerMemoryAggregateIpc();
793
802
  registerProjectBriefIpc();
794
803
  registerProjectPagesIpc();
@@ -214,6 +214,39 @@ const configWatch = z.array(z.string().min(1).max(4096));
214
214
 
215
215
  const configParseImports = z.object({ path: z.string().min(1).max(4096) });
216
216
 
217
+ // ──────────────────────────────────────────── PromptSessions (Epics) active-index merge
218
+ // lib/activeIndexMerge.cjs's read-merge-write, invoked over IPC by the
219
+ // renderer's persistActiveIndex (state/promptSessions.ts) instead of that
220
+ // module doing its own config:read-json/config:write-json round trip. Payload
221
+ // carries only the CALLING renderer's in-memory contribution for this cwd —
222
+ // disk is main's truth, merged inside the same withPathLock epicMint.cjs uses.
223
+ //
224
+ // Epic ids are keys here (Electron's structured-clone IPC transport, unlike
225
+ // JSON.parse, can produce an own-enumerable "__proto__" key on a cloned
226
+ // object) — the regex excludes the prototype-pollution trio so a malformed/
227
+ // hostile payload fails validation before it ever reaches activeIndexMerge.cjs
228
+ // (which additionally uses null-prototype merge targets as defense in depth).
229
+ // Real epic ids are `${slugify(goalText)}-${uuid8}` (epicMint.cjs) — max 64
230
+ // mirrors slugify's 48-char cap plus the "-" + 8 hex chars suffix, rounded up.
231
+ const PROMPT_SESSION_ID_RE = /^(?!__proto__$|constructor$|prototype$)[A-Za-z0-9_-]{1,64}$/;
232
+ const promptSessionsMergeActiveIndex = z.object({
233
+ cwd: z.string().min(1).max(4096),
234
+ sessions: z.record(z.string().regex(PROMPT_SESSION_ID_RE), z.unknown()).refine(
235
+ (v) => Object.keys(v).length <= 2000,
236
+ { message: 'too many sessions in one merge payload' },
237
+ ),
238
+ events: z.record(z.string().regex(PROMPT_SESSION_ID_RE), z.array(z.unknown()).max(5000)).refine(
239
+ (v) => Object.keys(v).length <= 2000,
240
+ { message: 'too many event chains in one merge payload' },
241
+ ),
242
+ removedIds: z.array(z.string().regex(PROMPT_SESSION_ID_RE)).max(500).optional(),
243
+ // Always the literal 'epics' on the wire — the handler ignores this value
244
+ // and hardcodes the writer id itself (single-writer law,
245
+ // lib/opsOwnership.cjs); requiring the literal here just means a
246
+ // forged/malformed payload fails validation before it reaches the merge.
247
+ source: z.literal('epics'),
248
+ });
249
+
217
250
  // ──────────────────────────────────────────── Sessions
218
251
  const sessionsPayload = z.object({
219
252
  tabs: z.array(z.object({
@@ -908,6 +941,7 @@ module.exports = {
908
941
  projectBriefUpdate,
909
942
  promptSessionTranscriptAppend,
910
943
  promptSessionTranscriptRead,
944
+ promptSessionsMergeActiveIndex,
911
945
  auditLogAppend,
912
946
  agentMemoryList,
913
947
  agentMemoryGet,
@@ -0,0 +1,168 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * activeIndexMerge.cjs — main-process read-merge-write for a cwd's
5
+ * prompt-sessions/active-index.json, invoked over IPC by the renderer's
6
+ * persistActiveIndex (state/promptSessions.ts). Moved here from the renderer
7
+ * (which used to read-merge-write itself over config:read-json/
8
+ * config:write-json, commit 3d12e19) to close two holes a purely-renderer
9
+ * merge can't:
10
+ *
11
+ * 1. Resurrection: a second window that still holds an archived/deleted
12
+ * Epic as 'active'/'proposed' in its stale in-memory snapshot would
13
+ * write that row right back on its next persist — "memory wins on id
14
+ * collision" doesn't know the row was deliberately removed elsewhere.
15
+ * Fixed with a removal tombstone (index.tombstones, id -> removedAt
16
+ * ISO): an id present there is dropped from every future merge's memory
17
+ * contribution, not just from the write that performed the removal.
18
+ * 2. TOCTOU: a renderer's own read (config:read-json) and write
19
+ * (config:write-json), round-tripped over IPC with no lock in between,
20
+ * left a gap where a main-process mint (epicMint.cjs's ensureEpic,
21
+ * itself serialized through its own withPathLock) could land and then
22
+ * be silently dropped by the renderer's subsequent write. Running the
23
+ * merge inside the SAME withPathLock instance epicMint.cjs already
24
+ * serializes ensureEpic/appendPrdCreatedEvent through closes the gap:
25
+ * every read-modify-write of this file, from either caller, is now one
26
+ * lock (epicMint.cjs exports withPathLock; requiring it here shares the
27
+ * same module-level Map via Node's require cache, not a second one).
28
+ *
29
+ * Tombstones are never auto-cleared: resumeArchived() (state/promptSessions.ts)
30
+ * always mints a BRAND NEW Epic id + claudeSessionId — it never reuses the
31
+ * archived id — so a tombstoned id is never legitimately reborn under the
32
+ * current domain model. If that ever changes, this module would need an
33
+ * explicit "un-tombstone" path; today one would be dead code.
34
+ */
35
+
36
+ const { activeIndexPath, withPathLock } = require('./epicMint.cjs');
37
+ const config = require('../config.cjs');
38
+
39
+ function hasOwn(obj, key) {
40
+ return Object.prototype.hasOwnProperty.call(obj, key);
41
+ }
42
+
43
+ // Mirrors state/promptSessions.ts's compareEventsChainAware exactly. Kept as
44
+ // a separate copy (not a shared module) since one side runs in the renderer
45
+ // and the other in Node — small and stable enough that duplicating it here
46
+ // is cheaper than wiring a cross-boundary shared module for one function.
47
+ function compareEventsChainAware(a, b) {
48
+ const ta = Date.parse(a.at);
49
+ const tb = Date.parse(b.at);
50
+ const aValid = !Number.isNaN(ta);
51
+ const bValid = !Number.isNaN(tb);
52
+ if (aValid && bValid && ta !== tb) return ta - tb;
53
+ if (b.causedByEventId === a.id) return -1;
54
+ if (a.causedByEventId === b.id) return 1;
55
+ if (aValid && bValid) return 0;
56
+ if (aValid) return -1;
57
+ if (bValid) return 1;
58
+ return 0;
59
+ }
60
+
61
+ /**
62
+ * mergeActiveIndex(cwd, { sessions, events, removedIds }) → Promise<{ sessions, events }>
63
+ *
64
+ * `sessions`/`events` are the CALLING renderer's own in-memory contribution
65
+ * for this cwd only (already filtered to 'active'/'proposed' sessions by
66
+ * persistActiveIndex) — never a full merged file; disk remains the source of
67
+ * truth this function reads and reconciles against. Returns the merged
68
+ * { sessions, events } actually written.
69
+ */
70
+ async function mergeActiveIndex(cwd, { sessions: rawMemorySessions = {}, events: rawMemoryEvents = {}, removedIds = [] } = {}) {
71
+ if (!cwd || typeof cwd !== 'string') throw new Error('mergeActiveIndex: cwd is required');
72
+ const filePath = activeIndexPath(cwd);
73
+ // Null-prototype copies of the CALLER'S OWN payload too: an Epic id equal
74
+ // to "__proto__" is a plain string key from the caller's perspective, but
75
+ // `rawMemoryEvents[id]` on a plain object literal resolves through the
76
+ // prototype chain to Object.prototype itself (not undefined) rather than
77
+ // hitting the `?? []` fallback below — same hazard as the disk-side merge
78
+ // targets, just on the read side instead of the write side.
79
+ const memorySessions = Object.assign(Object.create(null), rawMemorySessions);
80
+ const memoryEvents = Object.assign(Object.create(null), rawMemoryEvents);
81
+ return withPathLock(filePath, async () => {
82
+ // Null-prototype targets: `sessions`/`events` are keyed by Epic id, which
83
+ // for an IPC payload delivered via Electron's structured-clone (not
84
+ // JSON.parse) could legitimately carry an own-enumerable "__proto__" key.
85
+ // A plain-object target's bracket assignment (`obj[id] = x`) with
86
+ // id === "__proto__" invokes Object.prototype's accessor and re-links the
87
+ // object's own prototype instead of storing a normal entry — the same
88
+ // hazard epicMint.cjs's hasOwn() helper guards reads against (see its
89
+ // comment ~epicMint.cjs:61). A null-prototype target has no such accessor,
90
+ // so "__proto__"/"constructor"/etc. behave as ordinary keys.
91
+ let diskSessions = Object.create(null);
92
+ let diskEvents = Object.create(null);
93
+ let tombstones = Object.create(null);
94
+ const result = await config.readJson(filePath);
95
+ if (result.exists && result.data) {
96
+ const disk = result.data;
97
+ if (disk.sessions && typeof disk.sessions === 'object') Object.assign(diskSessions, disk.sessions);
98
+ if (disk.events && typeof disk.events === 'object') Object.assign(diskEvents, disk.events);
99
+ if (disk.tombstones && typeof disk.tombstones === 'object') Object.assign(tombstones, disk.tombstones);
100
+ } else if (result.error || result.parseError) {
101
+ // Genuine unreadable/corrupt disk index (fs error, or valid file that
102
+ // failed JSON.parse) rather than "file legitimately doesn't exist yet".
103
+ // config.readJson never throws (it reports failure via exists/error/
104
+ // parseError, not exceptions), so this branch — not a try/catch — is
105
+ // the only way to detect it. Falling back to this caller's own memory
106
+ // contribution is still the best-effort choice (matches the old
107
+ // renderer merge's contract), but silently doing so would recreate the
108
+ // exact "unlogged data loss" incident this module exists to close —
109
+ // any on-disk tombstones/other-window sessions get dropped, so log it.
110
+ console.warn(
111
+ `[activeIndexMerge] disk index unreadable for ${filePath} (error=${result.error ?? 'none'}, `
112
+ + `parseError=${result.parseError ?? 'none'}) — falling back to this caller's in-memory contribution alone`,
113
+ );
114
+ }
115
+
116
+ const now = new Date().toISOString();
117
+ for (const id of removedIds) tombstones[id] = now;
118
+
119
+ const mergedSessions = Object.assign(Object.create(null), diskSessions);
120
+ for (const [id, session] of Object.entries(memorySessions)) {
121
+ // Resurrection guard: a caller whose in-memory snapshot still thinks a
122
+ // tombstoned id is open (it hasn't hydrated the removal yet) must not
123
+ // write it back into the merged file.
124
+ if (hasOwn(tombstones, id)) continue;
125
+ mergedSessions[id] = session;
126
+ }
127
+ for (const id of removedIds) delete mergedSessions[id];
128
+
129
+ // Events are a per-id UNION (same contract the renderer's own merge used
130
+ // — PRD 855): a scheduler job appends events to disk under its narrow
131
+ // delegation, so memory-wins-wholesale would silently drop any event
132
+ // that landed since this caller's last hydrate. Memory order wins for
133
+ // events both sides know; disk-only events are appended and re-sorted
134
+ // chain-aware.
135
+ const mergedEvents = Object.create(null);
136
+ for (const id of Object.keys(mergedSessions)) {
137
+ const memEvts = memoryEvents[id] ?? [];
138
+ const diskEvts = diskEvents[id] ?? [];
139
+ if (memEvts.length === 0) {
140
+ mergedEvents[id] = diskEvts;
141
+ continue;
142
+ }
143
+ const memIds = new Set(memEvts.map((e) => e.id));
144
+ const missing = diskEvts.filter((e) => !memIds.has(e.id));
145
+ mergedEvents[id] = missing.length > 0 ? [...memEvts, ...missing].sort(compareEventsChainAware) : memEvts;
146
+ }
147
+
148
+ const index = { sessions: mergedSessions, events: mergedEvents, tombstones };
149
+ // Single-writer law: prompt-sessions/ is owned by 'epics'
150
+ // (lib/opsOwnership.cjs) — hardcoded here, never taken from the IPC
151
+ // payload, so a compromised/forged renderer call can't claim a
152
+ // different writer id.
153
+ await config.writeJson(filePath, index, { writer: 'epics' });
154
+ return { sessions: mergedSessions, events: mergedEvents };
155
+ });
156
+ }
157
+
158
+ function registerActiveIndexMergeHandlers() {
159
+ const { ipcMain } = require('electron');
160
+ const { schemas: s, validated: v } = require('../ipcSchemas.cjs');
161
+ ipcMain.handle(
162
+ 'promptSessions:merge-active-index',
163
+ v(s.promptSessionsMergeActiveIndex, ({ cwd, sessions, events, removedIds }) =>
164
+ mergeActiveIndex(cwd, { sessions, events, removedIds: removedIds || [] })),
165
+ );
166
+ }
167
+
168
+ module.exports = { mergeActiveIndex, registerActiveIndexMergeHandlers };
@@ -34,9 +34,16 @@ function readActiveIndex(cwd) {
34
34
  return {
35
35
  sessions: parsed && typeof parsed.sessions === 'object' && parsed.sessions ? parsed.sessions : {},
36
36
  events: parsed && typeof parsed.events === 'object' && parsed.events ? parsed.events : {},
37
+ // Passed through untouched — this module never consults tombstones (it
38
+ // doesn't resurrect completed/deleted Epics on its own), but every
39
+ // caller here does read-modify-write on the object this returns, so
40
+ // dropping the field would silently erase the removal tombstones
41
+ // lib/activeIndexMerge.cjs's mergeActiveIndex records the next time any
42
+ // of ensureEpic/appendPrdCreatedEvent/removeEpic writes this file.
43
+ tombstones: parsed && typeof parsed.tombstones === 'object' && parsed.tombstones ? parsed.tombstones : {},
37
44
  };
38
45
  } catch {
39
- return { sessions: {}, events: {} };
46
+ return { sessions: {}, events: {}, tombstones: {} };
40
47
  }
41
48
  }
42
49
 
@@ -374,12 +381,28 @@ function appendPrdCreatedEvent(cwd, epicId, prdSlug, text) {
374
381
  */
375
382
  function removeEpic(cwd, epicId) {
376
383
  if (!cwd || !epicId) return false;
377
- const index = readActiveIndex(cwd);
378
- if (!hasOwn(index.sessions, epicId)) return false;
379
- delete index.sessions[epicId];
380
- delete index.events[epicId];
381
- writeActiveIndex(cwd, index);
382
- return true;
384
+ return withPathLock(activeIndexPath(cwd), () => {
385
+ const index = readActiveIndex(cwd);
386
+ if (!hasOwn(index.sessions, epicId)) return false;
387
+ delete index.sessions[epicId];
388
+ delete index.events[epicId];
389
+ writeActiveIndex(cwd, index);
390
+ return true;
391
+ });
383
392
  }
384
393
 
385
- module.exports = { ensureEpic, appendPrdCreatedEvent, removeEpic, activeIndexPath, readActiveIndex, findJoinableEpic, tokenize };
394
+ module.exports = {
395
+ ensureEpic,
396
+ appendPrdCreatedEvent,
397
+ removeEpic,
398
+ activeIndexPath,
399
+ readActiveIndex,
400
+ findJoinableEpic,
401
+ tokenize,
402
+ // Exported so lib/activeIndexMerge.cjs's renderer-facing merge IPC handler
403
+ // serializes through the SAME lock instance as ensureEpic/
404
+ // appendPrdCreatedEvent (module-level Map, shared via Node's require
405
+ // cache) — one lock per active-index.json path across both callers, not
406
+ // two independent lock maps that could still interleave.
407
+ withPathLock,
408
+ };
@@ -13,10 +13,13 @@
13
13
  */
14
14
 
15
15
  const config = require('./config.cjs');
16
-
17
- function promptSessionActiveIndexPath(cwd) {
18
- return `${String(cwd).replace(/\/+$/, '')}/session-manager-operations/prompt-sessions/active-index.json`;
19
- }
16
+ // activeIndexPath/withPathLock come from epicMint.cjs so this module's
17
+ // read-modify-write of active-index.json (appendResponseEventIfKnown, below)
18
+ // serializes through the EXACT SAME lock instance as ensureEpic/
19
+ // appendPrdCreatedEvent and lib/activeIndexMerge.cjs's mergeActiveIndex — one
20
+ // lock across all three writers of this file, not three independent maps
21
+ // that could still interleave a stale read-modify-write past each other.
22
+ const { activeIndexPath: promptSessionActiveIndexPath, withPathLock } = require('./lib/epicMint.cjs');
20
23
 
21
24
  // IPC channel broadcast whenever an event is appended to a PromptSession's
22
25
  // chain from the main process (currently only the scheduler's response-event
@@ -40,26 +43,6 @@ function mintEventId() {
40
43
  return `pevt-${Date.now().toString(36)}-${seq}`;
41
44
  }
42
45
 
43
- // Serializes read-modify-write cycles per active-index.json path so two
44
- // PRDs finishing back-to-back for sessions in the same cwd chain onto the
45
- // tail in issue order instead of racing two independent read-then-write
46
- // round trips (mirrors promptSessions.ts's own pendingWritesByPath).
47
- const pendingWritesByPath = new Map();
48
-
49
- function withPathLock(path, task) {
50
- const prior = pendingWritesByPath.get(path) || Promise.resolve();
51
- const settle = () => task();
52
- const run = prior.then(settle, settle);
53
- pendingWritesByPath.set(
54
- path,
55
- run.then(
56
- () => undefined,
57
- () => undefined,
58
- ),
59
- );
60
- return run;
61
- }
62
-
63
46
  /**
64
47
  * If `sourcePromptId` resolves to a known, still-active PromptSession under
65
48
  * `cwd`'s active-index.json, appends a 'response' event chained to that