dsh-diff-approval 0.13.1 → 0.14.1

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/lib/index.js CHANGED
@@ -1,5 +1,4 @@
1
- import { randomUUID } from "node:crypto";
2
- import { mkdir, readFile, rename, rm, writeFile } from "node:fs/promises";
1
+ import { mkdir, readFile, readdir, rename, rm, writeFile } from "node:fs/promises";
3
2
  import { dirname, join, resolve, sep } from "node:path";
4
3
  import { dshHomePath, expandHomePath } from "@deepseek-ai/dsh-home-paths";
5
4
  import { SessionId } from "@deepseek-ai/dsh-session";
@@ -7,51 +6,40 @@ import { spawn } from "node:child_process";
7
6
  import { existsSync } from "node:fs";
8
7
  //#region lib/types/pending.js
9
8
  /**
10
- * In-memory pending-diff store: one entry per (session, path), holding the
11
- * file's full set of unhandled changes as one cumulative span. Every later
12
- * operation to a tracked path folds into its entry `oldText` stays the
13
- * earliest basis, `newText` takes the latest contenteven when the chain
14
- * breaks (an outside writer changed the file between operations): the list
15
- * still shows one element per file. Pure state and transitions; the plugin
9
+ * In-memory pending-diff store: one entry per file path, globally, holding the
10
+ * file's full set of unhandled changes as one cumulative span (earliest basis →
11
+ * latest content) across every session and workspace that touched it. Every
12
+ * later operation to a tracked path folds into its entry `oldText` stays the
13
+ * earliest basis, `newText` takes the latest content, and the touching sessions
14
+ * accumulate in `sessionIds` even when the chain breaks (an outside writer
15
+ * changed the file between operations). Pure state and transitions; the plugin
16
16
  * body owns the `tools/result` observation, the RPC surface, and the
17
17
  * filesystem I/O.
18
18
  * @module dsh-diff-approval/src/pending
19
19
  */
