dsh-diff-approval 0.13.1 → 0.14.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/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)) await 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
+ await 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
+ await 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
+ await 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,26 @@ 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);
1128
+ if (state.entry !== void 0) store.restore(state.entry);
1129
+ else store.remove(state.path);
1124
1130
  }
1125
1131
  /**
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;
1141
- }
1142
- /**
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
+ async function persistSession() {
1139
+ await ensureLoaded();
1224
1140
  try {
1225
- await persistence.save(String(workspace.id), String(sessionId), store.list(sessionId));
1141
+ await persistence.save(store.all());
1226
1142
  } catch (error) {
1227
- ctx.logger.warn(`diff-approval: persisting session ${String(sessionId)} failed: ${errorMessage(error)}`);
1143
+ ctx.logger.warn(`diff-approval: persisting pending changes failed: ${errorMessage(error)}`);
1228
1144
  }
1229
1145
  }
1230
1146
  ctx.on("tools/result", (exec, result) => {
@@ -1237,22 +1153,21 @@ function apply(ctx, config) {
1237
1153
  if (outcome === void 0 || outcome.oldText === outcome.newText) return;
1238
1154
  const sessionId = exec.agent.id;
1239
1155
  const entry = {
1240
- id: randomUUID(),
1156
+ id: outcome.path,
1241
1157
  sessionId,
1242
1158
  ...outcome,
1243
- updatedAt: Date.now()
1159
+ updatedAt: Date.now(),
1160
+ sessionIds: [sessionId]
1244
1161
  };
1245
- (async () => {
1246
- await ensureLoaded(sessionId);
1247
- if (store.fold(entry)) await persistSession(sessionId);
1248
- })();
1162
+ if (store.fold(entry)) persistSession();
1249
1163
  });
1250
1164
  const handle = async (endpoint, payload, signal) => {
1251
1165
  switch (endpoint) {
1252
1166
  case "list": {
1253
1167
  const sessionId = sessionOf(payload);
1254
1168
  if (sessionId === void 0) return rpcError("sessionId must be a non-empty string");
1255
- const { files, redoCleared } = await listWithState(sessionId, await workspaceEntries(sessionId));
1169
+ await ensureLoaded();
1170
+ const { files, redoCleared } = await listWithState(sessionId);
1256
1171
  return {
1257
1172
  ok: true,
1258
1173
  value: {
@@ -1265,25 +1180,28 @@ function apply(ctx, config) {
1265
1180
  case "keep": {
1266
1181
  const target = targetOf(payload);
1267
1182
  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);
1183
+ let entry = store.get(target.id);
1184
+ if (entry === void 0) {
1185
+ await ensureLoaded();
1186
+ entry = store.get(target.id);
1187
+ if (entry === void 0) return {
1188
+ ok: true,
1189
+ value: { outcome: "missing" }
1190
+ };
1191
+ }
1192
+ store.remove(target.id);
1275
1193
  pushUndo(target.sessionId, {
1276
- id: entry.id,
1194
+ id: entry.path,
1277
1195
  path: entry.path,
1278
1196
  entry,
1279
1197
  fileText: void 0
1280
1198
  }, {
1281
- id: entry.id,
1199
+ id: entry.path,
1282
1200
  path: entry.path,
1283
1201
  entry: void 0,
1284
1202
  fileText: void 0
1285
1203
  });
1286
- await persistSession(target.sessionId);
1204
+ await persistSession();
1287
1205
  return {
1288
1206
  ok: true,
1289
1207
  value: { outcome: "kept" }
@@ -1292,12 +1210,15 @@ function apply(ctx, config) {
1292
1210
  case "revert": {
1293
1211
  const target = targetOf(payload);
1294
1212
  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
- };
1213
+ let entry = store.get(target.id);
1214
+ if (entry === void 0) {
1215
+ await ensureLoaded();
1216
+ entry = store.get(target.id);
1217
+ if (entry === void 0) return {
1218
+ ok: true,
1219
+ value: { outcome: "missing" }
1220
+ };
1221
+ }
1301
1222
  let undo;
1302
1223
  try {
1303
1224
  const resolved = await ctx.fs.resolve(entry.path, { signal });
@@ -1308,13 +1229,13 @@ function apply(ctx, config) {
1308
1229
  await writeRevert(resolved, content, target.sessionId, signal);
1309
1230
  undo = {
1310
1231
  before: {
1311
- id: entry.id,
1232
+ id: entry.path,
1312
1233
  path: entry.path,
1313
1234
  entry,
1314
1235
  fileText: preWrite
1315
1236
  },
1316
1237
  after: {
1317
- id: entry.id,
1238
+ id: entry.path,
1318
1239
  path: entry.path,
1319
1240
  entry: void 0,
1320
1241
  fileText: content
@@ -1324,9 +1245,9 @@ function apply(ctx, config) {
1324
1245
  } catch (error) {
1325
1246
  return rpcError(`revert failed: ${errorMessage(error)}`);
1326
1247
  }
1327
- store.remove(target.sessionId, target.id);
1248
+ store.remove(target.id);
1328
1249
  if (undo !== void 0) pushUndo(target.sessionId, undo.before, undo.after);
1329
- await persistSession(target.sessionId);
1250
+ await persistSession();
1330
1251
  return {
1331
1252
  ok: true,
1332
1253
  value: { outcome: "reverted" }
@@ -1335,15 +1256,15 @@ function apply(ctx, config) {
1335
1256
  case "block-keep": {
1336
1257
  const blockTarget = blockTargetOf(payload);
1337
1258
  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);
1259
+ await ensureLoaded();
1260
+ const entry = store.get(blockTarget.id);
1340
1261
  if (entry === void 0) return {
1341
1262
  ok: true,
1342
1263
  value: { outcome: "missing" }
1343
1264
  };
1344
1265
  const accepted = contentRangeOf(entry.newText, blockTarget.block.newStart, blockTarget.block.newEnd);
1345
1266
  const updatedOld = replaceContentLines(entry.oldText, blockTarget.block.oldStart, blockTarget.block.oldEnd, accepted);
1346
- store.update(blockTarget.sessionId, blockTarget.id, { oldText: updatedOld });
1267
+ store.update(blockTarget.id, { oldText: updatedOld });
1347
1268
  const afterEntry = {
1348
1269
  ...entry,
1349
1270
  oldText: updatedOld,
@@ -1360,7 +1281,7 @@ function apply(ctx, config) {
1360
1281
  entry: afterEntry,
1361
1282
  fileText: void 0
1362
1283
  });
1363
- await persistSession(blockTarget.sessionId);
1284
+ await persistSession();
1364
1285
  return {
1365
1286
  ok: true,
1366
1287
  value: updatedOld === entry.newText ? {
@@ -1372,8 +1293,8 @@ function apply(ctx, config) {
1372
1293
  case "block-revert": {
1373
1294
  const blockTarget = blockTargetOf(payload);
1374
1295
  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);
1296
+ await ensureLoaded();
1297
+ const entry = store.get(blockTarget.id);
1377
1298
  if (entry === void 0) return {
1378
1299
  ok: true,
1379
1300
  value: { outcome: "missing" }
@@ -1381,7 +1302,7 @@ function apply(ctx, config) {
1381
1302
  const restored = contentRangeOf(entry.oldText, blockTarget.block.oldStart, blockTarget.block.oldEnd);
1382
1303
  const updatedNew = replaceContentLines(entry.newText, blockTarget.block.newStart, blockTarget.block.newEnd, restored);
1383
1304
  const content = reencodeEol(updatedNew, detectEol(entry.newText));
1384
- store.update(blockTarget.sessionId, blockTarget.id, { newText: content });
1305
+ store.update(blockTarget.id, { newText: content });
1385
1306
  const afterEntry = {
1386
1307
  ...entry,
1387
1308
  newText: content,
@@ -1413,7 +1334,7 @@ function apply(ctx, config) {
1413
1334
  return rpcError(`block revert failed: ${errorMessage(error)}`);
1414
1335
  }
1415
1336
  if (undo !== void 0) pushUndo(blockTarget.sessionId, undo.before, undo.after);
1416
- await persistSession(blockTarget.sessionId);
1337
+ await persistSession();
1417
1338
  return {
1418
1339
  ok: true,
1419
1340
  value: normalizeEol(updatedNew) === normalizeEol(entry.oldText) ? {
@@ -1425,9 +1346,7 @@ function apply(ctx, config) {
1425
1346
  case "undo": {
1426
1347
  const sessionId = sessionOf(payload);
1427
1348
  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();
1349
+ const pair = undoStack.pop();
1431
1350
  if (pair === void 0) return {
1432
1351
  ok: true,
1433
1352
  value: { outcome: "nothing" }
@@ -1435,13 +1354,11 @@ function apply(ctx, config) {
1435
1354
  try {
1436
1355
  await restoreState(sessionId, pair.before, pair.after, signal);
1437
1356
  } catch (error) {
1438
- stack.push(pair);
1357
+ undoStack.push(pair);
1439
1358
  return rpcError(`undo failed: ${errorMessage(error)}`);
1440
1359
  }
1441
- const redoStack = redoStacks.get(key) ?? [];
1442
1360
  redoStack.push(pair);
1443
- redoStacks.set(key, redoStack);
1444
- await persistSession(sessionId);
1361
+ await persistSession();
1445
1362
  return {
1446
1363
  ok: true,
1447
1364
  value: {
@@ -1453,9 +1370,7 @@ function apply(ctx, config) {
1453
1370
  case "redo": {
1454
1371
  const sessionId = sessionOf(payload);
1455
1372
  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();
1373
+ const pair = redoStack.pop();
1459
1374
  if (pair === void 0) return {
1460
1375
  ok: true,
1461
1376
  value: { outcome: "nothing" }
@@ -1463,13 +1378,11 @@ function apply(ctx, config) {
1463
1378
  try {
1464
1379
  await restoreState(sessionId, pair.after, pair.before, signal);
1465
1380
  } catch (error) {
1466
- stack.push(pair);
1381
+ redoStack.push(pair);
1467
1382
  return rpcError(`redo failed: ${errorMessage(error)}`);
1468
1383
  }
1469
- const undoStack = undoStacks.get(key) ?? [];
1470
1384
  undoStack.push(pair);
1471
- undoStacks.set(key, undoStack);
1472
- await persistSession(sessionId);
1385
+ await persistSession();
1473
1386
  return {
1474
1387
  ok: true,
1475
1388
  value: {
@@ -1509,19 +1422,20 @@ function apply(ctx, config) {
1509
1422
  } catch (error) {
1510
1423
  return rpcError(`import failed: ${errorMessage(error)}`);
1511
1424
  }
1512
- await ensureLoaded(sessionId);
1425
+ await ensureLoaded();
1513
1426
  const before = new Map(store.list(sessionId).map((entry) => [entry.path, entry]));
1514
1427
  let imported = 0;
1515
1428
  const changedPaths = [];
1516
1429
  for (const change of changes) {
1517
1430
  const entry = {
1518
- id: randomUUID(),
1431
+ id: change.path,
1519
1432
  sessionId,
1520
1433
  path: change.path,
1521
1434
  kind: change.kind,
1522
1435
  oldText: change.oldText,
1523
1436
  newText: change.newText,
1524
- updatedAt: Date.now()
1437
+ updatedAt: Date.now(),
1438
+ sessionIds: [sessionId]
1525
1439
  };
1526
1440
  if (store.fold(entry)) {
1527
1441
  imported += 1;
@@ -1529,7 +1443,7 @@ function apply(ctx, config) {
1529
1443
  }
1530
1444
  }
1531
1445
  if (imported > 0) {
1532
- await persistSession(sessionId);
1446
+ await persistSession();
1533
1447
  const after = new Map(store.list(sessionId).map((entry) => [entry.path, entry]));
1534
1448
  const batchBefore = [];
1535
1449
  const batchAfter = [];
@@ -1575,8 +1489,8 @@ function apply(ctx, config) {
1575
1489
  case "open": {
1576
1490
  const target = openTargetOf(payload);
1577
1491
  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);
1492
+ await ensureLoaded();
1493
+ const entry = store.get(target.id);
1580
1494
  if (entry === void 0) return {
1581
1495
  ok: true,
1582
1496
  value: { outcome: "missing" }