@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
@@ -8,10 +8,12 @@
8
8
  * File: ~/.code-shell/runs/<runId>/heartbeat
9
9
  * Content: JSON { pid, timestamp, runId }
10
10
  */
11
- import { existsSync, readFileSync, writeFileSync, unlinkSync } from "node:fs";
12
- import { join } from "node:path";
11
+ import { closeSync, constants, existsSync, fstatSync, lstatSync, openSync, readFileSync, renameSync, rmSync, unlinkSync, writeFileSync, } from "node:fs";
12
+ import { dirname, join } from "node:path";
13
13
  import { homedir } from "node:os";
14
+ import { randomUUID } from "node:crypto";
14
15
  import { assertSafeRunId } from "./ids.js";
16
+ const MAX_HEARTBEAT_BYTES = 64 * 1024;
15
17
  export class Heartbeat {
16
18
  runsDir;
17
19
  intervalMs;
@@ -66,14 +68,42 @@ export class Heartbeat {
66
68
  read(runId) {
67
69
  assertSafeRunId(runId);
68
70
  const filePath = this.filePath(runId);
71
+ try {
72
+ const parentInfo = lstatSync(dirname(filePath));
73
+ if (parentInfo.isSymbolicLink() || !parentInfo.isDirectory())
74
+ return null;
75
+ }
76
+ catch {
77
+ return null;
78
+ }
69
79
  if (!existsSync(filePath))
70
80
  return null;
81
+ let fd;
71
82
  try {
72
- return JSON.parse(readFileSync(filePath, "utf-8"));
83
+ const entry = lstatSync(filePath);
84
+ if (entry.isSymbolicLink() || !entry.isFile() || entry.size > MAX_HEARTBEAT_BYTES)
85
+ return null;
86
+ fd = openSync(filePath, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0));
87
+ const opened = fstatSync(fd);
88
+ if (!opened.isFile() || opened.size > MAX_HEARTBEAT_BYTES)
89
+ return null;
90
+ const value = JSON.parse(readFileSync(fd, "utf-8"));
91
+ if (value.runId !== runId ||
92
+ !Number.isSafeInteger(value.pid) ||
93
+ (value.pid ?? 0) <= 0 ||
94
+ !Number.isSafeInteger(value.timestamp) ||
95
+ (value.timestamp ?? -1) < 0) {
96
+ return null;
97
+ }
98
+ return value;
73
99
  }
74
100
  catch {
75
101
  return null;
76
102
  }
103
+ finally {
104
+ if (fd !== undefined)
105
+ closeSync(fd);
106
+ }
77
107
  }
78
108
  /**
79
109
  * Check if a run's heartbeat is stale (older than threshold).
@@ -110,17 +140,46 @@ export class Heartbeat {
110
140
  timestamp: Date.now(),
111
141
  runId,
112
142
  };
143
+ const filePath = this.filePath(runId);
144
+ const temporary = `${filePath}.${process.pid}.${randomUUID()}.tmp`;
113
145
  try {
114
- writeFileSync(this.filePath(runId), JSON.stringify(data), "utf-8");
146
+ const parent = dirname(filePath);
147
+ const parentInfo = lstatSync(parent);
148
+ if (parentInfo.isSymbolicLink() || !parentInfo.isDirectory())
149
+ return;
150
+ try {
151
+ const targetInfo = lstatSync(filePath);
152
+ if (targetInfo.isSymbolicLink() || !targetInfo.isFile())
153
+ return;
154
+ }
155
+ catch (error) {
156
+ if (error.code !== "ENOENT")
157
+ return;
158
+ }
159
+ writeFileSync(temporary, JSON.stringify(data), {
160
+ encoding: "utf8",
161
+ mode: 0o600,
162
+ flag: "wx",
163
+ });
164
+ renameSync(temporary, filePath);
115
165
  }
116
166
  catch {
117
167
  // Run directory may have been deleted
118
168
  }
169
+ finally {
170
+ rmSync(temporary, { force: true });
171
+ }
119
172
  }
120
173
  remove(runId) {
121
174
  try {
122
175
  const filePath = this.filePath(runId);
176
+ const parentInfo = lstatSync(dirname(filePath));
177
+ if (parentInfo.isSymbolicLink() || !parentInfo.isDirectory())
178
+ return;
123
179
  if (existsSync(filePath)) {
180
+ const targetInfo = lstatSync(filePath);
181
+ if (targetInfo.isSymbolicLink() || !targetInfo.isFile())
182
+ return;
124
183
  unlinkSync(filePath);
125
184
  }
126
185
  }
@@ -4,7 +4,7 @@
4
4
  * Periodically consolidates and organizes memories using the LLM,
5
5
  * similar to the /dream command but running automatically.
6
6
  */
7
- import { existsSync, readFileSync, writeFileSync, mkdirSync } from "node:fs";
7
+ import { closeSync, constants, fstatSync, lstatSync, mkdirSync, openSync, readFileSync, } from "node:fs";
8
8
  import { join } from "node:path";
9
9
  import { resolveMemoryBaseDir } from "../session/memory.js";
10
10
  import { mutateJsonFile } from "../utils/file-mutex.js";
@@ -13,6 +13,8 @@ const DEFAULT_CONFIG = {
13
13
  minTimeBetween: 24 * 60 * 60 * 1000, // 24 hours
14
14
  enabled: true,
15
15
  };
16
+ const MAX_DREAM_STATE_BYTES = 64 * 1024;
17
+ const MAX_SESSION_COUNT = 1_000_000;
16
18
  // Co-locate the dream-cadence state with the memories it tracks: both resolve
17
19
  // through resolveMemoryBaseDir (CODE_SHELL_HOME ?? $HOME ?? homedir()), so a
18
20
  // relocated/test HOME moves them together and never writes the real ~/.code-shell.
@@ -21,18 +23,38 @@ function getStateFile() {
21
23
  }
22
24
  function loadState() {
23
25
  const stateFile = getStateFile();
24
- if (existsSync(stateFile)) {
25
- try {
26
- return JSON.parse(readFileSync(stateFile, "utf-8"));
26
+ let descriptor;
27
+ try {
28
+ const pathInfo = lstatSync(stateFile);
29
+ if (pathInfo.isSymbolicLink() || !pathInfo.isFile() || pathInfo.size > MAX_DREAM_STATE_BYTES) {
30
+ return { lastDreamAt: null, sessionsSinceLastDream: 0 };
27
31
  }
28
- catch { }
32
+ descriptor = openSync(stateFile, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0));
33
+ const opened = fstatSync(descriptor);
34
+ if (!opened.isFile() || opened.size > MAX_DREAM_STATE_BYTES) {
35
+ return { lastDreamAt: null, sessionsSinceLastDream: 0 };
36
+ }
37
+ return parseDreamState(readFileSync(descriptor, "utf-8"));
38
+ }
39
+ catch {
40
+ return { lastDreamAt: null, sessionsSinceLastDream: 0 };
41
+ }
42
+ finally {
43
+ if (descriptor !== undefined)
44
+ closeSync(descriptor);
29
45
  }
30
- return { lastDreamAt: null, sessionsSinceLastDream: 0 };
31
46
  }
32
- function saveState(state) {
33
- const stateFile = getStateFile();
34
- mkdirSync(resolveMemoryBaseDir(), { recursive: true });
35
- writeFileSync(stateFile, JSON.stringify(state, null, 2), "utf-8");
47
+ function parseDreamState(raw) {
48
+ const parsed = JSON.parse(raw);
49
+ const timestamp = typeof parsed.lastDreamAt === "string" ? parsed.lastDreamAt : null;
50
+ const lastDreamAt = timestamp && timestamp.length <= 64 && Number.isFinite(Date.parse(timestamp)) ? timestamp : null;
51
+ const count = parsed.sessionsSinceLastDream;
52
+ return {
53
+ lastDreamAt,
54
+ sessionsSinceLastDream: typeof count === "number" && Number.isSafeInteger(count) && count >= 0
55
+ ? Math.min(count, MAX_SESSION_COUNT)
56
+ : 0,
57
+ };
36
58
  }
37
59
  /**
38
60
  * Read-modify-write the cadence state under a cross-process lock.
@@ -47,11 +69,7 @@ function mutateState(change) {
47
69
  if (raw === undefined)
48
70
  return { lastDreamAt: null, sessionsSinceLastDream: 0 };
49
71
  try {
50
- const parsed = JSON.parse(raw);
51
- return {
52
- lastDreamAt: parsed.lastDreamAt ?? null,
53
- sessionsSinceLastDream: Number(parsed.sessionsSinceLastDream) || 0,
54
- };
72
+ return parseDreamState(raw);
55
73
  }
56
74
  catch {
57
75
  return { lastDreamAt: null, sessionsSinceLastDream: 0 };
@@ -59,6 +77,7 @@ function mutateState(change) {
59
77
  },
60
78
  serialize: (state) => JSON.stringify(state, null, 2),
61
79
  mutation: (current) => ({ value: change(current) }),
80
+ maxBytes: MAX_DREAM_STATE_BYTES,
62
81
  });
63
82
  }
64
83
  /**
@@ -99,7 +118,7 @@ export function recordSession() {
99
118
  // reached its threshold and auto-consolidation silently stopped happening.
100
119
  mutateState((state) => ({
101
120
  ...state,
102
- sessionsSinceLastDream: state.sessionsSinceLastDream + 1,
121
+ sessionsSinceLastDream: Math.min(MAX_SESSION_COUNT, state.sessionsSinceLastDream + 1),
103
122
  }));
104
123
  }
105
124
  /**
@@ -111,9 +130,12 @@ export function recordSession() {
111
130
  * resetting to 0 threw away increments that belonged to the next cycle.
112
131
  */
113
132
  export function recordDreamComplete(consumed) {
133
+ const safeConsumed = typeof consumed === "number" && Number.isSafeInteger(consumed) && consumed >= 0
134
+ ? consumed
135
+ : undefined;
114
136
  mutateState((state) => ({
115
137
  lastDreamAt: new Date().toISOString(),
116
- sessionsSinceLastDream: consumed === undefined ? 0 : Math.max(0, state.sessionsSinceLastDream - consumed),
138
+ sessionsSinceLastDream: safeConsumed === undefined ? 0 : Math.max(0, state.sessionsSinceLastDream - safeConsumed),
117
139
  }));
118
140
  }
119
141
  /**
@@ -4,10 +4,15 @@
4
4
  * Extracts and maintains conversation context as persistent memory
5
5
  * entries that survive across sessions.
6
6
  */
7
- import { existsSync, readFileSync, writeFileSync, mkdirSync, readdirSync } from "node:fs";
7
+ import { randomUUID } from "node:crypto";
8
+ import { closeSync, constants, existsSync, fstatSync, lstatSync, mkdirSync, openSync, readFileSync, readdirSync, renameSync, rmSync, writeFileSync, } from "node:fs";
8
9
  import { join } from "node:path";
9
10
  import { userHome } from "../settings/manager.js";
10
11
  import { sortSessionMemoriesByRecency } from "./session-memory-sort.js";
12
+ const SESSION_ID_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,255}$/u;
13
+ const MAX_MEMORY_FILE_BYTES = 1024 * 1024;
14
+ const MAX_MEMORY_ENTRIES = 20_000;
15
+ const MAX_MEMORY_LIST_ITEMS = 1_000;
11
16
  // Resolve per-call (NOT a module const): userHome() reads $HOME live, so a
12
17
  // relocated/test HOME redirects writes instead of pinning the real ~/.code-shell
13
18
  // at import time (bun freezes a module-level homedir() and never re-reads it).
@@ -22,23 +27,54 @@ function memoryDir(baseDir) {
22
27
  */
23
28
  export function saveSessionMemory(entry, baseDir) {
24
29
  const dir = memoryDir(baseDir);
25
- mkdirSync(dir, { recursive: true });
30
+ assertSessionMemoryEntry(entry);
31
+ ensureRealMemoryDirectory(dir);
26
32
  const file = join(dir, `${entry.sessionId}.json`);
27
- writeFileSync(file, JSON.stringify(entry, null, 2), "utf-8");
33
+ assertSafeMemoryTarget(file);
34
+ const serialized = JSON.stringify(entry, null, 2);
35
+ if (Buffer.byteLength(serialized, "utf8") > MAX_MEMORY_FILE_BYTES) {
36
+ throw new Error("session memory is too large");
37
+ }
38
+ const temporary = `${file}.${process.pid}.${randomUUID()}.tmp`;
39
+ try {
40
+ writeFileSync(temporary, serialized, { encoding: "utf8", mode: 0o600, flag: "wx" });
41
+ renameSync(temporary, file);
42
+ }
43
+ finally {
44
+ rmSync(temporary, { force: true });
45
+ }
28
46
  }
29
47
  /**
30
48
  * Load a session memory by session ID.
31
49
  */
32
50
  export function loadSessionMemory(sessionId, baseDir) {
51
+ if (!validSessionId(sessionId))
52
+ return null;
33
53
  const file = join(memoryDir(baseDir), `${sessionId}.json`);
34
54
  if (!existsSync(file))
35
55
  return null;
56
+ let fd;
36
57
  try {
37
- return JSON.parse(readFileSync(file, "utf-8"));
58
+ const dirInfo = lstatSync(memoryDir(baseDir));
59
+ if (dirInfo.isSymbolicLink() || !dirInfo.isDirectory())
60
+ return null;
61
+ const entry = lstatSync(file);
62
+ if (entry.isSymbolicLink() || !entry.isFile() || entry.size > MAX_MEMORY_FILE_BYTES)
63
+ return null;
64
+ fd = openSync(file, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0));
65
+ const opened = fstatSync(fd);
66
+ if (!opened.isFile() || opened.size > MAX_MEMORY_FILE_BYTES)
67
+ return null;
68
+ const parsed = JSON.parse(readFileSync(fd, "utf-8"));
69
+ return validSessionMemoryEntry(parsed) && parsed.sessionId === sessionId ? parsed : null;
38
70
  }
39
71
  catch {
40
72
  return null;
41
73
  }
74
+ finally {
75
+ if (fd !== undefined)
76
+ closeSync(fd);
77
+ }
42
78
  }
43
79
  /**
44
80
  * List all session memories, most recent first.
@@ -47,21 +83,84 @@ export function listSessionMemories(limit = 50, baseDir) {
47
83
  const dir = memoryDir(baseDir);
48
84
  if (!existsSync(dir))
49
85
  return [];
86
+ try {
87
+ const info = lstatSync(dir);
88
+ if (info.isSymbolicLink() || !info.isDirectory())
89
+ return [];
90
+ }
91
+ catch {
92
+ return [];
93
+ }
50
94
  // Read all entries, then order by createdAt (not by filename — the filename
51
95
  // is the sessionId, which has no chronological meaning), then take `limit`.
52
96
  const entries = [];
53
- for (const file of readdirSync(dir)) {
54
- if (!file.endsWith(".json"))
97
+ const files = readdirSync(dir, { withFileTypes: true });
98
+ if (files.length > MAX_MEMORY_ENTRIES)
99
+ return [];
100
+ for (const file of files) {
101
+ if (!file.isFile() || !file.name.endsWith(".json"))
102
+ continue;
103
+ const sessionId = file.name.slice(0, -5);
104
+ if (!validSessionId(sessionId))
55
105
  continue;
56
106
  try {
57
- entries.push(JSON.parse(readFileSync(join(dir, file), "utf-8")));
107
+ const entry = loadSessionMemory(sessionId, baseDir);
108
+ if (entry)
109
+ entries.push(entry);
58
110
  }
59
111
  catch {
60
112
  /* intentional: skip a corrupt/torn memory file rather than failing the
61
113
  whole listing — one bad entry must not hide all the others. */
62
114
  }
63
115
  }
