claude-code-session-manager 0.53.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/assets/{TiptapBody-BYG18cfk.js → TiptapBody-B91BqJWI.js} +1 -1
- package/dist/assets/{index-BoOiJJOr.css → index-BEyUC3v9.css} +1 -1
- package/dist/assets/{index-D1v4VU6k.js → index-BYOscSQK.js} +920 -917
- package/dist/index.html +2 -2
- package/package.json +1 -1
- package/src/main/__tests__/activeIndexMerge.test.cjs +235 -0
- package/src/main/__tests__/epicMint.test.cjs +3 -3
- package/src/main/index.cjs +2 -0
- package/src/main/ipcSchemas.cjs +34 -0
- package/src/main/lib/activeIndexMerge.cjs +168 -0
- package/src/main/lib/epicMint.cjs +31 -8
- package/src/main/promptSessionEvents.cjs +7 -24
- package/src/preload/api.d.ts +19 -0
- package/src/preload/index.cjs +5 -0
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-
|
|
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-
|
|
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.
|
|
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
|
|
package/src/main/index.cjs
CHANGED
|
@@ -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();
|
|
@@ -789,6 +790,7 @@ registerHistoryAggregatorHandlers();
|
|
|
789
790
|
registerHistoryDashboardHandlers();
|
|
790
791
|
pluginInstall.registerPluginInstallHandlers();
|
|
791
792
|
memoryTool.registerMemoryHandlers();
|
|
793
|
+
activeIndexMerge.registerActiveIndexMergeHandlers();
|
|
792
794
|
registerMemoryAggregateIpc();
|
|
793
795
|
registerProjectBriefIpc();
|
|
794
796
|
registerProjectPagesIpc();
|
package/src/main/ipcSchemas.cjs
CHANGED
|
@@ -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
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
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 = {
|
|
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
|
-
|
|
18
|
-
|
|
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
|
package/src/preload/api.d.ts
CHANGED
|
@@ -1653,9 +1653,28 @@ export interface SessionManagerAPI {
|
|
|
1653
1653
|
* from notifyOriginatingTab) — lets an already-hydrated Epic pick it up
|
|
1654
1654
|
* live instead of waiting for a restart. */
|
|
1655
1655
|
onEventAppended: (handler: (e: PromptSessionEventAppendedPayload) => void) => () => void;
|
|
1656
|
+
/** Main-side read-merge-write of a cwd's active-index.json
|
|
1657
|
+
* (lib/activeIndexMerge.cjs) — persistActiveIndex sends only its own
|
|
1658
|
+
* in-memory contribution for the cwd; disk stays main's truth, merged
|
|
1659
|
+
* inside the same lock epicMint.cjs's ensureEpic serializes through. */
|
|
1660
|
+
mergeActiveIndex: (payload: PromptSessionsMergeActiveIndexPayload) => Promise<PromptSessionsMergeActiveIndexResult>;
|
|
1656
1661
|
};
|
|
1657
1662
|
}
|
|
1658
1663
|
|
|
1664
|
+
// ─────────────────────────────────── PromptSessions active-index merge
|
|
1665
|
+
export interface PromptSessionsMergeActiveIndexPayload {
|
|
1666
|
+
cwd: string;
|
|
1667
|
+
sessions: Record<string, unknown>;
|
|
1668
|
+
events: Record<string, unknown[]>;
|
|
1669
|
+
removedIds?: string[];
|
|
1670
|
+
source: 'epics';
|
|
1671
|
+
}
|
|
1672
|
+
|
|
1673
|
+
export interface PromptSessionsMergeActiveIndexResult {
|
|
1674
|
+
sessions: Record<string, unknown>;
|
|
1675
|
+
events: Record<string, unknown[]>;
|
|
1676
|
+
}
|
|
1677
|
+
|
|
1659
1678
|
// ─────────────────────────────────── PromptSession event-appended broadcast
|
|
1660
1679
|
|
|
1661
1680
|
export interface PromptSessionEventAppendedPayload {
|
package/src/preload/index.cjs
CHANGED
|
@@ -471,5 +471,10 @@ contextBridge.exposeInMainWorld('api', {
|
|
|
471
471
|
ipcRenderer.on('promptSession:event-appended', listener);
|
|
472
472
|
return () => ipcRenderer.removeListener('promptSession:event-appended', listener);
|
|
473
473
|
},
|
|
474
|
+
/** Main-side read-merge-write of a cwd's active-index.json
|
|
475
|
+
* (lib/activeIndexMerge.cjs) — replaces persistActiveIndex's old
|
|
476
|
+
* renderer-side read-merge-write. Payload carries only this renderer's
|
|
477
|
+
* own in-memory contribution for the cwd; disk stays main's truth. */
|
|
478
|
+
mergeActiveIndex: (payload) => ipcRenderer.invoke('promptSessions:merge-active-index', payload),
|
|
474
479
|
},
|
|
475
480
|
});
|