@cjhyy/code-shell-core 0.8.8 → 0.8.10

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.
Files changed (86) hide show
  1. package/dist/automation/scheduler.js +49 -0
  2. package/dist/automation/store.d.ts +1 -1
  3. package/dist/automation/store.js +184 -10
  4. package/dist/cli/agent-server-stdio.js +7 -0
  5. package/dist/credentials/store.d.ts +14 -0
  6. package/dist/credentials/store.js +245 -42
  7. package/dist/engine/engine.js +81 -27
  8. package/dist/engine/file-history-hook.js +24 -5
  9. package/dist/engine/run-finalize.js +8 -6
  10. package/dist/engine/run-session-open.js +1 -1
  11. package/dist/engine/run-setup.d.ts +4 -0
  12. package/dist/engine/run-setup.js +5 -1
  13. package/dist/engine/run-tooling.js +7 -1
  14. package/dist/engine/run-types.d.ts +33 -0
  15. package/dist/engine/run-types.js +21 -0
  16. package/dist/engine/run-workspace.js +11 -5
  17. package/dist/engine/turn-loop.js +9 -8
  18. package/dist/goal/lifecycle.d.ts +2 -0
  19. package/dist/goal/lifecycle.js +56 -33
  20. package/dist/index.d.ts +2 -3
  21. package/dist/index.internal.d.ts +1 -0
  22. package/dist/index.internal.js +1 -0
  23. package/dist/index.js +2 -2
  24. package/dist/links/cli.d.ts +2 -0
  25. package/dist/links/cli.js +11 -4
  26. package/dist/model-catalog/index.js +19 -4
  27. package/dist/model-catalog/save-entry.js +122 -61
  28. package/dist/model-catalog/types.js +27 -23
  29. package/dist/panel-apps/installer.js +27 -14
  30. package/dist/panel-apps/manifest.d.ts +11 -11
  31. package/dist/panel-apps/manifest.js +3 -1
  32. package/dist/panel-apps/registry.js +60 -12
  33. package/dist/plugins/installedPlugins.d.ts +4 -0
  34. package/dist/plugins/installedPlugins.js +70 -30
  35. package/dist/plugins/installer/types.d.ts +12 -12
  36. package/dist/plugins/installer/update.js +37 -38
  37. package/dist/plugins/knownMarketplaces.d.ts +7 -3
  38. package/dist/plugins/knownMarketplaces.js +127 -23
  39. package/dist/plugins/pluginCatalog.js +18 -4
  40. package/dist/plugins/pluginHookApproval.js +56 -60
  41. package/dist/plugins/pluginMcpApproval.js +50 -52
  42. package/dist/profile/catalog-store.js +39 -4
  43. package/dist/profile/catalog.js +55 -15
  44. package/dist/profile/store.js +51 -21
  45. package/dist/prompt/composer.d.ts +8 -0
  46. package/dist/prompt/composer.js +8 -0
  47. package/dist/protocol/chat-session-manager.d.ts +9 -0
  48. package/dist/protocol/chat-session-manager.js +22 -0
  49. package/dist/protocol/chat-session.d.ts +5 -0
  50. package/dist/protocol/chat-session.js +1 -0
  51. package/dist/protocol/server.d.ts +2 -0
  52. package/dist/protocol/server.js +112 -29
  53. package/dist/protocol/types.d.ts +20 -0
  54. package/dist/run/FileRunStore.d.ts +2 -0
  55. package/dist/run/FileRunStore.js +153 -18
  56. package/dist/run/Heartbeat.js +63 -4
  57. package/dist/services/auto-dream.js +39 -17
  58. package/dist/services/session-memory.js +107 -8
  59. package/dist/session/file-history.d.ts +63 -2
  60. package/dist/session/file-history.js +593 -86
  61. package/dist/session/session-manager.d.ts +4 -1
  62. package/dist/session/session-manager.js +94 -47
  63. package/dist/session/transcript.js +33 -3
  64. package/dist/session/undo-target.d.ts +15 -6
  65. package/dist/session/undo-target.js +26 -9
  66. package/dist/settings/manager.d.ts +22 -3
  67. package/dist/settings/manager.js +185 -50
  68. package/dist/settings/schema.d.ts +3 -3
  69. package/dist/sources/adapters/local-files.js +49 -4
  70. package/dist/sources/catalog.js +64 -18
  71. package/dist/sources/types.d.ts +3 -3
  72. package/dist/sources/types.js +7 -4
  73. package/dist/themes/installer.js +192 -28
  74. package/dist/tool-system/builtin/add-marketplace.js +21 -1
  75. package/dist/tool-system/builtin/cron.d.ts +2 -1
  76. package/dist/tool-system/builtin/cron.js +20 -6
  77. package/dist/tool-system/builtin/index.js +48 -1
  78. package/dist/tool-system/builtin/install-capability.d.ts +52 -0
  79. package/dist/tool-system/builtin/install-capability.js +1057 -0
  80. package/dist/tool-system/builtin/skill.js +3 -1
  81. package/dist/tool-system/executor.js +1 -0
  82. package/dist/tool-system/path-policy.js +25 -33
  83. package/dist/tool-system/registry.js +5 -0
  84. package/dist/utils/file-mutex.d.ts +2 -0
  85. package/dist/utils/file-mutex.js +29 -4
  86. package/package.json +2 -1
@@ -4,12 +4,15 @@
4
4
  * Stores snapshots in the session directory under file-history/.
5
5
  * Supports restoring files to a previous state.
6
6
  */