64
- return sortSessionMemoriesByRecency(entries).slice(0, limit);
116
+ const safeLimit = Number.isSafeInteger(limit) && limit > 0 ? Math.min(limit, MAX_MEMORY_LIST_ITEMS) : 0;
117
+ return sortSessionMemoriesByRecency(entries).slice(0, safeLimit);
118
+ }
119
+ function validSessionId(value) {
120
+ return typeof value === "string" && SESSION_ID_RE.test(value) && value !== "." && value !== "..";
121
+ }
122
+ function validSessionMemoryEntry(value) {
123
+ if (!value || typeof value !== "object" || Array.isArray(value))
124
+ return false;
125
+ const entry = value;
126
+ return (validSessionId(entry.sessionId) &&
127
+ typeof entry.summary === "string" &&
128
+ entry.summary.length <= 100_000 &&
129
+ validMemoryStringList(entry.keyTopics) &&
130
+ validMemoryStringList(entry.decisions) &&
131
+ typeof entry.createdAt === "string" &&
132
+ entry.createdAt.length <= 64 &&
133
+ Number.isFinite(Date.parse(entry.createdAt)) &&
134
+ (entry.tokenCount === undefined ||
135
+ (Number.isSafeInteger(entry.tokenCount) && entry.tokenCount >= 0 && entry.tokenCount <= 10_000_000)));
136
+ }
137
+ function validMemoryStringList(value) {
138
+ return (Array.isArray(value) &&
139
+ value.length <= 1_000 &&
140
+ value.every((item) => typeof item === "string" && item.length <= 4_096 && !item.includes("\0")));
141
+ }
142
+ function assertSessionMemoryEntry(entry) {
143
+ if (!validSessionMemoryEntry(entry))
144
+ throw new Error("invalid session memory entry");
145
+ }
146
+ function ensureRealMemoryDirectory(dir) {
147
+ mkdirSync(dir, { recursive: true, mode: 0o700 });
148
+ const info = lstatSync(dir);
149
+ if (info.isSymbolicLink() || !info.isDirectory()) {
150
+ throw new Error("session memory directory must be a real directory");
151
+ }
152
+ }
153
+ function assertSafeMemoryTarget(file) {
154
+ try {
155
+ const info = lstatSync(file);
156
+ if (info.isSymbolicLink() || !info.isFile()) {
157
+ throw new Error("session memory target must be a regular file");
158
+ }
159
+ }
160
+ catch (error) {
161
+ if (error.code !== "ENOENT")
162
+ throw error;
163
+ }
65
164
  }