20
- /** Map key joining one session id and one entry id. */
21
- function entryKey(sessionId, id) {
22
- return `${String(sessionId)}\u0000${id}`;
20
+ /** Map key: the file path (the single global identity of a pending change). */
21
+ function pathKeyOf(path) {
22
+ return path;
23
23
  }
24
- /** Map key joining one session id and one path, indexing the path's current entry. */
25
- function pathKey(sessionId, path) {
26
- return `${String(sessionId)}\u0000${path}`;
24
+ /** The session ids an entry was touched by (tolerant of legacy rows without the field). */
25
+ function touchedBy(entry) {
26
+ if (Array.isArray(entry.sessionIds) && entry.sessionIds.length > 0) return entry.sessionIds;
27
+ return [entry.sessionId];
27
28
  }
28
29
  /** Whether a merge changed nothing (a repeated operation is a no-op). */
29
30
  function sameEntry(left, right) {
30
- return left.path === right.path && left.kind === right.kind && left.oldText === right.oldText && left.newText === right.newText && left.updatedAt === right.updatedAt;
31
+ const lIds = touchedBy(left);
32
+ const rIds = touchedBy(right);
33
+ return left.path === right.path && left.kind === right.kind && left.oldText === right.oldText && left.newText === right.newText && left.updatedAt === right.updatedAt && left.sessionId === right.sessionId && lIds.length === rIds.length && lIds.every((id, index) => id === rIds[index]);
31
34
  }
32
35
  /**
33
- * The pending-diff store. Keep/Revert decides one whole file at a time.
36
+ * The pending-diff store: one entry per file path across all sessions. Keep /
37
+ * Revert decides one whole file at a time.
34
38
  */
35
39
  var PendingDiffStore = class {
36
40
  entries = /* @__PURE__ */ new Map();
37
- pathIndex = /* @__PURE__ */ new Map();
38
- /** Merge one operation into the store, keyed by (session, path). */
39
- merge(entry) {
40
- const key = pathKey(entry.sessionId, entry.path);
41
- const currentId = this.pathIndex.get(key);
42
- const current = currentId === void 0 ? void 0 : this.entries.get(entryKey(entry.sessionId, currentId));
43
- const merged = current === void 0 ? { ...entry } : {
44
- ...current,
45
- newText: entry.newText,
46
- updatedAt: entry.updatedAt
47
- };
48
- if (current !== void 0 && sameEntry(current, merged)) return false;
49
- this.entries.set(entryKey(entry.sessionId, merged.id), merged);
50
- this.pathIndex.set(key, merged.id);
51
- return true;
52
- }
53
41
  /**
54
- * Fold one captured operation into its file's entry. A no-op (equal before
42
+ * Merge one captured operation into its file's entry. A no-op (equal before
55
43
  * and after) folds nothing.
56
44
  * @param entry - the captured operation (id assigned by the caller).
57
45
  * @returns whether the stored entry changed.
@@ -60,103 +48,105 @@ var PendingDiffStore = class {
60
48
  if (entry.oldText === entry.newText) return false;
61
49
  return this.merge(entry);
62
50
  }
51
+ /** Fold a captured entry into the path's single global entry. */
52
+ merge(entry) {
53
+ const current = this.entries.get(pathKeyOf(entry.path));
54
+ const merged = current === void 0 ? { ...entry } : this.mergeEntry(current, entry);
55
+ if (current !== void 0 && sameEntry(current, merged)) return false;
56
+ this.entries.set(pathKeyOf(merged.path), merged);
57
+ return true;
58
+ }
63
59
  /**
64
- * Merge persisted entries into the store, one per path after folding. An
65
- * already-keyed live entry wins over a persisted one: the live entry is
66
- * newer by construction (it was captured after the persisted state was
67
- * written), so the path's whole persisted run is skipped.
68
- * @param sessionId - the session the entries belong to.
69
- * @param entries - persisted entries, oldest capture first, to fold in.
60
+ * Combine a stored entry and a new capture. `oldText` comes from the earlier
61
+ * capture (the file's original basis), `newText` from the later (latest
62
+ * content), and the session set is the union — so the entry always spans the
63
+ * whole pending change regardless of which session contributed which part.
70
64
  */
71
- hydrate(sessionId, entries) {
72
- const livePaths = new Set(this.pathIndex.keys());
73
- for (const entry of entries) {
74
- const key = pathKey(sessionId, entry.path);
75
- if (livePaths.has(key)) continue;
76
- this.merge({
77
- ...entry,
78
- sessionId
79
- });
80
- }
65
+ mergeEntry(a, b) {
66
+ const earlier = a.updatedAt <= b.updatedAt ? a : b;
67
+ const later = earlier === a ? b : a;
68
+ const sessionIds = [.../* @__PURE__ */ new Set([...touchedBy(a), b.sessionId])];
69
+ return {
70
+ ...later,
71
+ id: later.path,
72
+ oldText: earlier.oldText,
73
+ kind: earlier.kind === "create" || later.kind === "create" ? "create" : "edit",
74
+ sessionIds
75
+ };
81
76
  }
82
77
  /**
83
- * Copy the session's entries, oldest capture first.
84
- * @param sessionId - the session whose entries to list.
78
+ * Copy every entry touching a session (the session's view), oldest capture
79
+ * first. The file list is session-scoped: an entry appears for each session
80
+ * that touched it, so a session only sees files it worked on.
81
+ * @param sessionId - the viewing session.
85
82
  * @returns detached entries in capture order.
86
83
  */
87
84
  list(sessionId) {
88
85
  const found = [];
89
- for (const entry of this.entries.values()) if (entry.sessionId === sessionId) found.push(entry);
86
+ for (const entry of this.entries.values()) if (touchedBy(entry).includes(sessionId)) found.push(entry);
90
87
  return found.sort((left, right) => left.updatedAt - right.updatedAt);
91
88
  }
89
+ /** Every entry (all paths, all sessions), oldest capture first. */
90
+ all() {
91
+ return [...this.entries.values()].sort((left, right) => left.updatedAt - right.updatedAt);
92
+ }
92
93
  /**
93
- * Read one entry without removing it.
94
- * @param sessionId - the owning session.
95
- * @param id - the entry id.
96
- * @returns the entry, or `undefined` when the pair has none.
94
+ * Read one path's entry without removing it.
95
+ * @param path - the file path (the global entry key).
96
+ * @returns the entry, or `undefined` when none is tracked.
97
97
  */
98
- get(sessionId, id) {
99
- return this.entries.get(entryKey(sessionId, id));
98
+ get(path) {
99
+ return this.entries.get(pathKeyOf(path));
100
100
  }
101
101
  /**
102
- * Remove one entry.
103
- * @param sessionId - the owning session.
104
- * @param id - the entry id.
102
+ * Remove one path's entry.
103
+ * @param path - the file path.
105
104
  * @returns whether an entry was removed.
106
105
  */
107
- remove(sessionId, id) {
108
- if (!this.entries.delete(entryKey(sessionId, id))) return false;
109
- for (const [key, indexedId] of this.pathIndex) if (indexedId === id) this.pathIndex.delete(key);
110
- return true;
106
+ remove(path) {
107
+ return this.entries.delete(pathKeyOf(path));
111
108
  }
112
109
  /**
113
110
  * Advance one entry's tracked content after a block-level keep/revert. The
114
- * entry keeps its id and path; only the given side's text and capture time
115
- * move. When the caller decides the sides now match, it removes the entry
116
- * instead of updating it.
117
- * @param sessionId - the owning session.
118
- * @param id - the entry id.
111
+ * entry keeps its path; only the given side's text and capture time move.
112
+ * @param path - the file path.
119
113
  * @param patch - the side to advance (`oldText` for keep, `newText` for revert).
120
114
  * @returns whether the entry changed.
121
115
  */
122
- update(sessionId, id, patch) {
123
- const key = entryKey(sessionId, id);
124
- const entry = this.entries.get(key);
116
+ update(path, patch) {
117
+ const entry = this.entries.get(pathKeyOf(path));
125
118
  if (entry === void 0) return false;
126
119
  const next = {
127
120
  ...entry,
128
121
  ...patch,
129
122
  updatedAt: Date.now()
130
123
  };
131
- this.entries.set(key, next);
124
+ this.entries.set(pathKeyOf(path), next);
132
125
  return true;
133
126
  }
134
127
  /**
135
128
  * Restore one entry exactly as given (an undo/redo replays a snapshot). The
136
- * entry is inserted or replaced by id, and the path index points at it so a
137
- * later capture folds into the restored entry. The store keeps one entry per
138
- * path: restoring an entry for a path that a DIFFERENT entry currently owns
139
- * drops that occupant first, so an undo can never leave two list items for
140
- * the same file (e.g. an imported entry and a replayed keep of the same path).
141
- * @param sessionId - the owning session.
129
+ * entry is inserted or replaced by path.
142
130
  * @param entry - the entry state to restore.
143
131
  * @returns whether the store changed.
144
132
  */
145
- restore(sessionId, entry) {
146
- const key = entryKey(sessionId, entry.id);
133
+ restore(entry) {
134
+ const key = pathKeyOf(entry.path);
147
135
  const existing = this.entries.get(key);
148
136
  if (existing !== void 0 && sameEntry(existing, entry)) return false;
149
- const pathKey_ = pathKey(sessionId, entry.path);
150
- const occupantId = this.pathIndex.get(pathKey_);
151
- if (occupantId !== void 0 && occupantId !== entry.id) this.entries.delete(entryKey(sessionId, occupantId));
152
- this.entries.set(key, {
153
- ...entry,
154
- sessionId
155
- });
156
- this.pathIndex.set(pathKey_, entry.id);
137
+ this.entries.set(key, { ...entry });
157
138
  return true;
158
139
  }
159
- /** Total entry count across all sessions. */
140
+ /**
141
+ * Merge persisted entries into the store, one per path after folding. A live
142
+ * entry wins over a persisted one only when its time is newer (folders are
143
+ * applied in capture order, so a later persisted capture is strictly newer).
144
+ * @param entries - persisted entries, oldest capture first, to fold in.
145
+ */
146
+ hydrate(entries) {
147
+ for (const entry of entries) this.merge({ ...entry });
148
+ }
149
+ /** Total entry count (one per tracked file path). */
160
150
  get size() {
161
151
  return this.entries.size;
162
152
  }
@@ -164,182 +154,202 @@ var PendingDiffStore = class {
164
154
  //#endregion
165
155
  //#region lib/types/persist.js
166
156
  /**
167
- * Durable pending-entry persistence under the harness home. One JSON file per
168
- * workspace holds every session's entries, at
169
- * `<storageDir>/<workspaceId>.json`; the default root is
170
- * `<dshHome>/diff-approval/workspaces` and the plugin's `storageDir` config
171
- * relocates it. `save` rewrites the whole workspace file so sibling sessions
172
- * survive; a session with no entries leaves the file, and a workspace with no
173
- * sessions loses its file. Writes are serialized per file, staged as a
174
- * sibling temp file, and atomically renamed, so a crash leaves either the old
175
- * or the new file. Missing files read as empty; corrupt content and unknown
176
- * versions throw so the caller can fail loud instead of silently dropping
177
- * persisted data.
157
+ * Durable pending-entry persistence under the harness home: one global JSON
158
+ * file holding one entry per file path (across all sessions and workspaces), at
159
+ * `<storageDir>/pending.json`; the default root is `<dshHome>/diff-approval/workspaces`
160
+ * and the plugin's `storageDir` config relocates it. Saves rewrite the whole
161
+ * file (the entry set is small), staged as a sibling temp file and atomically
162
+ * renamed, so a crash leaves either the old or the new file. A missing file
163
+ * reads as empty; corrupt content and unknown versions throw.
164
+ *
165
+ * Legacy layout: the pre-global schema stored one JSON file per workspace
166
+ * (`<storageDir>/<workspaceId>.json`, `{ version: 2, sessions: { [sessionId]:
167
+ * PendingEntry[] } }`). On first load without a global file, `loadAll` scans for
168
+ * those files, flattens every session's entries, and reports them so the caller
169
+ * can fold them into the global store once; `save` then writes the global file
170
+ * and deletes the legacy files.
178
171
  * @module dsh-diff-approval/src/persist
179
172
  */
180
173
  /** Default persistence root: this plugin's own directory under the harness home. */
181
174
  function defaultStorageDir() {
182
175
  return dshHomePath("diff-approval", "workspaces");
183
176
  }
184
- /** On-disk envelope version; bumping it abandons older files (pre-release stance). */
185
- const FILE_VERSION = 2;
177
+ /** Global-file version (post per-workspace schema). */
178
+ const FILE_VERSION = 3;
179
+ /** Legacy per-workspace-file version, accepted only for migration. */
180
+ const LEGACY_FILE_VERSION = 2;
186
181
  /** Narrow one JSON value to a pending entry; malformed rows are skipped. */
187
182
  function pendingEntryOf(value) {
188
183
  if (typeof value !== "object" || value === null || Array.isArray(value)) return void 0;
189
- const { id, sessionId, path, kind, oldText, newText, updatedAt } = value;
184
+ const { id, path, kind, oldText, newText, updatedAt, sessionId, sessionIds } = value;
190
185
  if (typeof id !== "string" || id.length === 0) return void 0;
191
- if (typeof sessionId !== "string" || sessionId.length === 0) return void 0;
192
186
  if (typeof path !== "string" || path.length === 0) return void 0;
193
187
  if (kind !== "edit" && kind !== "create") return void 0;
194
188
  if (typeof oldText !== "string" || typeof newText !== "string") return void 0;
195
189
  if (typeof updatedAt !== "number") return void 0;
190
+ if (typeof sessionId !== "string" || sessionId.length === 0) return void 0;
191
+ const ids = Array.isArray(sessionIds) ? sessionIds.filter((value) => typeof value === "string" && value.length > 0) : [];
196
192
  return {
197
- id,
198
- sessionId,
193
+ id: path,
199
194
  path,
200
195
  kind,
201
196
  oldText,
202
197
  newText,
203
- updatedAt
198
+ updatedAt,
199
+ sessionId,
200
+ sessionIds: ids.length > 0 ? ids : [sessionId]
204
201
  };
205
202
  }
206
- /**
207
- * Validate a parsed workspace file. Missing files yield `undefined` (empty);
208
- * anything else that is not a current-version workspace file throws so a
209
- * later save cannot silently overwrite unreadable persisted data.
210
- * @param file - the file path, for the error message.
211
- * @param value - the parsed JSON value.
212
- * @returns the validated file, or `undefined` when the file does not exist.
213
- */
214
- function workspaceFileOf(file, value) {
215
- if (typeof value !== "object" || value === null || Array.isArray(value)) throw new Error(`pending persistence file '${file}' is not a workspace file`);
216
- const { version, sessions } = value;
203
+ /** Validate a parsed global file. */
204
+ function globalFileOf(file, value) {
205
+ if (typeof value !== "object" || value === null || Array.isArray(value)) throw new Error(`pending persistence file '${file}' is not a global file`);
206
+ const { version, entries } = value;
217
207
  if (version !== FILE_VERSION) throw new Error(`pending persistence file '${file}' has unsupported version ${JSON.stringify(version)}`);
218
- if (typeof sessions !== "object" || sessions === null || Array.isArray(sessions)) throw new Error(`pending persistence file '${file}' has no session map`);
208
+ if (!Array.isArray(entries)) throw new Error(`pending persistence file '${file}' has no entry list`);
219
209
  return {
220
210
  version,
221
- sessions
211
+ entries
222
212
  };
223
213
  }
214
+ /** Read one file's content; `undefined` when absent (the normal empty state). */
215
+ async function readJson(file) {
216
+ let raw;
217
+ try {
218
+ raw = await readFile(file, "utf8");
219
+ } catch (error) {
220
+ if (error.code === "ENOENT") return void 0;
221
+ throw error;
222
+ }
223
+ try {
224
+ return JSON.parse(raw);
225
+ } catch (error) {
226
+ throw new Error(`pending persistence file '${file}' is not valid JSON: ${error instanceof Error ? error.message : String(error)}`);
227
+ }
228
+ }
229
+ /** Stage a JSON envelope as a sibling temp file and atomically rename it into place. */
230
+ async function writeJson(file, value) {
231
+ await mkdir(dirName(file), { recursive: true });
232
+ const tmp = `${file}.${process.pid}.${Date.now()}.tmp`;
233
+ await writeFile(tmp, JSON.stringify(value), "utf8");
234
+ try {
235
+ await rename(tmp, file);
236
+ } catch (error) {
237
+ await rm(tmp, { force: true }).catch(() => {});
238
+ throw error;
239
+ }
240
+ }
241
+ function dirName(file) {
242
+ const index = file.lastIndexOf("/");
243
+ return index < 0 ? "." : file.slice(0, index);
244
+ }
224
245
  /**
225
- * File-backed pending entries keyed by (workspace, session).
246
+ * File-backed pending entries, one global file keyed by path.
226
247
  */
227
248
  var PendingPersistence = class {
228
249
  root;
229
250
  tails = /* @__PURE__ */ new Map();
251
+ globalFile;
230
252
  /**
231
- * @param root - directory holding one JSON file per workspace.
253
+ * @param root - directory holding the global `pending.json` (and, pre-migration,
254
+ * one JSON file per workspace).
232
255
  */
233
256
  constructor(root) {
234
257
  this.root = root;
235
- }
236
- fileOf(workspaceId) {
237
- return join(this.root, `${workspaceId}.json`);
238
- }
239
- /** Read one workspace file; a missing file reads as empty. */
240
- async readWorkspace(file) {
241
- let raw;
242
- try {
243
- raw = await readFile(file, "utf8");
244
- } catch (error) {
245
- if (error.code === "ENOENT") return void 0;
246
- throw error;
247
- }
248
- let parsed;
249
- try {
250
- parsed = JSON.parse(raw);
251
- } catch (error) {
252
- throw new Error(`pending persistence file '${file}' is not valid JSON: ${error instanceof Error ? error.message : String(error)}`);
253
- }
254
- return workspaceFileOf(file, parsed);
255
- }
256
- /** Stage the envelope as a sibling temp file and atomically rename it into place. */
257
- async writeWorkspace(file, envelope) {
258
- await mkdir(this.root, { recursive: true });
259
- const tmp = `${file}.${process.pid}.${Date.now()}.tmp`;
260
- await writeFile(tmp, JSON.stringify(envelope), "utf8");
261
- try {
262
- await rename(tmp, file);
263
- } catch (error) {
264
- await rm(tmp, { force: true }).catch(() => {});
265
- throw error;
266
- }
258
+ this.globalFile = join(root, "pending.json");
267
259
  }
268
260
  /**
269
- * Load one session's entries from its workspace file, oldest capture first.
270
- * @param workspaceId - the owning workspace's stable id.
271
- * @param sessionId - the session whose entries to load.
272
- * @returns the persisted entries; empty when none were saved.
261
+ * Load every persisted entry. Returns the global file's entries, or when the
262
+ * global file is absent — flattens the legacy per-workspace files so the
263
+ * caller can fold them once and then save (which clears the legacy files).
264
+ * @returns the entries plus whether they came from the legacy layout (awaiting
265
+ * a save to finalize the migration).
273
266
  */
274
- async load(workspaceId, sessionId) {
275
- const envelope = await this.readWorkspace(this.fileOf(workspaceId));
276
- if (envelope === void 0) return [];
277
- const rows = envelope.sessions[String(sessionId)];
278
- if (!Array.isArray(rows)) return [];
279
- const entries = [];
280
- for (const row of rows) {
281
- const entry = pendingEntryOf(row);
282
- if (entry !== void 0) entries.push(entry);
283
- }
284
- return entries.sort((left, right) => left.updatedAt - right.updatedAt);
285
- }
286
- /**
287
- * Load every session's entries for one workspace, oldest capture first.
288
- * The list shows a workspace's pending changes across sessions — a session
289
- * that restarted carries a fresh id while its earlier entries sit under the
290
- * original session ids in the same workspace file — so hydration reads the
291
- * whole workspace, not one session.
292
- * @param workspaceId - the owning workspace's stable id.
293
- * @returns the persisted entries across all sessions; empty when none were saved.
294
- */
295
- async loadWorkspace(workspaceId) {
296
- const envelope = await this.readWorkspace(this.fileOf(workspaceId));
297
- if (envelope === void 0) return [];
298
- const entries = [];
299
- for (const sessionId of Object.keys(envelope.sessions)) {
300
- const rows = envelope.sessions[sessionId];
301
- if (!Array.isArray(rows)) continue;
302
- for (const row of rows) {
267
+ async loadAll() {
268
+ const global = await readJson(this.globalFile);
269
+ if (global !== void 0) {
270
+ const parsed = globalFileOf(this.globalFile, global);
271
+ const entries = [];
272
+ for (const row of parsed.entries) {
303
273
  const entry = pendingEntryOf(row);
304
274
  if (entry !== void 0) entries.push(entry);
305
275
  }
276
+ return {
277
+ entries: entries.sort((l, r) => l.updatedAt - r.updatedAt),
278
+ migratedLegacy: false
279
+ };
306
280
  }
307
- return entries.sort((left, right) => left.updatedAt - right.updatedAt);
281
+ const legacy = await collectLegacy(this.root);
282
+ if (legacy.length > 0) return {
283
+ entries: legacy.sort((l, r) => l.updatedAt - r.updatedAt),
284
+ migratedLegacy: true
285
+ };
286
+ return {
287
+ entries: [],
288
+ migratedLegacy: false
289
+ };
308
290
  }
309
291
  /**
310
- * Replace one session's entries durably. Saves to one file are serialized;
311
- * a previous save's failure does not block the next one.
312
- * @param workspaceId - the owning workspace's stable id.
313
- * @param sessionId - the session whose entries to replace.
314
- * @param entries - the session's complete entry list, possibly empty.
292
+ * Replace the persisted entry set durably. Saves to the one file are
293
+ * serialized; a previous save's failure does not block the next one. On a
294
+ * legacy migration this also removes the per-workspace files.
295
+ * @param entries - the complete entry list, possibly empty.
315
296
  * @returns resolution after the file is durable.
316
297
  */
317
- save(workspaceId, sessionId, entries) {
318
- const file = this.fileOf(workspaceId);
298
+ save(entries) {
319
299
  const task = async () => {
320
- const envelope = await this.readWorkspace(file);
321
- if (entries.length === 0) {
322
- if (envelope === void 0) return;
323
- delete envelope.sessions[String(sessionId)];
324
- if (Object.keys(envelope.sessions).length === 0) {
325
- await rm(file, { force: true });
326
- return;
327
- }
328
- await this.writeWorkspace(file, envelope);
329
- return;
330
- }
331
- const next = envelope ?? {
300
+ await writeJson(this.globalFile, {
332
301
  version: FILE_VERSION,
333
- sessions: {}
334
- };
335
- next.sessions[String(sessionId)] = [...entries];
336
- await this.writeWorkspace(file, next);
302
+ entries
303
+ });
304
+ await removeLegacy(this.root);
337
305
  };
338
- const run = (this.tails.get(file) ?? Promise.resolve()).then(task, task);
339
- this.tails.set(file, run.catch(() => {}));
306
+ const run = (this.tails.get(this.globalFile) ?? Promise.resolve()).then(task, task);
307
+ this.tails.set(this.globalFile, run.catch(() => {}));
340
308
  return run;
341
309
  }
342
310
  };
311
+ /** Flatten every legacy per-workspace file's entries (adding `sessionIds`). */
312
+ async function collectLegacy(root) {
313
+ let names;
314
+ try {
315
+ names = await readdir(root);
316
+ } catch (error) {
317
+ if (error.code === "ENOENT") return [];
318
+ throw error;
319
+ }
320
+ const entries = [];
321
+ for (const name of names) {
322
+ if (name === "pending.json" || !name.endsWith(".json")) continue;
323
+ const value = await readJson(join(root, name));
324
+ if (value === void 0) continue;
325
+ if (typeof value !== "object" || value === null) continue;
326
+ const { version, sessions } = value;
327
+ if (version !== LEGACY_FILE_VERSION) continue;
328
+ if (typeof sessions !== "object" || sessions === null) continue;
329
+ for (const sessionId of Object.keys(sessions)) {
330
+ const rows = sessions[sessionId];
331
+ if (!Array.isArray(rows)) continue;
332
+ for (const row of rows) {
333
+ const entry = pendingEntryOf(row);
334
+ if (entry !== void 0) entries.push(entry);
335
+ }
336
+ }
337
+ }
338
+ return entries;
339
+ }
340
+ /** Delete the legacy per-workspace files (post-migration cleanup). */
341
+ async function removeLegacy(root) {
342
+ let names;
343
+ try {
344
+ names = await readdir(root);
345
+ } catch {
346
+ return;
347
+ }
348
+ for (const name of names) {
349
+ if (name === "pending.json" || !name.endsWith(".json")) continue;
350
+ await rm(join(root, name), { force: true }).catch(() => {});
351
+ }
352
+ }
343
353
  //#endregion
344
354
  //#region lib/types/open.js
345
355
  /**
@@ -819,11 +829,20 @@ function apply(ctx, config) {
819
829
  const store = new PendingDiffStore();
820
830
  const persistence = new PendingPersistence(resolve(expandHomePath(storageDir ?? defaultStorageDir())));
821
831
  const launchPath = config?.openPath ?? defaultOpenPath;
822
- /** Sessions seen per workspace, so the list can merge a workspace's sessions. */
823
- const sessionsByWorkspace = /* @__PURE__ */ new Map();
824
- /** Workspace ids whose persisted state has been hydrated into the store. */
825
- const loadedWorkspaces = /* @__PURE__ */ new Set();
826
- const loadingWorkspaces = /* @__PURE__ */ new Map();
832
+ /** Hydrate the globally-unique store once from the single persistence file. */
833
+ let loadPromise;
834
+ const ensureLoaded = () => {
835
+ loadPromise ??= (async () => {
836
+ try {
837
+ const { entries, migratedLegacy } = await persistence.loadAll();
838
+ if (entries.length > 0) store.hydrate(entries);
839
+ if (migratedLegacy) await persistence.save(store.all());
840
+ } catch (error) {
841
+ ctx.logger.warn(`diff-approval: loading persisted state failed: ${errorMessage(error)}`);
842
+ }
843
+ })();
844
+ return loadPromise;
845
+ };
827
846
  /** Pre-write bases captured at the intent seams, keyed by the tool call id. */
828
847
  const editorIntents = /* @__PURE__ */ new Map();
829
848
  /**
@@ -881,17 +900,19 @@ function apply(ctx, config) {
881
900
  }
882
901
  if (basis.kind === "edit" && after === basis.before) return;
883
902
  const sessionId = basis.sessionId;
903
+ const path = basis.target.displayPath;
884
904
  const entry = {
885
- id: randomUUID(),
905
+ id: path,
886
906
  sessionId,
887
- path: basis.target.displayPath,
907
+ path,
888
908
  kind: basis.kind,
889
909
  oldText: basis.before,
890
910
  newText: after,
891
- updatedAt: Date.now()
911
+ updatedAt: Date.now(),
912
+ sessionIds: [sessionId]
892
913
  };
893
- await ensureLoaded(sessionId);
894
- if (store.fold(entry)) await persistSession(sessionId);
914
+ await ensureLoaded();
915
+ if (store.fold(entry)) persistSession();
895
916
  }
896
917
  /** The stable `FsError.code`, when the thrown value carries one. */
897
918
  function fsErrorCodeOf(error) {
@@ -935,20 +956,20 @@ function apply(ctx, config) {
935
956
  * @param entries - the store's entries for one session.
936
957
  * @returns entries with `missing` and `diverged` set from the live file.
937
958
  */
938
- /** Drop every undo/redo pair that belongs to a removed entry, so the LIFO
959
+ /** Drop every undo/redo pair that belongs to a removed file, so the LIFO
939
960
  * queue stays traversable without ever trying to restore an unreadable file. */
940
- function purgeForEntry(sessionId, entryId, path) {
941
- const key = String(sessionId);
942
- for (const stacks of [undoStacks, redoStacks]) {
943
- const stack = stacks.get(key);
944
- if (stack === void 0) continue;
945
- stacks.set(key, stack.filter((pair) => {
946
- const touches = (state) => state.id === entryId || state.path === path;
961
+ function purgeForEntry(path) {
962
+ const clean = (stack) => {
963
+ const kept = stack.filter((pair) => {
947
964
  const batch = pair.before.batch ?? pair.after.batch;
948
- if (batch !== void 0) return !batch.some((item) => item.id === entryId || item.path === path);
949
- return !touches(pair.before) && !touches(pair.after);
950
- }));
951
- }
965
+ if (batch !== void 0) return !batch.some((item) => item.path === path);
966
+ return pair.before.path !== path && pair.after.path !== path;
967
+ });
968
+ stack.length = 0;
969
+ stack.push(...kept);
970
+ };
971
+ clean(undoStack);
972
+ clean(redoStack);
952
973
  }
953
974
  /**
954
975
  * Settle each listed entry against its live file. Existence is decided by the
@@ -956,82 +977,70 @@ function apply(ctx, config) {
956
977
  * (Ctrl+Z recreates it and restores the entry), an unavailable one leaves the
957
978
  * list and has its undo/redo records dropped, and externally changed content
958
979
  * is adopted as the new baseline with its own checkpoint.
959
- * @param sessionId - the session being listed.
960
- * @param entries - the store's entries for one session.
980
+ * @param sessionId - the session being listed (its session-scoped view).
961
981
  * @returns the listed entries plus whether an external change cleared redo.
962
982
  */
963
- async function listWithState(sessionId, entries) {
964
- const byPath = /* @__PURE__ */ new Map();
965
- for (const entry of entries) {
966
- const group = byPath.get(entry.path);
967
- if (group === void 0) byPath.set(entry.path, [entry]);
968
- else group.push(entry);
969
- }
983
+ async function listWithState(sessionId) {
970
984
  const listed = [];
971
985
  let redoCleared = false;
972
- for (const group of byPath.values()) {
973
- const newest = group[group.length - 1];
974
- if (newest === void 0) continue;
975
- const live = await liveStateOf(newest.path);
986
+ for (const entry of store.list(sessionId)) {
987
+ const live = await liveStateOf(entry.path);
976
988
  if (live.kind === "deleted") {
977
- store.remove(sessionId, newest.id);
989
+ store.remove(entry.path);
978
990
  pushUndo(sessionId, {
979
- id: newest.id,
980
- path: newest.path,
981
- entry: newest,
982
- fileText: newest.newText
991
+ id: entry.path,
992
+ path: entry.path,
993
+ entry,
994
+ fileText: entry.newText
983
995
  }, {
984
- id: newest.id,
985
- path: newest.path,
996
+ id: entry.path,
997
+ path: entry.path,
986
998
  entry: void 0,
987
999
  fileText: void 0
988
1000
  });
989
- await persistSession(sessionId);
1001
+ persistSession();
990
1002
  continue;
991
1003
  }
992
1004
  if (live.kind === "unavailable") {
993
- store.remove(sessionId, newest.id);
994
- purgeForEntry(sessionId, newest.id, newest.path);
995
- await persistSession(sessionId);
1005
+ store.remove(entry.path);
1006
+ purgeForEntry(entry.path);
1007
+ persistSession();
996
1008
  continue;
997
1009
  }
998
1010
  const content = live.content;
999
- let adopted = newest.newText;
1011
+ let adopted = entry.newText;
1000
1012
  const hasContent = typeof content === "string";
1001
- if (hasContent && content !== newest.newText) {
1013
+ if (hasContent && content !== entry.newText) {
1002
1014
  adopted = content;
1003
- const beforeText = newest.newText;
1004
- const redoWasPresent = redoStacks.has(String(sessionId));
1005
- store.update(sessionId, newest.id, { newText: content });
1015
+ const beforeText = entry.newText;
1016
+ const redoWasPresent = redoStack.length > 0;
1017
+ store.update(entry.path, { newText: content });
1006
1018
  pushUndo(sessionId, {
1007
- id: newest.id,
1008
- path: newest.path,
1009
- entry: newest,
1019
+ id: entry.path,
1020
+ path: entry.path,
1021
+ entry,
1010
1022
  fileText: beforeText
1011
1023
  }, {
1012
- id: newest.id,
1013
- path: newest.path,
1024
+ id: entry.path,
1025
+ path: entry.path,
1014
1026
  entry: {
1015
- ...newest,
1027
+ ...entry,
1016
1028
  newText: content,
1017
1029
  updatedAt: Date.now()
1018
1030
  },
1019
1031
  fileText: content
1020
1032
  });
1021
- await persistSession(sessionId);
1033
+ persistSession();
1022
1034
  if (redoWasPresent) redoCleared = true;
1023
1035
  }
1024
1036
  const state = {
1025
1037
  missing: false,
1026
1038
  diverged: hasContent ? content !== adopted : true
1027
1039
  };
1028
- for (const entry of group) listed.push(entry.id === newest.id ? {
1040
+ listed.push({
1029
1041
  ...entry,
1030
1042
  newText: adopted,
1031
1043
  ...state
1032
- } : {
1033
- ...entry,
1034
- ...state
1035
1044
  });
1036
1045
  }
1037
1046
  return {
@@ -1083,18 +1092,15 @@ function apply(ctx, config) {
1083
1092
  const policy = sandboxPolicyOf(sessionId);
1084
1093
  return policy === void 0 ? ctx.fs.writeText(target, content, void 0, signal) : ctx.fs.writeText(target, content, void 0, signal, policy);
1085
1094
  }
1086
- const undoStacks = /* @__PURE__ */ new Map();
1087
- const redoStacks = /* @__PURE__ */ new Map();
1095
+ const undoStack = [];
1096
+ const redoStack = [];
1088
1097
  function pushUndo(sessionId, before, after) {
1089
- const key = String(sessionId);
1090
- const stack = undoStacks.get(key) ?? [];
1091
- stack.push({
1098
+ undoStack.push({
1092
1099
  sessionId,
1093
1100
  before,
1094
1101
  after
1095
1102
  });
1096
- undoStacks.set(key, stack);
1097
- redoStacks.delete(key);
1103
+ redoStack.length = 0;
1098
1104
  }
1099
1105
  /**
1100
1106
  * Restore one snapshot (the before/after side of an undo pair). File writes
@@ -1115,116 +1121,59 @@ function apply(ctx, config) {
1115
1121
  await writeRevert(resolved, state.fileText, sessionId, signal);
1116
1122
  }
1117
1123
  if (state.batch !== void 0) {
1118
- for (const item of state.batch) if (item.entry !== void 0) store.restore(sessionId, item.entry);
1119
- else store.remove(sessionId, item.id);
1124
+ for (const item of state.batch) if (item.entry !== void 0) store.restore(item.entry);
1125
+ else store.remove(item.path);
1120
1126
  return;
1121
1127
  }
1122
- if (state.entry !== void 0) store.restore(sessionId, state.entry);
1123
- else store.remove(sessionId, state.id);
1124
- }
1125
- /**
1126
- * Record one session in its workspace's account. Every path that touches a
1127
- * session registers it, so the list merges all of a workspace's sessions'
1128
- * entries — a fresh session after restart still sees the workspace's
1129
- * persisted pending changes.
1130
- * @param sessionId - the session to register.
1131
- * @returns the owning workspace, or `undefined` when none accounts it.
1132
- */
1133
- function registerSession(sessionId) {
1134
- const workspace = workspaceOf(sessionId);
1135
- if (workspace === void 0) return void 0;
1136
- const key = String(workspace.id);
1137
- const sessions = sessionsByWorkspace.get(key);
1138
- if (sessions === void 0) sessionsByWorkspace.set(key, /* @__PURE__ */ new Set([String(sessionId)]));
1139
- else sessions.add(String(sessionId));
1140
- return workspace;
1128
+ if (state.entry !== void 0) store.restore(state.entry);
1129
+ else store.remove(state.path);
1141
1130
  }
1142
1131
  /**
1143
- * Merge one workspace's persisted entries into the store, once per
1144
- * workspace. Hydration is workspace-scoped: after a restart the current
1145
- * session has a fresh id while the persisted entries live under their
1146
- * original session ids in the same workspace file, so the whole workspace
1147
- * is loaded and every persisted session is accounted. Concurrent callers
1148
- * share the in-flight load, and folds arriving while the load runs stay
1149
- * safe: `hydrate` never overwrites a live entry.
1150
- * @param workspace - the workspace whose persisted state to merge.
1151
- * @returns resolution after the workspace's persisted state is merged (or skipped).
1152
- */
1153
- function ensureWorkspaceLoaded(workspace) {
1154
- const key = String(workspace.id);
1155
- if (loadedWorkspaces.has(key)) return Promise.resolve();
1156
- const pending = loadingWorkspaces.get(key);
1157
- if (pending !== void 0) return pending;
1158
- const task = (async () => {
1159
- try {
1160
- const persisted = await persistence.loadWorkspace(key);
1161
- const bySession = /* @__PURE__ */ new Map();
1162
- for (const entry of persisted) {
1163
- const sessionKey = String(entry.sessionId);
1164
- const group = bySession.get(sessionKey);
1165
- if (group === void 0) bySession.set(sessionKey, [entry]);
1166
- else group.push(entry);
1167
- }
1168
- for (const [sessionKey, entries] of bySession) {
1169
- const sessions = sessionsByWorkspace.get(key) ?? /* @__PURE__ */ new Set();
1170
- sessions.add(sessionKey);
1171
- sessionsByWorkspace.set(key, sessions);
1172
- store.hydrate(SessionId(sessionKey), entries);
1173
- }
1174
- } catch (error) {
1175
- ctx.logger.warn(`diff-approval: loading persisted state for workspace ${key} failed: ${errorMessage(error)}`);
1176
- }
1177
- loadedWorkspaces.add(key);
1178
- loadingWorkspaces.delete(key);
1179
- })();
1180
- loadingWorkspaces.set(key, task);
1181
- return task;
1182
- }
1183
- /**
1184
- * Merge the session's workspace's persisted state into the store (a session
1185
- * with no workspace is the memory-only edge and has nothing to load).
1186
- * @param sessionId - the session to hydrate for.
1187
- * @returns resolution after the workspace's persisted state is merged.
1188
- */
1189
- function ensureLoaded(sessionId) {
1190
- const workspace = registerSession(sessionId);
1191
- if (workspace === void 0) return Promise.resolve();
1192
- return ensureWorkspaceLoaded(workspace);
1193
- }
1194
- /**
1195
- * All entries visible to one session: every registered session of its
1196
- * workspace, merged oldest capture first. This is what makes an unhandled
1197
- * change survive a restart — the new session lists the workspace's whole
1198
- * pending set, its own live folds plus the earlier sessions' persisted
1199
- * entries.
1200
- * @param sessionId - the viewing session.
1201
- * @returns the merged entries; a session with no workspace lists only itself.
1202
- */
1203
- async function workspaceEntries(sessionId) {
1204
- await ensureLoaded(sessionId);
1205
- const workspace = workspaceOf(sessionId);
1206
- if (workspace === void 0) return store.list(sessionId);
1207
- const sessions = sessionsByWorkspace.get(String(workspace.id));
1208
- if (sessions === void 0) return store.list(sessionId);
1209
- const entries = [];
1210
- for (const sessionKey of sessions) entries.push(...store.list(SessionId(sessionKey)));
1211
- return entries.sort((left, right) => left.updatedAt - right.updatedAt);
1212
- }
1213
- /**
1214
- * Mirror one session's entries to disk. A write fault logs a warning and
1215
- * leaves the in-memory view intact: the review flow must not break on a
1216
- * storage fault, and the next successful mutation rewrites the whole file.
1217
- * @param sessionId - the session whose complete entry list to save.
1132
+ * Mirror the whole (globally-unique) entry set to disk. A write fault logs a
1133
+ * warning and leaves the in-memory view intact: the review flow must not
1134
+ * break on a storage fault, and the next successful mutation rewrites the
1135
+ * file.
1218
1136
  * @returns resolution after the write settles (successful or logged).
1219
1137
  */
1220
- async function persistSession(sessionId) {
1221
- await ensureLoaded(sessionId);
1222
- const workspace = workspaceOf(sessionId);
1223
- if (workspace === void 0) return;
1138
+ const PERSIST_THROTTLE_MS = 1e3;
1139
+ let persistDirty = false;
1140
+ let persistScheduled = false;
1141
+ let lastPersistAt = 0;
1142
+ let persistTimer;
1143
+ /** Actually write the (dirty) store to disk; one coalesced write. */
1144
+ async function flushPersist() {
1145
+ if (!persistDirty) return;
1146
+ persistDirty = false;
1147
+ lastPersistAt = Date.now();
1224
1148
  try {
1225
- await persistence.save(String(workspace.id), String(sessionId), store.list(sessionId));
1149
+ await ensureLoaded();
1150
+ await persistence.save(store.all());
1226
1151
  } catch (error) {
1227
- ctx.logger.warn(`diff-approval: persisting session ${String(sessionId)} failed: ${errorMessage(error)}`);
1152
+ ctx.logger.warn(`diff-approval: persisting pending changes failed: ${errorMessage(error)}`);
1153
+ }
1154
+ }
1155
+ /** Mark the store dirty and schedule one throttled, coalesced write. Pass
1156
+ * `force` to write immediately (user actions need durable, immediate results). */
1157
+ function persistSession(force = false) {
1158
+ persistDirty = true;
1159
+ if (force) {
1160
+ if (persistScheduled) {
1161
+ clearTimeout(persistTimer);
1162
+ persistScheduled = false;
1163
+ }
1164
+ flushPersist();
1165
+ return;
1166
+ }
1167
+ if (persistScheduled) return;
1168
+ const delay = PERSIST_THROTTLE_MS - (Date.now() - lastPersistAt);
1169
+ if (delay <= 0) flushPersist();
1170
+ else {
1171
+ persistScheduled = true;
1172
+ persistTimer = setTimeout(() => {
1173
+ persistScheduled = false;
1174
+ flushPersist();
1175
+ }, delay);
1176
+ persistTimer.unref?.();
1228
1177
  }
1229
1178
  }
1230
1179
  ctx.on("tools/result", (exec, result) => {
@@ -1237,22 +1186,21 @@ function apply(ctx, config) {
1237
1186
  if (outcome === void 0 || outcome.oldText === outcome.newText) return;
1238
1187
  const sessionId = exec.agent.id;
1239
1188
  const entry = {
1240
- id: randomUUID(),
1189
+ id: outcome.path,
1241
1190
  sessionId,
1242
1191
  ...outcome,
1243
- updatedAt: Date.now()
1192
+ updatedAt: Date.now(),
1193
+ sessionIds: [sessionId]
1244
1194
  };
1245
- (async () => {
1246
- await ensureLoaded(sessionId);
1247
- if (store.fold(entry)) await persistSession(sessionId);
1248
- })();
1195
+ if (store.fold(entry)) persistSession();
1249
1196
  });
1250
1197
  const handle = async (endpoint, payload, signal) => {
1251
1198
  switch (endpoint) {
1252
1199
  case "list": {
1253
1200
  const sessionId = sessionOf(payload);
1254
1201
  if (sessionId === void 0) return rpcError("sessionId must be a non-empty string");
1255
- const { files, redoCleared } = await listWithState(sessionId, await workspaceEntries(sessionId));
1202
+ await ensureLoaded();
1203
+ const { files, redoCleared } = await listWithState(sessionId);
1256
1204
  return {
1257
1205
  ok: true,
1258
1206
  value: {
@@ -1265,25 +1213,28 @@ function apply(ctx, config) {
1265
1213
  case "keep": {
1266
1214
  const target = targetOf(payload);
1267
1215
  if (target === void 0) return rpcError("sessionId and id must be non-empty strings");
1268
- await ensureLoaded(target.sessionId);
1269
- const entry = store.get(target.sessionId, target.id);
1270
- if (entry === void 0) return {
1271
- ok: true,
1272
- value: { outcome: "missing" }
1273
- };
1274
- store.remove(target.sessionId, target.id);
1216
+ let entry = store.get(target.id);
1217
+ if (entry === void 0) {
1218
+ await ensureLoaded();
1219
+ entry = store.get(target.id);
1220
+ if (entry === void 0) return {
1221
+ ok: true,
1222
+ value: { outcome: "missing" }
1223
+ };
1224
+ }
1225
+ store.remove(target.id);
1275
1226
  pushUndo(target.sessionId, {
1276
- id: entry.id,
1227
+ id: entry.path,
1277
1228
  path: entry.path,
1278
1229
  entry,
1279
1230
  fileText: void 0
1280
1231
  }, {
1281
- id: entry.id,
1232
+ id: entry.path,
1282
1233
  path: entry.path,
1283
1234
  entry: void 0,
1284
1235
  fileText: void 0
1285
1236
  });
1286
- await persistSession(target.sessionId);
1237
+ persistSession(true);
1287
1238
  return {
1288
1239
  ok: true,
1289
1240
  value: { outcome: "kept" }
@@ -1292,12 +1243,15 @@ function apply(ctx, config) {
1292
1243
  case "revert": {
1293
1244
  const target = targetOf(payload);
1294
1245
  if (target === void 0) return rpcError("sessionId and id must be non-empty strings");
1295
- await ensureLoaded(target.sessionId);
1296
- const entry = store.get(target.sessionId, target.id);
1297
- if (entry === void 0) return {
1298
- ok: true,
1299
- value: { outcome: "missing" }
1300
- };
1246
+ let entry = store.get(target.id);
1247
+ if (entry === void 0) {
1248
+ await ensureLoaded();
1249
+ entry = store.get(target.id);
1250
+ if (entry === void 0) return {
1251
+ ok: true,
1252
+ value: { outcome: "missing" }
1253
+ };
1254
+ }
1301
1255
  let undo;
1302
1256
  try {
1303
1257
  const resolved = await ctx.fs.resolve(entry.path, { signal });
@@ -1308,13 +1262,13 @@ function apply(ctx, config) {
1308
1262
  await writeRevert(resolved, content, target.sessionId, signal);
1309
1263
  undo = {
1310
1264
  before: {
1311
- id: entry.id,
1265
+ id: entry.path,
1312
1266
  path: entry.path,
1313
1267
  entry,
1314
1268
  fileText: preWrite
1315
1269
  },
1316
1270
  after: {
1317
- id: entry.id,
1271
+ id: entry.path,
1318
1272
  path: entry.path,
1319
1273
  entry: void 0,
1320
1274
  fileText: content
@@ -1324,9 +1278,9 @@ function apply(ctx, config) {
1324
1278
  } catch (error) {
1325
1279
  return rpcError(`revert failed: ${errorMessage(error)}`);
1326
1280
  }
1327
- store.remove(target.sessionId, target.id);
1281
+ store.remove(target.id);
1328
1282
  if (undo !== void 0) pushUndo(target.sessionId, undo.before, undo.after);
1329
- await persistSession(target.sessionId);
1283
+ persistSession(true);
1330
1284
  return {
1331
1285
  ok: true,
1332
1286
  value: { outcome: "reverted" }
@@ -1335,15 +1289,15 @@ function apply(ctx, config) {
1335
1289
  case "block-keep": {
1336
1290
  const blockTarget = blockTargetOf(payload);
1337
1291
  if (blockTarget === void 0) return rpcError("sessionId, id, and block must be valid");
1338
- await ensureLoaded(blockTarget.sessionId);
1339
- const entry = store.get(blockTarget.sessionId, blockTarget.id);
1292
+ await ensureLoaded();
1293
+ const entry = store.get(blockTarget.id);
1340
1294
  if (entry === void 0) return {
1341
1295
  ok: true,
1342
1296
  value: { outcome: "missing" }
1343
1297
  };
1344
1298
  const accepted = contentRangeOf(entry.newText, blockTarget.block.newStart, blockTarget.block.newEnd);
1345
1299
  const updatedOld = replaceContentLines(entry.oldText, blockTarget.block.oldStart, blockTarget.block.oldEnd, accepted);
1346
- store.update(blockTarget.sessionId, blockTarget.id, { oldText: updatedOld });
1300
+ store.update(blockTarget.id, { oldText: updatedOld });
1347
1301
  const afterEntry = {
1348
1302
  ...entry,
1349
1303
  oldText: updatedOld,
@@ -1360,7 +1314,7 @@ function apply(ctx, config) {
1360
1314
  entry: afterEntry,
1361
1315
  fileText: void 0
1362
1316
  });
1363
- await persistSession(blockTarget.sessionId);
1317
+ persistSession();
1364
1318
  return {
1365
1319
  ok: true,
1366
1320
  value: updatedOld === entry.newText ? {
@@ -1372,8 +1326,8 @@ function apply(ctx, config) {
1372
1326
  case "block-revert": {
1373
1327
  const blockTarget = blockTargetOf(payload);
1374
1328
  if (blockTarget === void 0) return rpcError("sessionId, id, and block must be valid");
1375
- await ensureLoaded(blockTarget.sessionId);
1376
- const entry = store.get(blockTarget.sessionId, blockTarget.id);
1329
+ await ensureLoaded();
1330
+ const entry = store.get(blockTarget.id);
1377
1331
  if (entry === void 0) return {
1378
1332
  ok: true,
1379
1333
  value: { outcome: "missing" }
@@ -1381,7 +1335,7 @@ function apply(ctx, config) {
1381
1335
  const restored = contentRangeOf(entry.oldText, blockTarget.block.oldStart, blockTarget.block.oldEnd);
1382
1336
  const updatedNew = replaceContentLines(entry.newText, blockTarget.block.newStart, blockTarget.block.newEnd, restored);
1383
1337
  const content = reencodeEol(updatedNew, detectEol(entry.newText));
1384
- store.update(blockTarget.sessionId, blockTarget.id, { newText: content });
1338
+ store.update(blockTarget.id, { newText: content });
1385
1339
  const afterEntry = {
1386
1340
  ...entry,
1387
1341
  newText: content,
@@ -1413,7 +1367,7 @@ function apply(ctx, config) {
1413
1367
  return rpcError(`block revert failed: ${errorMessage(error)}`);
1414
1368
  }
1415
1369
  if (undo !== void 0) pushUndo(blockTarget.sessionId, undo.before, undo.after);
1416
- await persistSession(blockTarget.sessionId);
1370
+ persistSession();
1417
1371
  return {
1418
1372
  ok: true,
1419
1373
  value: normalizeEol(updatedNew) === normalizeEol(entry.oldText) ? {
@@ -1425,9 +1379,7 @@ function apply(ctx, config) {
1425
1379
  case "undo": {
1426
1380
  const sessionId = sessionOf(payload);
1427
1381
  if (sessionId === void 0) return rpcError("sessionId must be a non-empty string");
1428
- const key = String(sessionId);
1429
- const stack = undoStacks.get(key) ?? [];
1430
- const pair = stack.pop();
1382
+ const pair = undoStack.pop();
1431
1383
  if (pair === void 0) return {
1432
1384
  ok: true,
1433
1385
  value: { outcome: "nothing" }
@@ -1435,13 +1387,11 @@ function apply(ctx, config) {
1435
1387
  try {
1436
1388
  await restoreState(sessionId, pair.before, pair.after, signal);
1437
1389
  } catch (error) {
1438
- stack.push(pair);
1390
+ undoStack.push(pair);
1439
1391
  return rpcError(`undo failed: ${errorMessage(error)}`);
1440
1392
  }
1441
- const redoStack = redoStacks.get(key) ?? [];
1442
1393
  redoStack.push(pair);
1443
- redoStacks.set(key, redoStack);
1444
- await persistSession(sessionId);
1394
+ persistSession(true);
1445
1395
  return {
1446
1396
  ok: true,
1447
1397
  value: {
@@ -1453,9 +1403,7 @@ function apply(ctx, config) {
1453
1403
  case "redo": {
1454
1404
  const sessionId = sessionOf(payload);
1455
1405
  if (sessionId === void 0) return rpcError("sessionId must be a non-empty string");
1456
- const key = String(sessionId);
1457
- const stack = redoStacks.get(key) ?? [];
1458
- const pair = stack.pop();
1406
+ const pair = redoStack.pop();
1459
1407
  if (pair === void 0) return {
1460
1408
  ok: true,
1461
1409
  value: { outcome: "nothing" }
@@ -1463,13 +1411,11 @@ function apply(ctx, config) {
1463
1411
  try {
1464
1412
  await restoreState(sessionId, pair.after, pair.before, signal);
1465
1413
  } catch (error) {
1466
- stack.push(pair);
1414
+ redoStack.push(pair);
1467
1415
  return rpcError(`redo failed: ${errorMessage(error)}`);
1468
1416
  }
1469
- const undoStack = undoStacks.get(key) ?? [];
1470
1417
  undoStack.push(pair);
1471
- undoStacks.set(key, undoStack);
1472
- await persistSession(sessionId);
1418
+ persistSession(true);
1473
1419
  return {
1474
1420
  ok: true,
1475
1421
  value: {
@@ -1509,19 +1455,20 @@ function apply(ctx, config) {
1509
1455
  } catch (error) {
1510
1456
  return rpcError(`import failed: ${errorMessage(error)}`);
1511
1457
  }
1512
- await ensureLoaded(sessionId);
1458
+ await ensureLoaded();
1513
1459
  const before = new Map(store.list(sessionId).map((entry) => [entry.path, entry]));
1514
1460
  let imported = 0;
1515
1461
  const changedPaths = [];
1516
1462
  for (const change of changes) {
1517
1463
  const entry = {
1518
- id: randomUUID(),
1464
+ id: change.path,
1519
1465
  sessionId,
1520
1466
  path: change.path,
1521
1467
  kind: change.kind,
1522
1468
  oldText: change.oldText,
1523
1469
  newText: change.newText,
1524
- updatedAt: Date.now()
1470
+ updatedAt: Date.now(),
1471
+ sessionIds: [sessionId]
1525
1472
  };
1526
1473
  if (store.fold(entry)) {
1527
1474
  imported += 1;
@@ -1529,7 +1476,7 @@ function apply(ctx, config) {
1529
1476
  }
1530
1477
  }
1531
1478
  if (imported > 0) {
1532
- await persistSession(sessionId);
1479
+ persistSession();
1533
1480
  const after = new Map(store.list(sessionId).map((entry) => [entry.path, entry]));
1534
1481
  const batchBefore = [];
1535
1482
  const batchAfter = [];
@@ -1575,8 +1522,8 @@ function apply(ctx, config) {
1575
1522
  case "open": {
1576
1523
  const target = openTargetOf(payload);
1577
1524
  if (target === void 0) return rpcError("sessionId, id, and action must be valid");
1578
- await ensureLoaded(target.sessionId);
1579
- const entry = store.get(target.sessionId, target.id);
1525
+ await ensureLoaded();
1526
+ const entry = store.get(target.id);
1580
1527
  if (entry === void 0) return {
1581
1528
  ok: true,
1582
1529
  value: { outcome: "missing" }