7
- import { mkdirSync, existsSync, readFileSync, writeFileSync, copyFileSync, rmSync, } from "node:fs";
8
- import { join, resolve } from "node:path";
9
- import { createHash } from "node:crypto";
10
- import { earliestSnapshotsPerFile } from "./undo-target.js";
7
+ import { mkdirSync, existsSync, readFileSync, copyFileSync, chmodSync, lstatSync, realpathSync, rmSync, } from "node:fs";
8
+ import { basename, dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
9
+ import { createHash, randomUUID } from "node:crypto";
10
+ import { earliestSnapshotsPerFile, latestRedoTargets } from "./undo-target.js";
11
+ import { acquireFileLock, writeFileAtomic } from "../utils/file-mutex.js";
12
+ const LEGACY_TURN = Number.MIN_SAFE_INTEGER;
11
13
  export class FileHistory {
12
14
  historyDir;
15
+ lockDepth = 0;
13
16
  snapshots = [];
14
17
  /**
15
18
  * Redo material for undone turns, kept SEPARATE from `snapshots` so it never
@@ -19,8 +22,10 @@ export class FileHistory {
19
22
  /** Per-turn "this file was created" markers (see CreatedMarker). */
20
23
  created = [];
21
24
  constructor(sessionDir) {
22
- this.historyDir = join(sessionDir, "file-history");
23
- mkdirSync(this.historyDir, { recursive: true });
25
+ this.historyDir = resolve(sessionDir, "file-history");
26
+ mkdirSync(this.historyDir, { recursive: true, mode: 0o700 });
27
+ if (process.platform !== "win32")
28
+ chmodSync(this.historyDir, 0o700);
24
29
  }
25
30
  /**
26
31
  * Save a snapshot of a file before it is modified.
@@ -31,10 +36,19 @@ export class FileHistory {
31
36
  * latestTurnUndoTargets). Omit it for callers without turn context.
32
37
  */
33
38
  saveSnapshot(filePath, turnSeq) {
39
+ return this.withCurrentIndex(() => this.saveSnapshotUnlocked(filePath, turnSeq));
40
+ }
41
+ saveSnapshotUnlocked(filePath, turnSeq) {
42
+ if (turnSeq !== undefined && !isFiniteTurn(turnSeq))
43
+ return null;
34
44
  const absPath = resolve(filePath);
35
45
  if (!existsSync(absPath))
36
46
  return null;
37
47
  try {
48
+ const sourceInfo = lstatSync(absPath);
49
+ if (!sourceInfo.isFile() || sourceInfo.isSymbolicLink())
50
+ return null;
51
+ const realPath = realpathSync(absPath);
38
52
  const content = readFileSync(absPath);
39
53
  const hash = createHash("md5").update(content).digest("hex");
40
54
  // Check if we already have this exact (path, content) for this turn. Dedup
@@ -43,7 +57,7 @@ export class FileHistory {
43
57
  // miss it. Within a turn, re-snapshotting unchanged content is a no-op.
44
58
  const existing = this.snapshots.find((s) => s.filePath === absPath && s.hash === hash && s.turnSeq === turnSeq);
45
59
  if (existing)
46
- return existing;
60
+ return { ...existing };
47
61
  // Create backup. The filename must uniquely identify (path, content): a
48
62
  // bare 100-char tail of the path can collide for two different files
49
63
  // whose tails coincide, and with the same ms timestamp the second
@@ -56,18 +70,30 @@ export class FileHistory {
56
70
  const safeName = absPath.replace(/[/\\:]/g, "_").slice(-80);
57
71
  const backupPath = join(this.historyDir, `${timestamp}_${pathHash}_${hash.slice(0, 8)}_${safeName}`);
58
72
  copyFileSync(absPath, backupPath);
73
+ if (process.platform !== "win32")
74
+ chmodSync(backupPath, 0o600);
59
75
  const snapshot = {
60
76
  filePath: absPath,
61
77
  timestamp,
62
78
  backupPath,
63
79
  hash,
64
80
  size: content.length,
81
+ mode: sourceInfo.mode & 0o7777,
82
+ realPath,
65
83
  ...(turnSeq === undefined ? {} : { turnSeq }),
66
84
  };
67
85
  this.snapshots.push(snapshot);
68
- // Write index
69
- this.saveIndex();
70
- return snapshot;
86
+ if (!this.saveIndex()) {
87
+ this.snapshots.pop();
88
+ try {
89
+ rmSync(backupPath, { force: true });
90
+ }
91
+ catch {
92
+ // The unreferenced private backup is harmless; preserve the write failure result.
93
+ }
94
+ return null;
95
+ }
96
+ return { ...snapshot };
71
97
  }
72
98
  catch {
73
99
  return null;
@@ -82,16 +108,101 @@ export class FileHistory {
82
108
  * re-armed) — only fresh turns create new markers.
83
109
  */
84
110
  recordCreated(filePath, turnSeq) {
111
+ this.withCurrentIndex(() => this.recordCreatedUnlocked(filePath, turnSeq));
112
+ }
113
+ recordCreatedUnlocked(filePath, turnSeq) {
114
+ if (!isFiniteTurn(turnSeq))
115
+ return;
85
116
  const absPath = resolve(filePath);
86
117
  const existing = this.created.find((c) => c.filePath === absPath && c.turnSeq === turnSeq);
87
118
  if (existing)
88
119
  return;
89
- this.created.push({ filePath: absPath, turnSeq });
90
- this.saveIndex();
120
+ const realPath = this.canonicalizeNewPath(absPath);
121
+ const marker = {
122
+ filePath: absPath,
123
+ turnSeq,
124
+ ...(realPath ? { realPath } : {}),
125
+ };
126
+ this.created.push(marker);
127
+ if (!this.saveIndex())
128
+ this.created.pop();
129
+ }
130
+ /**
131
+ * Capture a create marker before the tool runs, but do not persist it yet.
132
+ * `lstat` (rather than `existsSync`) deliberately treats dangling symlinks as
133
+ * existing: a failed/no-follow Write must never turn such a path into a
134
+ * create marker that a later undo would remove.
135
+ */
136
+ prepareCreated(filePath, turnSeq) {
137
+ if (!isFiniteTurn(turnSeq))
138
+ return null;
139
+ const absPath = resolve(filePath);
140
+ try {
141
+ lstatSync(absPath);
142
+ return null;
143
+ }
144
+ catch (err) {
145
+ if (err.code !== "ENOENT")
146
+ return null;
147
+ }
148
+ const realPath = this.canonicalizeNewPath(absPath);
149
+ return {
150
+ filePath: absPath,
151
+ turnSeq,
152
+ ...(realPath ? { realPath } : {}),
153
+ };
154
+ }
155
+ /** Persist a marker previously returned by prepareCreated after tool success. */
156
+ commitCreated(marker) {
157
+ this.withCurrentIndex(() => this.commitCreatedUnlocked(marker));
158
+ }
159
+ commitCreatedUnlocked(marker) {
160
+ if (!isCreatedMarker(marker))
161
+ return;
162
+ const existing = this.created.find((candidate) => candidate.filePath === marker.filePath && candidate.turnSeq === marker.turnSeq);
163
+ if (existing)
164
+ return;
165
+ this.created.push({ ...marker, undone: undefined });
166
+ if (!this.saveIndex())
167
+ this.created.pop();
91
168
  }
92
169
  /** Redo material captured by past undos (see RedoRecord). Returns a copy. */
93
170
  getRedoRecords() {
94
- return [...this.redoRecords];
171
+ return this.redoRecords.map((record) => ({ ...record }));
172
+ }
173
+ /** Latest redo records after accounting for create-only live turns. */
174
+ getLatestRedoRecords() {
175
+ const records = latestRedoTargets(this.redoRecords, this.snapshots);
176
+ if (records.length === 0)
177
+ return [];
178
+ const redoTurn = records[0].turnSeq;
179
+ const snapshots = this.snapshots.filter((snapshot) => this.turnKey(snapshot.turnSeq) === redoTurn);
180
+ const markers = this.created.filter((marker) => marker.turnSeq === redoTurn);
181
+ if (snapshots.length + markers.length === 0 ||
182
+ snapshots.some((snapshot) => !snapshot.undone) ||
183
+ markers.some((marker) => !marker.undone)) {
184
+ return [];
185
+ }
186
+ if (this.created.some((marker) => !marker.undone && marker.turnSeq > redoTurn)) {
187
+ return [];
188
+ }
189
+ return records.map((record) => ({ ...record }));
190
+ }
191
+ /**
192
+ * Authoritative latest-turn plan, including turns that only created files.
193
+ * Keeping this decision in FileHistory prevents UI callers from accidentally
194
+ * selecting an older snapshot-only turn.
195
+ */
196
+ getLatestTurnUndoPlan() {
197
+ const plan = this.latestTurnUndoPlanInternal();
198
+ if (!plan)
199
+ return null;
200
+ return {
201
+ turnSeq: plan.turnSeq,
202
+ snapshots: plan.snapshots.map((snapshot) => ({ ...snapshot })),
203
+ createdPaths: [...plan.createdPaths],
204
+ filePaths: [...plan.filePaths],
205
+ };
95
206
  }
96
207
  /**
97
208
  * Get all snapshots for a specific file.
@@ -100,32 +211,28 @@ export class FileHistory {
100
211
  const absPath = resolve(filePath);
101
212
  return this.snapshots
102
213
  .filter((s) => s.filePath === absPath)
214
+ .map((snapshot) => ({ ...snapshot }))
103
215
  .sort((a, b) => b.timestamp - a.timestamp);
104
216
  }
105
217
  /**
106
218
  * Restore a file to a specific snapshot.
107
219
  */
108
220
  restore(snapshot) {
109
- try {
110
- if (!existsSync(snapshot.backupPath))
111
- return false;
112
- // Save current state before restoring
113
- this.saveSnapshot(snapshot.filePath);
114
- copyFileSync(snapshot.backupPath, snapshot.filePath);
115
- return true;
116
- }
117
- catch {
118
- return false;
119
- }
221
+ return this.withCurrentIndex(() => {
222
+ const authoritative = this.findSnapshot(snapshot);
223
+ return authoritative ? this.restoreSnapshot(authoritative, true) : false;
224
+ });
120
225
  }
121
226
  /**
122
227
  * Restore a file to its most recent snapshot.
123
228
  */
124
229
  restoreLatest(filePath) {
125
- const snapshots = this.getSnapshots(filePath);
126
- if (snapshots.length === 0)
127
- return false;
128
- return this.restore(snapshots[0]);
230
+ return this.withCurrentIndex(() => {
231
+ const snapshots = this.getSnapshots(filePath);
232
+ if (snapshots.length === 0)
233
+ return false;
234
+ return this.restore(snapshots[0]);
235
+ });
129
236
  }
130
237
  /**
131
238
  * Get all tracked files.
@@ -141,8 +248,10 @@ export class FileHistory {
141
248
  * hide which files reverted.
142
249
  */
143
250
  restoreAllToEarliest() {
144
- const targets = earliestSnapshotsPerFile(this.snapshots);
145
- return targets.map((snap) => ({ filePath: snap.filePath, ok: this.restore(snap) }));
251
+ return this.withCurrentIndex(() => {
252
+ const targets = earliestSnapshotsPerFile(this.snapshots);
253
+ return targets.map((snap) => ({ filePath: snap.filePath, ok: this.restore(snap) }));
254
+ });
146
255
  }
147
256
  /**
148
257
  * Capture the file's CURRENT on-disk content into a fresh redo backup and
@@ -152,16 +261,44 @@ export class FileHistory {
152
261
  */
153
262
  captureRedoBackup(filePath, turnSeq, existedBefore) {
154
263
  try {
155
- if (!existsSync(filePath))
264
+ if (this.redoRecords.some((record) => record.filePath === filePath && record.turnSeq === turnSeq)) {
265
+ return true;
266
+ }
267
+ if (!existsSync(filePath)) {
268
+ const snapshot = this.snapshots.find((candidate) => candidate.filePath === filePath && this.turnKey(candidate.turnSeq) === turnSeq);
269
+ const backupPath = join(this.historyDir, `redo_absent_${Date.now()}_${randomUUID()}`);
270
+ writeFileAtomic(backupPath, "", 0o600);
271
+ this.redoRecords.push({
272
+ filePath,
273
+ turnSeq,
274
+ backupPath,
275
+ existedBefore,
276
+ existedAfter: false,
277
+ ...(snapshot?.realPath ? { realPath: snapshot.realPath } : {}),
278
+ });
279
+ return true;
280
+ }
281
+ const sourceInfo = lstatSync(filePath);
282
+ if (!sourceInfo.isFile() || sourceInfo.isSymbolicLink())
156
283
  return false;
157
284
  const content = readFileSync(filePath);
158
285
  const hash = createHash("md5").update(content).digest("hex");
159
286
  const timestamp = Date.now();
160
287
  const pathHash = createHash("md5").update(filePath).digest("hex").slice(0, 8);
161
288
  const safeName = filePath.replace(/[/\\:]/g, "_").slice(-80);
162
- const backupPath = join(this.historyDir, `redo_${timestamp}_${pathHash}_${hash.slice(0, 8)}_${safeName}`);
289
+ const backupPath = join(this.historyDir, `redo_${timestamp}_${pathHash}_${hash.slice(0, 8)}_${randomUUID()}_${safeName}`);
163
290
  copyFileSync(filePath, backupPath);
164
- this.redoRecords.push({ filePath, turnSeq, backupPath, existedBefore });
291
+ if (process.platform !== "win32")
292
+ chmodSync(backupPath, 0o600);
293
+ this.redoRecords.push({
294
+ filePath,
295
+ turnSeq,
296
+ backupPath,
297
+ existedBefore,
298
+ existedAfter: true,
299
+ mode: sourceInfo.mode & 0o7777,
300
+ realPath: realpathSync(filePath),
301
+ });
165
302
  return true;
166
303
  }
167
304
  catch {
@@ -181,20 +318,76 @@ export class FileHistory {
181
318
  * latestTurnUndoTargets so preview and restore agree. Returns per-file results.
182
319
  */
183
320
  undoLatestTurn(targets) {
184
- // The turn being undone = the turnSeq shared by the targets (all from the
185
- // same latest live turn). Created-only turns may have no snapshot targets, so
186
- // fall back to the created markers' turn.
187
- const undoneTurn = targets[0]?.turnSeq ?? this.latestLiveCreatedTurn();
188
- if (undoneTurn === undefined)
321
+ return this.withCurrentIndex(() => this.undoLatestTurnUnlocked(targets));
322
+ }
323
+ undoLatestTurnUnlocked(targets) {
324
+ const plan = this.latestTurnUndoPlanInternal();
325
+ if (!plan)
189
326
  return [];
327
+ if (!this.isExactSnapshotSet(targets, plan.snapshots)) {
328
+ return plan.filePaths.map((filePath) => ({ filePath, ok: false }));
329
+ }
330
+ const undoneTurn = plan.turnKey;
190
331
  const results = [];
191
332
  // Files created this turn: delete them (after stashing redo material). Skip
192
333
  // files that are also a restore target — created wins (the file didn't exist
193
334
  // pre-turn, so its "pre-turn state" is "absent" → delete, not restore).
194
335
  const createdThisTurn = this.created.filter((c) => !c.undone && c.turnSeq === undoneTurn);
195
336
  const createdPaths = new Set(createdThisTurn.map((c) => c.filePath));
337
+ // Validate every destination against the location recorded BEFORE the
338
+ // creating/editing tool ran. Redo backups describe the current location,
339
+ // so comparing only against them is tautological and would miss a parent
340
+ // directory swapped to a symlink after the edit.
341
+ const safeToCapture = plan.filePaths.every((filePath) => {
342
+ if (!this.isSafeDestination(filePath))
343
+ return false;
344
+ const marker = createdThisTurn.find((candidate) => candidate.filePath === filePath);
345
+ const snapshot = plan.snapshots.find((candidate) => candidate.filePath === filePath);
346
+ const expectedRealPath = marker?.realPath ?? snapshot?.realPath;
347
+ if (!this.matchesRecordedLocation(filePath, expectedRealPath))
348
+ return false;
349
+ return !snapshot || this.isSafeBackup(snapshot.backupPath);
350
+ });
351
+ if (!safeToCapture) {
352
+ return plan.filePaths.map((filePath) => ({ filePath, ok: false }));
353
+ }
354
+ // Capture every post-turn state before mutating any file. A missing file is
355
+ // a legitimate post-turn state (delete), represented by an absence marker.
356
+ // If any capture fails, leave all working files untouched and keep the turn
357
+ // live so the user can retry.
358
+ const captured = plan.filePaths.map((filePath) => this.captureRedoBackup(filePath, undoneTurn, !createdPaths.has(filePath)));
359
+ if (captured.some((ok) => !ok)) {
360
+ this.discardRedoTurn(undoneTurn);
361
+ this.saveIndex();
362
+ return plan.filePaths.map((filePath) => ({ filePath, ok: false }));
363
+ }
364
+ const redoForTurn = new Map(this.redoRecords
365
+ .filter((record) => record.turnSeq === undoneTurn)
366
+ .map((record) => [record.filePath, record]));
367
+ const canApply = plan.filePaths.every((filePath) => {
368
+ const redo = redoForTurn.get(filePath);
369
+ if (!redo || !this.isSafeBackup(redo.backupPath))
370
+ return false;
371
+ if (!this.isSafeDestination(filePath))
372
+ return false;
373
+ if (!this.matchesRecordedLocation(filePath, redo.realPath))
374
+ return false;
375
+ const snapshot = plan.snapshots.find((candidate) => candidate.filePath === filePath);
376
+ return !snapshot || this.isSafeBackup(snapshot.backupPath);
377
+ });
378
+ if (!canApply) {
379
+ this.discardRedoTurn(undoneTurn);
380
+ this.saveIndex();
381
+ return plan.filePaths.map((filePath) => ({ filePath, ok: false }));
382
+ }
383
+ // Commit the complete recovery plan before touching working files. If the
384
+ // process crashes during a restore, the next load still has the original
385
+ // post-turn material and can safely retry the idempotent operation.
386
+ if (!this.saveIndex()) {
387
+ this.discardRedoTurn(undoneTurn);
388
+ return plan.filePaths.map((filePath) => ({ filePath, ok: false }));
389
+ }
196
390
  for (const c of createdThisTurn) {
197
- this.captureRedoBackup(c.filePath, undoneTurn, false);
198
391
  let ok = true;
199
392
  try {
200
393
  if (existsSync(c.filePath))
@@ -207,35 +400,37 @@ export class FileHistory {
207
400
  }
208
401
  // Modified (pre-existing) files: stash the current result as redo material,
209
402
  // then restore the pre-turn content.
210
- for (const snap of targets) {
403
+ for (const snap of plan.snapshots) {
211
404
  if (createdPaths.has(snap.filePath))
212
405
  continue;
213
- this.captureRedoBackup(snap.filePath, undoneTurn, true);
214
- results.push({ filePath: snap.filePath, ok: this.restore(snap) });
406
+ results.push({ filePath: snap.filePath, ok: this.restoreSnapshot(snap, false) });
407
+ }
408
+ if (results.some((result) => !result.ok)) {
409
+ this.saveIndex();
410
+ return results;
215
411
  }
216
412
  // Mark the turn undone (snapshots + created markers) — keep them on disk so
217
413
  // redo has material and the turn is re-applyable.
218
414
  for (const s of this.snapshots) {
219
- if (s.turnSeq === undoneTurn)
415
+ if (this.turnKey(s.turnSeq) === undoneTurn)
220
416
  s.undone = true;
221
417
  }
222
418
  for (const c of this.created) {
223
419
  if (c.turnSeq === undoneTurn)
224
420
  c.undone = true;
225
421
  }
226
- this.saveIndex();
227
- return results;
228
- }
229
- /** Greatest turnSeq among not-yet-undone created markers, or undefined. */
230
- latestLiveCreatedTurn() {
231
- let max;
232
- for (const c of this.created) {
233
- if (c.undone)
234
- continue;
235
- if (max === undefined || c.turnSeq > max)
236
- max = c.turnSeq;
422
+ if (!this.saveIndex()) {
423
+ for (const s of this.snapshots) {
424
+ if (this.turnKey(s.turnSeq) === undoneTurn)
425
+ s.undone = false;
426
+ }
427
+ for (const c of this.created) {
428
+ if (c.turnSeq === undoneTurn)
429
+ c.undone = false;
430
+ }
431
+ return results.map((result) => ({ ...result, ok: false }));
237
432
  }
238
- return max;
433
+ return results;
239
434
  }
240
435
  /**
241
436
  * Turn-level redo: re-apply a previously undone turn. For each redo target,
@@ -247,14 +442,40 @@ export class FileHistory {
247
442
  * latest still-undone turn). Returns per-file results.
248
443
  */
249
444
  redoLatestTurn(redoTargets) {
445
+ return this.withCurrentIndex(() => this.redoLatestTurnUnlocked(redoTargets));
446
+ }
447
+ redoLatestTurnUnlocked(redoTargets) {
250
448
  if (redoTargets.length === 0)
251
449
  return [];
252
- const turn = redoTargets[0].turnSeq;
253
- const results = redoTargets.map((rec) => {
450
+ const expected = this.getLatestRedoRecords();
451
+ const authoritative = redoTargets.map((record) => this.findRedoRecord(record));
452
+ const expectedBackups = new Set(expected.map((record) => record.backupPath));
453
+ const suppliedBackups = new Set(authoritative.flatMap((record) => (record ? [record.backupPath] : [])));
454
+ const exactCurrentSet = authoritative.every((record) => record !== undefined) &&
455
+ suppliedBackups.size === authoritative.length &&
456
+ suppliedBackups.size === expectedBackups.size &&
457
+ [...suppliedBackups].every((backupPath) => expectedBackups.has(backupPath));
458
+ // Treat caller-provided records as untrusted values. Besides protecting the
459
+ // destination path, this prevents a stale preview from consuming a newer
460
+ // turn's only redo material.
461
+ if (!exactCurrentSet) {
462
+ return redoTargets.map((record) => ({ filePath: record.filePath, ok: false }));
463
+ }
464
+ const turn = authoritative[0].turnSeq;
465
+ const results = authoritative.map((rec) => {
254
466
  let ok = false;
255
467
  try {
256
- if (existsSync(rec.backupPath)) {
257
- copyFileSync(rec.backupPath, rec.filePath);
468
+ if (this.isSafeBackup(rec.backupPath) &&
469
+ this.isSafeDestination(rec.filePath) &&
470
+ this.matchesRecordedLocation(rec.filePath, rec.realPath)) {
471
+ if (rec.existedAfter === false) {
472
+ if (existsSync(rec.filePath))
473
+ rmSync(rec.filePath, { force: true });
474
+ }
475
+ else {
476
+ copyFileSync(rec.backupPath, rec.filePath);
477
+ this.restoreMode(rec.filePath, rec.mode);
478
+ }
258
479
  ok = true;
259
480
  }
260
481
  }
@@ -263,31 +484,48 @@ export class FileHistory {
263
484
  }
264
485
  return { filePath: rec.filePath, ok };
265
486
  });
266
- // Un-mark the turn: it is live again, so undo can re-target it.
487
+ // A partial filesystem failure must not consume redo history. Successful
488
+ // copies are safe to repeat when the user retries after fixing the failed
489
+ // path, while deleting the records here would make recovery impossible.
490
+ if (results.some((result) => !result.ok)) {
491
+ return results;
492
+ }
493
+ // Un-mark the turn and remove redo references in the index first. Backup
494
+ // files are deleted only after that commit succeeds; deleting them first
495
+ // creates a crash window where the durable index points at missing redo.
496
+ const previousRedoRecords = this.redoRecords;
267
497
  for (const s of this.snapshots) {
268
- if (s.turnSeq === turn)
498
+ if (this.turnKey(s.turnSeq) === turn)
269
499
  s.undone = false;
270
500
  }
271
501
  for (const c of this.created) {
272
502
  if (c.turnSeq === turn)
273
503
  c.undone = false;
274
504
  }
275
- // Consume the redo records for this turn (and remove their backups).
276
- const consumed = new Set(redoTargets);
277
- this.redoRecords = this.redoRecords.filter((r) => {
278
- if (consumed.has(r) || r.turnSeq === turn) {
279
- try {
280
- if (existsSync(r.backupPath))
281
- rmSync(r.backupPath, { force: true });
282
- }
283
- catch {
284
- // best-effort cleanup
285
- }
286
- return false;
505
+ const consumedBackups = new Set(authoritative.map((record) => record.backupPath));
506
+ const consumedRecords = this.redoRecords.filter((record) => consumedBackups.has(record.backupPath) || record.turnSeq === turn);
507
+ this.redoRecords = this.redoRecords.filter((record) => !consumedBackups.has(record.backupPath) && record.turnSeq !== turn);
508
+ if (!this.saveIndex()) {
509
+ for (const s of this.snapshots) {
510
+ if (this.turnKey(s.turnSeq) === turn)
511
+ s.undone = true;
287
512
  }
288
- return true;
289
- });
290
- this.saveIndex();
513
+ for (const c of this.created) {
514
+ if (c.turnSeq === turn)
515
+ c.undone = true;
516
+ }
517
+ this.redoRecords = previousRedoRecords;
518
+ return results.map((result) => ({ ...result, ok: false }));
519
+ }
520
+ for (const record of consumedRecords) {
521
+ try {
522
+ if (this.isSafeBackup(record.backupPath))
523
+ rmSync(record.backupPath, { force: true });
524
+ }
525
+ catch {
526
+ // The index no longer references this file; cleanup remains best effort.
527
+ }
528
+ }
291
529
  return results;
292
530
  }
293
531
  /**
@@ -296,7 +534,7 @@ export class FileHistory {
296
534
  * Returns a copy so callers can't mutate the internal list.
297
535
  */
298
536
  getAllSnapshots() {
299
- return [...this.snapshots];
537
+ return this.snapshots.map((snapshot) => ({ ...snapshot }));
300
538
  }
301
539
  saveIndex() {
302
540
  try {
@@ -306,33 +544,302 @@ export class FileHistory {
306
544
  redoRecords: this.redoRecords,
307
545
  created: this.created,
308
546
  };
309
- writeFileSync(indexPath, JSON.stringify(index, null, 2), "utf-8");
547
+ writeFileAtomic(indexPath, `${JSON.stringify(index, null, 2)}\n`, 0o600);
548
+ return true;
310
549
  }
311
550
  catch {
312
- // Silent fail
551
+ return false;
313
552
  }
314
553
  }
315
554
  static loadFromDir(sessionDir) {
316
555
  const history = new FileHistory(sessionDir);
317
- const indexPath = join(history.historyDir, "index.json");
556
+ history.reloadIndex(false);
557
+ return history;
558
+ }
559
+ /** Serialize every read-modify-write and refresh after taking the lock. */
560
+ withCurrentIndex(operation) {
561
+ if (this.lockDepth > 0)
562
+ return operation();
563
+ const indexPath = join(this.historyDir, "index.json");
564
+ const release = acquireFileLock(indexPath);
565
+ this.lockDepth = 1;
566
+ try {
567
+ this.reloadIndex(true);
568
+ return operation();
569
+ }
570
+ finally {
571
+ this.lockDepth = 0;
572
+ release();
573
+ }
574
+ }
575
+ reloadIndex(throwOnInvalid) {
576
+ const indexPath = join(this.historyDir, "index.json");
577
+ this.snapshots = [];
578
+ this.redoRecords = [];
579
+ this.created = [];
318
580
  if (existsSync(indexPath)) {
319
581
  try {
320
582
  const parsed = JSON.parse(readFileSync(indexPath, "utf-8"));
321
583
  if (Array.isArray(parsed)) {
322
584
  // Legacy v1: bare FileSnapshot[]. No redo/created material existed yet.
323
- history.snapshots = parsed;
585
+ this.snapshots = parsed.filter((value) => this.isSnapshot(value));
324
586
  }
325
587
  else {
326
588
  const index = parsed;
327
- history.snapshots = index.snapshots ?? [];
328
- history.redoRecords = index.redoRecords ?? [];
329
- history.created = index.created ?? [];
589
+ this.snapshots = Array.isArray(index.snapshots)
590
+ ? index.snapshots.filter((value) => this.isSnapshot(value))
591
+ : [];
592
+ this.redoRecords = Array.isArray(index.redoRecords)
593
+ ? index.redoRecords.filter((value) => this.isRedoRecord(value))
594
+ : [];
595
+ this.created = Array.isArray(index.created)
596
+ ? index.created.filter(isCreatedMarker)
597
+ : [];
330
598
  }
331
599
  }
600
+ catch (err) {
601
+ if (throwOnInvalid)
602
+ throw err;
603
+ // Read-only load keeps the historical recovery behavior. A later
604
+ // mutation reloads under lock and fails closed instead of overwriting
605
+ // the malformed index with an empty one.
606
+ }
607
+ }
608
+ }
609
+ isSafeBackup(backupPath) {
610
+ if (!this.isContainedBackupPath(backupPath))
611
+ return false;
612
+ try {
613
+ const info = lstatSync(backupPath);
614
+ return info.isFile() && !info.isSymbolicLink();
615
+ }
616
+ catch {
617
+ return false;
618
+ }
619
+ }
620
+ isContainedBackupPath(backupPath) {
621
+ if (typeof backupPath !== "string" || !isAbsolute(backupPath))
622
+ return false;
623
+ const rel = relative(this.historyDir, resolve(backupPath));
624
+ return Boolean(rel && rel !== ".." && !rel.startsWith(`..${sep}`) && !isAbsolute(rel));
625
+ }
626
+ /** Never follow a destination symlink while restoring an old file version. */
627
+ isSafeDestination(filePath) {
628
+ if (typeof filePath !== "string" || !isAbsolute(filePath))
629
+ return false;
630
+ if (!existsSync(filePath))
631
+ return true;
632
+ try {
633
+ const info = lstatSync(filePath);
634
+ return info.isFile() && !info.isSymbolicLink();
635
+ }
636
+ catch {
637
+ return false;
638
+ }
639
+ }
640
+ canonicalizeNewPath(filePath) {
641
+ const absPath = resolve(filePath);
642
+ let candidate = absPath;
643
+ const missingSegments = [];
644
+ for (let depth = 0; depth < 256; depth += 1) {
645
+ try {
646
+ return join(realpathSync(candidate), ...missingSegments.reverse());
647
+ }
332
648
  catch {
333
- // Start fresh
649
+ const parent = dirname(candidate);
650
+ if (parent === candidate)
651
+ return undefined;
652
+ missingSegments.push(basename(candidate));
653
+ candidate = parent;
334
654
  }
335
655
  }
336
- return history;
656
+ return undefined;
657
+ }
658
+ matchesRecordedLocation(filePath, realPath) {
659
+ if (!realPath)
660
+ return true; // Legacy index written before canonical metadata.
661
+ try {
662
+ const current = existsSync(filePath) ? realpathSync(filePath) : this.canonicalizeNewPath(filePath);
663
+ return current === realPath;
664
+ }
665
+ catch {
666
+ return false;
667
+ }
668
+ }
669
+ restoreSnapshot(snapshot, saveCurrent) {
670
+ try {
671
+ if (!this.isSafeBackup(snapshot.backupPath))
672
+ return false;
673
+ if (!this.isSafeDestination(snapshot.filePath))
674
+ return false;
675
+ if (!this.matchesRecordedLocation(snapshot.filePath, snapshot.realPath))
676
+ return false;
677
+ const currentMode = this.readMode(snapshot.filePath);
678
+ if (saveCurrent && existsSync(snapshot.filePath) && !this.saveSnapshot(snapshot.filePath)) {
679
+ return false;
680
+ }
681
+ copyFileSync(snapshot.backupPath, snapshot.filePath);
682
+ this.restoreMode(snapshot.filePath, snapshot.mode ?? currentMode);
683
+ return true;
684
+ }
685
+ catch {
686
+ return false;
687
+ }
337
688
  }
689
+ readMode(filePath) {
690
+ if (process.platform === "win32" || !existsSync(filePath))
691
+ return undefined;
692
+ try {
693
+ return lstatSync(filePath).mode & 0o7777;
694
+ }
695
+ catch {
696
+ return undefined;
697
+ }
698
+ }
699
+ restoreMode(filePath, mode) {
700
+ if (process.platform === "win32" || mode === undefined)
701
+ return;
702
+ chmodSync(filePath, mode);
703
+ }
704
+ discardRedoTurn(turnSeq) {
705
+ this.redoRecords = this.redoRecords.filter((record) => {
706
+ if (record.turnSeq !== turnSeq)
707
+ return true;
708
+ try {
709
+ if (this.isSafeBackup(record.backupPath))
710
+ rmSync(record.backupPath, { force: true });
711
+ }
712
+ catch {
713
+ // Best-effort cleanup; dropping the index reference is the safety boundary.
714
+ }
715
+ return false;
716
+ });
717
+ }
718
+ turnKey(turnSeq) {
719
+ return turnSeq ?? LEGACY_TURN;
720
+ }
721
+ latestTurnUndoPlanInternal() {
722
+ let latest = -Infinity;
723
+ let found = false;
724
+ for (const snapshot of this.snapshots) {
725
+ if (snapshot.undone)
726
+ continue;
727
+ latest = Math.max(latest, this.turnKey(snapshot.turnSeq));
728
+ found = true;
729
+ }
730
+ for (const marker of this.created) {
731
+ if (marker.undone)
732
+ continue;
733
+ latest = Math.max(latest, marker.turnSeq);
734
+ found = true;
735
+ }
736
+ if (!found)
737
+ return null;
738
+ const earliest = new Map();
739
+ for (const snapshot of this.snapshots) {
740
+ if (snapshot.undone || this.turnKey(snapshot.turnSeq) !== latest)
741
+ continue;
742
+ const current = earliest.get(snapshot.filePath);
743
+ if (!current || snapshot.timestamp < current.timestamp) {
744
+ earliest.set(snapshot.filePath, snapshot);
745
+ }
746
+ }
747
+ const snapshots = [...earliest.values()].sort((a, b) => a.timestamp - b.timestamp);
748
+ const createdPaths = this.created
749
+ .filter((marker) => !marker.undone && marker.turnSeq === latest)
750
+ .map((marker) => marker.filePath);
751
+ const filePaths = [...new Set([...createdPaths, ...snapshots.map((item) => item.filePath)])];
752
+ return {
753
+ turnKey: latest,
754
+ ...(latest === LEGACY_TURN ? {} : { turnSeq: latest }),
755
+ snapshots,
756
+ createdPaths,
757
+ filePaths,
758
+ };
759
+ }
760
+ isExactSnapshotSet(candidates, expected) {
761
+ if (candidates.length !== expected.length)
762
+ return false;
763
+ const remaining = [...expected];
764
+ for (const candidate of candidates) {
765
+ const index = remaining.findIndex((snapshot) => this.sameSnapshot(snapshot, candidate));
766
+ if (index < 0)
767
+ return false;
768
+ remaining.splice(index, 1);
769
+ }
770
+ return remaining.length === 0;
771
+ }
772
+ sameSnapshot(a, b) {
773
+ return (a.filePath === b.filePath &&
774
+ a.timestamp === b.timestamp &&
775
+ a.backupPath === b.backupPath &&
776
+ a.hash === b.hash &&
777
+ a.size === b.size &&
778
+ a.turnSeq === b.turnSeq &&
779
+ a.undone === b.undone);
780
+ }
781
+ findSnapshot(candidate) {
782
+ return this.snapshots.find((snapshot) => this.sameSnapshot(snapshot, candidate));
783
+ }
784
+ findRedoRecord(candidate) {
785
+ return this.redoRecords.find((record) => record.filePath === candidate.filePath &&
786
+ record.turnSeq === candidate.turnSeq &&
787
+ record.backupPath === candidate.backupPath &&
788
+ record.existedBefore === candidate.existedBefore &&
789
+ record.existedAfter === candidate.existedAfter);
790
+ }
791
+ isSnapshot(value) {
792
+ if (!value || typeof value !== "object" || Array.isArray(value))
793
+ return false;
794
+ const snapshot = value;
795
+ return (typeof snapshot.filePath === "string" &&
796
+ isAbsolute(snapshot.filePath) &&
797
+ typeof snapshot.timestamp === "number" &&
798
+ Number.isFinite(snapshot.timestamp) &&
799
+ typeof snapshot.hash === "string" &&
800
+ typeof snapshot.size === "number" &&
801
+ Number.isFinite(snapshot.size) &&
802
+ (snapshot.mode === undefined || isFileMode(snapshot.mode)) &&
803
+ (snapshot.realPath === undefined ||
804
+ (typeof snapshot.realPath === "string" && isAbsolute(snapshot.realPath))) &&
805
+ (snapshot.turnSeq === undefined || isFiniteTurn(snapshot.turnSeq)) &&
806
+ (snapshot.undone === undefined || typeof snapshot.undone === "boolean") &&
807
+ this.isSafeBackup(snapshot.backupPath));
808
+ }
809
+ isRedoRecord(value) {
810
+ if (!value || typeof value !== "object" || Array.isArray(value))
811
+ return false;
812
+ const record = value;
813
+ return (typeof record.filePath === "string" &&
814
+ isAbsolute(record.filePath) &&
815
+ typeof record.turnSeq === "number" &&
816
+ isHistoryTurn(record.turnSeq) &&
817
+ typeof record.existedBefore === "boolean" &&
818
+ (record.existedAfter === undefined || typeof record.existedAfter === "boolean") &&
819
+ (record.mode === undefined || isFileMode(record.mode)) &&
820
+ (record.realPath === undefined ||
821
+ (typeof record.realPath === "string" && isAbsolute(record.realPath))) &&
822
+ this.isContainedBackupPath(record.backupPath));
823
+ }
824
+ }
825
+ function isCreatedMarker(value) {
826
+ if (!value || typeof value !== "object" || Array.isArray(value))
827
+ return false;
828
+ const marker = value;
829
+ return (typeof marker.filePath === "string" &&
830
+ isAbsolute(marker.filePath) &&
831
+ typeof marker.turnSeq === "number" &&
832
+ isFiniteTurn(marker.turnSeq) &&
833
+ (marker.realPath === undefined ||
834
+ (typeof marker.realPath === "string" && isAbsolute(marker.realPath))) &&
835
+ (marker.undone === undefined || typeof marker.undone === "boolean"));
836
+ }
837
+ function isFiniteTurn(value) {
838
+ return typeof value === "number" && Number.isSafeInteger(value) && value >= 0;
839
+ }
840
+ function isHistoryTurn(value) {
841
+ return value === LEGACY_TURN || isFiniteTurn(value);
842
+ }
843
+ function isFileMode(value) {
844
+ return typeof value === "number" && Number.isInteger(value) && value >= 0 && value <= 0o7777;
338
845
  }