66
165
  /**
67
166
  * Search session memories by keyword.
@@ -10,6 +10,10 @@ export interface FileSnapshot {
10
10
  backupPath: string;
11
11
  hash: string;
12
12
  size: number;
13
+ /** Original POSIX permission bits, restored independently of backup privacy. */
14
+ mode?: number;
15
+ /** Canonical location at capture time, used to detect parent symlink swaps. */
16
+ realPath?: string;
13
17
  /**
14
18
  * The conversation turn (one user message = one turn) this snapshot was taken
15
19
  * in. Powers turn-level undo (`latestTurnUndoTargets`). Optional so snapshots
@@ -37,6 +41,12 @@ export interface RedoRecord {
37
41
  turnSeq: number;
38
42
  /** Backup of the post-turn content to re-apply on redo. */
39
43
  backupPath: string;
44
+ /** False when the turn's result was file absence (for example a delete). */
45
+ existedAfter?: boolean;
46
+ /** Post-turn POSIX permission bits to restore on redo. */
47
+ mode?: number;
48
+ /** Canonical location of the target at capture time. */
49
+ realPath?: string;
40
50
  /**
41
51
  * Whether the file EXISTED before this turn (had a pre-turn snapshot). False
42
52
  * means the turn CREATED it — undo deleted it, so redo must recreate it (and
@@ -55,10 +65,20 @@ export interface RedoRecord {
55
65
  export interface CreatedMarker {
56
66
  filePath: string;
57
67
  turnSeq: number;
68
+ /** Canonical destination planned before the creating tool ran. */
69
+ realPath?: string;
58
70
  undone?: boolean;
59
71
  }
72
+ export interface TurnUndoPlan {
73
+ /** Undefined denotes a legacy, pre-turnSeq history bucket. */
74
+ turnSeq?: number;
75
+ snapshots: FileSnapshot[];
76
+ createdPaths: string[];
77
+ filePaths: string[];
78
+ }
60
79
  export declare class FileHistory {
61
80
  private readonly historyDir;
81
+ private lockDepth;
62
82
  private snapshots;
63
83
  /**
64
84
  * Redo material for undone turns, kept SEPARATE from `snapshots` so it never
@@ -77,6 +97,7 @@ export declare class FileHistory {
77
97
  * latestTurnUndoTargets). Omit it for callers without turn context.
78
98
  */
79
99
  saveSnapshot(filePath: string, turnSeq?: number): FileSnapshot | null;
100
+ private saveSnapshotUnlocked;
80
101
  /**
81
102
  * Record that `filePath` was CREATED in `turnSeq` — called by the engine hook
82
103
  * when the pre-edit saveSnapshot returns null (file did not exist yet). Idempotent
@@ -86,8 +107,27 @@ export declare class FileHistory {
86
107
  * re-armed) — only fresh turns create new markers.
87
108
  */
88
109
  recordCreated(filePath: string, turnSeq: number): void;
110
+ private recordCreatedUnlocked;
111
+ /**
112
+ * Capture a create marker before the tool runs, but do not persist it yet.
113
+ * `lstat` (rather than `existsSync`) deliberately treats dangling symlinks as
114
+ * existing: a failed/no-follow Write must never turn such a path into a
115
+ * create marker that a later undo would remove.
116
+ */
117
+ prepareCreated(filePath: string, turnSeq: number): CreatedMarker | null;
118
+ /** Persist a marker previously returned by prepareCreated after tool success. */
119
+ commitCreated(marker: CreatedMarker): void;
120
+ private commitCreatedUnlocked;
89
121
  /** Redo material captured by past undos (see RedoRecord). Returns a copy. */
90
122
  getRedoRecords(): RedoRecord[];
123
+ /** Latest redo records after accounting for create-only live turns. */
124
+ getLatestRedoRecords(): RedoRecord[];
125
+ /**
126
+ * Authoritative latest-turn plan, including turns that only created files.
127
+ * Keeping this decision in FileHistory prevents UI callers from accidentally
128
+ * selecting an older snapshot-only turn.
129
+ */
130
+ getLatestTurnUndoPlan(): TurnUndoPlan | null;
91
131
  /**
92
132
  * Get all snapshots for a specific file.
93
133
  */
@@ -138,8 +178,7 @@ export declare class FileHistory {
138
178
  filePath: string;
139
179
  ok: boolean;
140
180
  }>;
141
- /** Greatest turnSeq among not-yet-undone created markers, or undefined. */
142
- private latestLiveCreatedTurn;
181
+ private undoLatestTurnUnlocked;
143
182
  /**
144
183
  * Turn-level redo: re-apply a previously undone turn. For each redo target,
145
184
  * write the stashed post-turn content back to disk (existedBefore:false
@@ -153,6 +192,7 @@ export declare class FileHistory {
153
192
  filePath: string;
154
193
  ok: boolean;
155
194
  }>;
195
+ private redoLatestTurnUnlocked;
156
196
  /**
157
197
  * All snapshots in record (chronological) order. Used by undo to pick the
158
198
  * single most-recent modification across every file (see latestUndoTarget).
@@ -161,4 +201,25 @@ export declare class FileHistory {
161
201
  getAllSnapshots(): FileSnapshot[];
162
202
  private saveIndex;
163
203
  static loadFromDir(sessionDir: string): FileHistory;
204
+ /** Serialize every read-modify-write and refresh after taking the lock. */
205
+ private withCurrentIndex;
206
+ private reloadIndex;
207
+ private isSafeBackup;
208
+ private isContainedBackupPath;
209
+ /** Never follow a destination symlink while restoring an old file version. */
210
+ private isSafeDestination;
211
+ private canonicalizeNewPath;
212
+ private matchesRecordedLocation;
213
+ private restoreSnapshot;
214
+ private readMode;
215
+ private restoreMode;
216
+ private discardRedoTurn;
217
+ private turnKey;
218
+ private latestTurnUndoPlanInternal;
219
+ private isExactSnapshotSet;
220
+ private sameSnapshot;
221
+ private findSnapshot;
222
+ private findRedoRecord;
223
+ private isSnapshot;
224
+ private isRedoRecord;
164
225
  }