@wrongstack/core 0.310.1 → 0.313.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (187) hide show
  1. package/dist/chronicle/index.js +84 -6391
  2. package/dist/chronicle/project-server.js +84 -4868
  3. package/dist/chunk-2BUUIMQB.js +225 -0
  4. package/dist/chunk-2FK4WTFF.js +207 -0
  5. package/dist/chunk-2FQL4PDC.js +292 -0
  6. package/dist/chunk-2KKODIQU.js +2256 -0
  7. package/dist/chunk-2YU4WVBW.js +134 -0
  8. package/dist/chunk-3E244UDA.js +356 -0
  9. package/dist/chunk-3XPVB7BA.js +135 -0
  10. package/dist/chunk-4LJJYBAU.js +388 -0
  11. package/dist/chunk-4VWO3ZXL.js +342 -0
  12. package/dist/chunk-4XV6IR37.js +187 -0
  13. package/dist/chunk-4ZHTKJHM.js +183 -0
  14. package/dist/chunk-52RTVH4M.js +361 -0
  15. package/dist/chunk-57GEWK5X.js +26 -0
  16. package/dist/chunk-5DSMEVA4.js +931 -0
  17. package/dist/chunk-5RP3QTRL.js +1223 -0
  18. package/dist/chunk-5S437PRC.js +50 -0
  19. package/dist/chunk-5XDUGTKN.js +511 -0
  20. package/dist/chunk-62V2AFVG.js +422 -0
  21. package/dist/chunk-65RDIOR2.js +2470 -0
  22. package/dist/chunk-6AKOTGLM.js +597 -0
  23. package/dist/chunk-7ZHZE5EO.js +28 -0
  24. package/dist/chunk-A455ATZB.js +140 -0
  25. package/dist/chunk-AD7ZFZCS.js +46 -0
  26. package/dist/chunk-AI4LLQ2U.js +856 -0
  27. package/dist/chunk-ASX6AW36.js +789 -0
  28. package/dist/chunk-AT4V4XGI.js +3522 -0
  29. package/dist/chunk-AWNQ6ED6.js +311 -0
  30. package/dist/chunk-B4AMLZPB.js +9 -0
  31. package/dist/chunk-BAKVJLEV.js +417 -0
  32. package/dist/chunk-BJHVULHO.js +55 -0
  33. package/dist/chunk-CB2HFKTP.js +9 -0
  34. package/dist/chunk-CE4ZOCOI.js +1640 -0
  35. package/dist/chunk-CQQL4ZOQ.js +151 -0
  36. package/dist/chunk-DEIQMBD2.js +65 -0
  37. package/dist/chunk-E6I5RXVK.js +58 -0
  38. package/dist/chunk-EFEHDQGY.js +1589 -0
  39. package/dist/chunk-EODDVJBM.js +82 -0
  40. package/dist/chunk-EPC3GZD4.js +308 -0
  41. package/dist/chunk-FAYRX2AD.js +13 -0
  42. package/dist/chunk-FFSX2QDQ.js +362 -0
  43. package/dist/chunk-FHAMQS7Q.js +5856 -0
  44. package/dist/chunk-FIV4IJL7.js +2263 -0
  45. package/dist/chunk-FNXVT7HF.js +52 -0
  46. package/dist/chunk-FSWCWSTS.js +395 -0
  47. package/dist/chunk-FW5LDDMJ.js +16464 -0
  48. package/dist/chunk-FXQLIX3K.js +211 -0
  49. package/dist/chunk-G5AAKEEZ.js +228 -0
  50. package/dist/chunk-G5S3S5ZN.js +38 -0
  51. package/dist/chunk-GAZEWQEZ.js +231 -0
  52. package/dist/chunk-GFJOMSHG.js +125 -0
  53. package/dist/chunk-GFXZTHNI.js +158 -0
  54. package/dist/chunk-GM54GJBU.js +960 -0
  55. package/dist/chunk-GPQ3NJPR.js +900 -0
  56. package/dist/chunk-GTCU6DZ3.js +1 -0
  57. package/dist/chunk-H5E77KUJ.js +15 -0
  58. package/dist/chunk-H72H4XDU.js +1023 -0
  59. package/dist/chunk-HA4ZYA6T.js +178 -0
  60. package/dist/chunk-I45KAW2N.js +5020 -0
  61. package/dist/chunk-IQ35HMQL.js +1396 -0
  62. package/dist/chunk-IYFBFX34.js +86 -0
  63. package/dist/chunk-JJPMWPB6.js +109 -0
  64. package/dist/chunk-JTHKOYWU.js +663 -0
  65. package/dist/chunk-JVGPR3TS.js +365 -0
  66. package/dist/chunk-JVKB2TBL.js +48 -0
  67. package/dist/chunk-JX7VBZDK.js +31 -0
  68. package/dist/chunk-K3G6JBUC.js +5232 -0
  69. package/dist/chunk-K4YUNKU7.js +589 -0
  70. package/dist/chunk-KBZGE6LN.js +710 -0
  71. package/dist/chunk-KDQJCT2J.js +45 -0
  72. package/dist/chunk-KI4L2DUL.js +560 -0
  73. package/dist/chunk-KJ2ZTQSL.js +47 -0
  74. package/dist/chunk-KSJ5UTP7.js +154 -0
  75. package/dist/chunk-LHOA5DWA.js +28 -0
  76. package/dist/chunk-LX5QWLBL.js +27 -0
  77. package/dist/chunk-LZDDLNCZ.js +279 -0
  78. package/dist/chunk-M2WF6AXG.js +115 -0
  79. package/dist/chunk-M7BS2K6U.js +1 -0
  80. package/dist/chunk-NDBIPFNY.js +102 -0
  81. package/dist/chunk-NEMIWEXC.js +1199 -0
  82. package/dist/chunk-NFAYHPRH.js +323 -0
  83. package/dist/chunk-O37BQ65S.js +21 -0
  84. package/dist/chunk-O74MJHXU.js +187 -0
  85. package/dist/chunk-OJZEX73F.js +112 -0
  86. package/dist/chunk-PAAWLLZX.js +74 -0
  87. package/dist/chunk-PFXFQQRT.js +339 -0
  88. package/dist/chunk-PFZO3C6H.js +14 -0
  89. package/dist/chunk-PHH7LD45.js +113 -0
  90. package/dist/chunk-QRTG4XTI.js +188 -0
  91. package/dist/chunk-QVOVMXJR.js +1351 -0
  92. package/dist/chunk-RCQYT2I4.js +757 -0
  93. package/dist/chunk-RD6DEE36.js +1178 -0
  94. package/dist/chunk-RJIZEJ42.js +30 -0
  95. package/dist/chunk-RPOA7IAE.js +150 -0
  96. package/dist/chunk-SN2DQRL6.js +1172 -0
  97. package/dist/chunk-SQJVFEBV.js +41 -0
  98. package/dist/chunk-SQP64RCS.js +5391 -0
  99. package/dist/chunk-SVT4RNTR.js +49 -0
  100. package/dist/chunk-SWUFN7XM.js +43 -0
  101. package/dist/chunk-SXMVYC5H.js +174 -0
  102. package/dist/chunk-SZ6AYUIZ.js +23 -0
  103. package/dist/chunk-T3KGZBU5.js +3171 -0
  104. package/dist/chunk-TBRL2RCC.js +9 -0
  105. package/dist/chunk-TRT2HRWZ.js +18 -0
  106. package/dist/chunk-TYW2GI5I.js +1077 -0
  107. package/dist/chunk-UIWONWVG.js +4959 -0
  108. package/dist/chunk-VB3H5WWB.js +151 -0
  109. package/dist/chunk-W4A6QA3D.js +258 -0
  110. package/dist/chunk-WBPVKEEE.js +227 -0
  111. package/dist/chunk-WEBMSQP2.js +123 -0
  112. package/dist/chunk-WOEZV3Q4.js +333 -0
  113. package/dist/chunk-WQQON3SV.js +473 -0
  114. package/dist/chunk-WQTOVMGV.js +119 -0
  115. package/dist/chunk-WZO23AWO.js +2779 -0
  116. package/dist/chunk-XBDP4QPW.js +7 -0
  117. package/dist/chunk-XEBHSJLT.js +103 -0
  118. package/dist/chunk-XQ2ECNK3.js +1 -0
  119. package/dist/chunk-XQ2XGIBF.js +2086 -0
  120. package/dist/chunk-Y2GE5TSI.js +355 -0
  121. package/dist/chunk-YBUJCZ57.js +351 -0
  122. package/dist/chunk-YNCVSFGF.js +149 -0
  123. package/dist/chunk-YUPMXOGP.js +282 -0
  124. package/dist/chunk-YY6APYUM.js +49 -0
  125. package/dist/chunk-Z2IJUO45.js +691 -0
  126. package/dist/chunk-ZEHUCJHY.js +129 -0
  127. package/dist/chunk-ZYU2YOJ6.js +1496 -0
  128. package/dist/chunk-ZZ4V3WIZ.js +163 -0
  129. package/dist/coordination/agents/index.js +145 -6260
  130. package/dist/coordination/index.js +365 -34323
  131. package/dist/coordination/mailbox-project-server.js +48 -3220
  132. package/dist/coordination/mailbox-types.d.ts +13 -1
  133. package/dist/coordination/provider-status-tracker.d.ts +4 -0
  134. package/dist/coordination/sqlite-mailbox.d.ts +2 -1
  135. package/dist/core/agent.d.ts +14 -1
  136. package/dist/core/index.js +267 -12479
  137. package/dist/core/model-ref.js +5 -23
  138. package/dist/core/provider-runner.d.ts +36 -1
  139. package/dist/design/index.js +50 -1087
  140. package/dist/execution/index.d.ts +1 -1
  141. package/dist/execution/index.js +1599 -21887
  142. package/dist/execution/prompt-enhancer.js +16 -535
  143. package/dist/execution/retry-policy.d.ts +31 -1
  144. package/dist/extension/index.js +7 -311
  145. package/dist/goal/index.js +33 -3435
  146. package/dist/hooks/index.js +14 -1138
  147. package/dist/hq/index.js +112 -5139
  148. package/dist/hq/protocol.js +21 -668
  149. package/dist/index.js +1410 -92655
  150. package/dist/infrastructure/index.js +64 -4811
  151. package/dist/kernel/events/provider-events.d.ts +15 -0
  152. package/dist/kernel/events/wrongtrace-events.d.ts +26 -0
  153. package/dist/kernel/events.d.ts +16 -7
  154. package/dist/kernel/index.js +14 -992
  155. package/dist/models/index.js +40 -1117
  156. package/dist/notifications/index.js +3 -108
  157. package/dist/observability/index.js +17 -279
  158. package/dist/plugin/index.js +584 -14835
  159. package/dist/prompts/index.js +14 -948
  160. package/dist/registry/index.js +9 -822
  161. package/dist/registry-R23UBE7H.js +14 -0
  162. package/dist/replay/index.js +6 -84
  163. package/dist/review-finding-store-DKAV3AS6.js +21 -0
  164. package/dist/review-report-store-OWY3EC3N.js +19 -0
  165. package/dist/security/index.js +114 -2202
  166. package/dist/session-catalog/index.js +36 -2103
  167. package/dist/session-catalog/project-server.js +42 -1435
  168. package/dist/skills/index.js +35 -1578
  169. package/dist/storage/index.js +182 -15600
  170. package/dist/tasking/index.js +10 -676
  171. package/dist/tools/index.js +52 -9589
  172. package/dist/types/config/autonomy.d.ts +5 -0
  173. package/dist/types/config/tools.d.ts +2 -2
  174. package/dist/types/config/ui.d.ts +1 -1
  175. package/dist/types/errors.d.ts +18 -0
  176. package/dist/types/index.js +121 -1341
  177. package/dist/types/provider-runner.d.ts +8 -0
  178. package/dist/types/session-markers.js +8 -144
  179. package/dist/utils/error.js +3 -4
  180. package/dist/utils/expect-defined.js +3 -9
  181. package/dist/utils/heap-watchdog.js +6 -706
  182. package/dist/utils/index.js +269 -5685
  183. package/dist/utils/sage-output-block.js +5 -42
  184. package/dist/wiring/proxy-rewrite.d.ts +92 -4
  185. package/dist/wiring/proxy-rewrite.js +20 -61
  186. package/dist/worktree/index.js +6 -887
  187. package/package.json +4 -4
@@ -0,0 +1,4959 @@
1
+ import {
2
+ mapWithConcurrency
3
+ } from "./chunk-SZ6AYUIZ.js";
4
+ import {
5
+ SessionCatalogProjectClient,
6
+ resolveSessionCatalogProjectServerUrl
7
+ } from "./chunk-52RTVH4M.js";
8
+ import {
9
+ SECRET_FILE_MODE
10
+ } from "./chunk-H5E77KUJ.js";
11
+ import {
12
+ DefaultSecretScrubber
13
+ } from "./chunk-3E244UDA.js";
14
+ import {
15
+ isSessionTranscriptFileName,
16
+ sessionScopedPath
17
+ } from "./chunk-AD7ZFZCS.js";
18
+ import {
19
+ buildChildEnv
20
+ } from "./chunk-VB3H5WWB.js";
21
+ import {
22
+ repairToolUseAdjacency
23
+ } from "./chunk-M2WF6AXG.js";
24
+ import {
25
+ ulid
26
+ } from "./chunk-4XV6IR37.js";
27
+ import {
28
+ isPidAlive
29
+ } from "./chunk-TRT2HRWZ.js";
30
+ import {
31
+ atomicWrite,
32
+ ensureDir,
33
+ withFileLock
34
+ } from "./chunk-7ZHZE5EO.js";
35
+ import {
36
+ toErrorMessage
37
+ } from "./chunk-TBRL2RCC.js";
38
+
39
+ // src/storage/director-state.ts
40
+ import * as fsp from "node:fs/promises";
41
+ import { hostname } from "node:os";
42
+ async function loadDirectorState(filePath) {
43
+ let raw;
44
+ try {
45
+ raw = await fsp.readFile(filePath, "utf8");
46
+ } catch {
47
+ return null;
48
+ }
49
+ try {
50
+ const parsed = JSON.parse(raw);
51
+ if (parsed?.version !== 1) return null;
52
+ return parsed;
53
+ } catch {
54
+ return null;
55
+ }
56
+ }
57
+ async function acquireDirectorStateLock(lockPath, processId = process.pid) {
58
+ let existing;
59
+ try {
60
+ existing = await fsp.readFile(lockPath, "utf8");
61
+ } catch {
62
+ }
63
+ if (existing) {
64
+ try {
65
+ const lock2 = JSON.parse(existing);
66
+ if (isPidAlive(lock2.pid)) return false;
67
+ } catch {
68
+ }
69
+ }
70
+ const lock = {
71
+ pid: processId,
72
+ hostname: hostname(),
73
+ startedAt: (/* @__PURE__ */ new Date()).toISOString()
74
+ };
75
+ await atomicWrite(lockPath, JSON.stringify(lock), { mode: 384 });
76
+ return true;
77
+ }
78
+ async function releaseDirectorStateLock(lockPath) {
79
+ try {
80
+ await fsp.unlink(lockPath);
81
+ } catch {
82
+ }
83
+ }
84
+ var DirectorStateCheckpoint = class {
85
+ snapshot;
86
+ filePath;
87
+ lockPath;
88
+ timer = null;
89
+ debounceMs;
90
+ writing = false;
91
+ rewriteRequested = false;
92
+ constructor(filePath, init, debounceMs = 250) {
93
+ this.filePath = filePath;
94
+ this.lockPath = `${filePath}.lock`;
95
+ this.debounceMs = debounceMs;
96
+ this.snapshot = {
97
+ version: 1,
98
+ directorRunId: init.directorRunId,
99
+ updatedAt: (/* @__PURE__ */ new Date()).toISOString(),
100
+ spawnCount: 0,
101
+ maxSpawns: init.maxSpawns,
102
+ spawnDepth: init.spawnDepth,
103
+ maxSpawnDepth: init.maxSpawnDepth,
104
+ directorBudget: init.directorBudget,
105
+ subagents: [],
106
+ tasks: []
107
+ };
108
+ }
109
+ /**
110
+ * Attempt to acquire the lock for this checkpoint. Call this before
111
+ * resuming a crashed director run. If it returns false, another
112
+ * director process is still running this fleet — do not resume.
113
+ */
114
+ async acquireLock() {
115
+ return acquireDirectorStateLock(this.lockPath);
116
+ }
117
+ /**
118
+ * Release the lock on graceful shutdown. Call `flush()` first to ensure
119
+ * the final checkpoint state is on disk before removing the lock.
120
+ * Without this, the next resume will see a stale-lock and refuse.
121
+ */
122
+ async releaseLock() {
123
+ return releaseDirectorStateLock(this.lockPath);
124
+ }
125
+ /**
126
+ * Resume from a snapshot previously loaded via `loadDirectorState()`.
127
+ * Use this when `--resume <runId>` is triggered — the snapshot has
128
+ * the full fleet state (subagents, tasks) from before the crash; the
129
+ * checkpoint continues from there.
130
+ */
131
+ resume(snapshot) {
132
+ this.snapshot = snapshot;
133
+ }
134
+ /**
135
+ * Reconciles tasks and worktrees that were left in 'running' or 'allocated'
136
+ * state when the previous director crashed. Transitions abandoned tasks to
137
+ * 'failed' with a descriptive error and returns the list of repaired task IDs.
138
+ */
139
+ reconcileCrashedState(reason = "Director crashed or was terminated while task was in flight") {
140
+ const repairedTaskIds = [];
141
+ const now = (/* @__PURE__ */ new Date()).toISOString();
142
+ this.snapshot = {
143
+ ...this.snapshot,
144
+ tasks: this.snapshot.tasks.map((task) => {
145
+ if (task.status === "running") {
146
+ repairedTaskIds.push(task.taskId);
147
+ return {
148
+ ...task,
149
+ status: "failed",
150
+ completedAt: now,
151
+ error: task.error ? `${task.error} (${reason})` : reason,
152
+ worktree: task.worktree && task.worktree.status === "allocated" ? { ...task.worktree, status: "failed", error: reason } : task.worktree
153
+ };
154
+ }
155
+ return task;
156
+ })
157
+ };
158
+ if (repairedTaskIds.length > 0) {
159
+ this.bumpUpdatedAt();
160
+ this.schedule();
161
+ }
162
+ return repairedTaskIds;
163
+ }
164
+ /**
165
+ * After resume, pin the live spawn ceiling from the current profile/flag
166
+ * while preserving `spawnCount` (cumulative used budget). Checkpoint
167
+ * metadata previously stored a historical `maxSpawns` that can diverge
168
+ * from the live runtime ceiling — operators need the live value to win.
169
+ */
170
+ applyLiveMaxSpawns(maxSpawns) {
171
+ if (this.snapshot.maxSpawns === maxSpawns) return;
172
+ this.snapshot = {
173
+ ...this.snapshot,
174
+ maxSpawns
175
+ };
176
+ this.bumpUpdatedAt();
177
+ this.schedule();
178
+ }
179
+ current() {
180
+ return this.snapshot;
181
+ }
182
+ recordSpawn(sub, spawnCount) {
183
+ this.snapshot = {
184
+ ...this.snapshot,
185
+ spawnCount,
186
+ subagents: [...this.snapshot.subagents.filter((s) => s.id !== sub.id), sub]
187
+ };
188
+ this.bumpUpdatedAt();
189
+ this.schedule();
190
+ }
191
+ recordTaskAssigned(task) {
192
+ const exists = this.snapshot.tasks.some((t) => t.taskId === task.taskId);
193
+ this.snapshot = {
194
+ ...this.snapshot,
195
+ tasks: exists ? this.snapshot.tasks.map((t) => t.taskId === task.taskId ? { ...t, ...task } : t) : [...this.snapshot.tasks, task]
196
+ };
197
+ this.bumpUpdatedAt();
198
+ this.schedule();
199
+ }
200
+ recordTaskStatus(taskId, patch) {
201
+ this.snapshot = {
202
+ ...this.snapshot,
203
+ tasks: this.snapshot.tasks.map((t) => t.taskId === taskId ? { ...t, ...patch } : t)
204
+ };
205
+ this.bumpUpdatedAt();
206
+ this.schedule();
207
+ }
208
+ recordTaskWorktree(taskId, worktree) {
209
+ const exists = this.snapshot.tasks.some((t) => t.taskId === taskId);
210
+ this.snapshot = {
211
+ ...this.snapshot,
212
+ tasks: exists ? this.snapshot.tasks.map((t) => t.taskId === taskId ? { ...t, worktree } : t) : [
213
+ ...this.snapshot.tasks,
214
+ {
215
+ taskId,
216
+ subagentId: worktree.subagentId,
217
+ status: "running",
218
+ worktree
219
+ }
220
+ ]
221
+ };
222
+ this.bumpUpdatedAt();
223
+ this.schedule();
224
+ }
225
+ setUsage(usage) {
226
+ this.snapshot = { ...this.snapshot, usage };
227
+ this.bumpUpdatedAt();
228
+ this.schedule();
229
+ }
230
+ /** Force a synchronous flush — used by Director.shutdown(). */
231
+ async flush() {
232
+ if (this.timer) {
233
+ clearTimeout(this.timer);
234
+ this.timer = null;
235
+ }
236
+ await this.persist();
237
+ while (this.rewriteRequested) {
238
+ this.rewriteRequested = false;
239
+ await this.persist();
240
+ }
241
+ }
242
+ bumpUpdatedAt() {
243
+ this.snapshot = { ...this.snapshot, updatedAt: (/* @__PURE__ */ new Date()).toISOString() };
244
+ }
245
+ schedule() {
246
+ if (this.timer) return;
247
+ this.timer = setTimeout(() => {
248
+ this.timer = null;
249
+ void this.persist();
250
+ }, this.debounceMs);
251
+ }
252
+ async persist() {
253
+ if (this.writing) {
254
+ this.rewriteRequested = true;
255
+ return;
256
+ }
257
+ this.writing = true;
258
+ try {
259
+ await atomicWrite(this.filePath, JSON.stringify(this.snapshot, null, 2), {
260
+ mode: 384
261
+ });
262
+ } catch (err) {
263
+ console.warn(
264
+ JSON.stringify({
265
+ level: "warn",
266
+ event: "director_state.checkpoint_write_failed",
267
+ message: toErrorMessage(err),
268
+ timestamp: (/* @__PURE__ */ new Date()).toISOString()
269
+ })
270
+ );
271
+ } finally {
272
+ this.writing = false;
273
+ if (this.rewriteRequested) {
274
+ this.rewriteRequested = false;
275
+ this.schedule();
276
+ }
277
+ }
278
+ }
279
+ };
280
+
281
+ // src/storage/session-checkpoint-cas.ts
282
+ import { spawn } from "node:child_process";
283
+ import { createHash, randomUUID } from "node:crypto";
284
+ import * as fsp2 from "node:fs/promises";
285
+ import * as path from "node:path";
286
+ var HASH_RE = /^[a-f\d]{64}$/;
287
+ var CAPTURE_CONCURRENCY = 8;
288
+ var MAX_GIT_OUTPUT = 16 * 1024 * 1024;
289
+ var MAX_BLOB_BYTES = 64 * 1024 * 1024;
290
+ var MAX_CHECKPOINT_BYTES = 512 * 1024 * 1024;
291
+ function sha256(content) {
292
+ return createHash("sha256").update(content).digest("hex");
293
+ }
294
+ function isInside(root, target) {
295
+ const relative3 = path.relative(root, target);
296
+ return relative3 === "" || !relative3.startsWith("..") && !path.isAbsolute(relative3);
297
+ }
298
+ function normalizeRelative(input) {
299
+ if (!input || path.isAbsolute(input)) return null;
300
+ const normalized = input.replace(/\\/g, "/").replace(/^\.\//, "");
301
+ const resolved = path.posix.normalize(normalized);
302
+ if (!resolved || resolved === "." || resolved === ".." || resolved.startsWith("../")) return null;
303
+ return resolved;
304
+ }
305
+ function parseNulPaths(output) {
306
+ return output.split("\0").map(normalizeRelative).filter((value) => value !== null);
307
+ }
308
+ function isWrongStackWorktreePath(relative3) {
309
+ return relative3 === ".wrongstack/worktrees" || relative3.startsWith(".wrongstack/worktrees/");
310
+ }
311
+ var SessionCheckpointCas = class {
312
+ rootDir;
313
+ projectRoot;
314
+ runGit;
315
+ constructor(opts) {
316
+ this.rootDir = path.resolve(opts.rootDir);
317
+ this.projectRoot = path.resolve(opts.projectRoot);
318
+ this.runGit = opts.runGit ?? defaultRunGit;
319
+ }
320
+ async capture(_sessionId, _promptIndex) {
321
+ const head = await this.runGit(["rev-parse", "--verify", "HEAD"], this.projectRoot);
322
+ const baseHead = head.stdout.trim();
323
+ if (head.code !== 0 || head.stdoutTruncated || !/^[a-f\d]{40,64}$/i.test(baseHead)) {
324
+ return void 0;
325
+ }
326
+ const [tracked, untracked] = await Promise.all([
327
+ this.runGit(["diff", "HEAD", "--name-only", "-z", "--"], this.projectRoot),
328
+ this.runGit(["ls-files", "--others", "--exclude-standard", "-z"], this.projectRoot)
329
+ ]);
330
+ if (tracked.code !== 0 || untracked.code !== 0 || tracked.stdoutTruncated || untracked.stdoutTruncated) {
331
+ return void 0;
332
+ }
333
+ const relativePaths = [
334
+ .../* @__PURE__ */ new Set([...parseNulPaths(tracked.stdout), ...parseNulPaths(untracked.stdout)])
335
+ ].filter((relative3) => !isWrongStackWorktreePath(relative3)).sort();
336
+ const unresolved = [];
337
+ let scheduledBytes = 0;
338
+ const captured = await mapWithConcurrency(
339
+ relativePaths,
340
+ CAPTURE_CONCURRENCY,
341
+ async (relative3) => {
342
+ const absolute = path.resolve(this.projectRoot, ...relative3.split("/"));
343
+ if (!isInside(this.projectRoot, absolute)) {
344
+ unresolved.push({ path: relative3, reason: "path escapes project root" });
345
+ return null;
346
+ }
347
+ try {
348
+ const stat11 = await fsp2.lstat(absolute);
349
+ if (stat11.isSymbolicLink()) {
350
+ const linkTarget = await fsp2.readlink(absolute);
351
+ const resolvedLink = path.resolve(path.dirname(absolute), linkTarget);
352
+ if (path.isAbsolute(linkTarget) || !isInside(this.projectRoot, resolvedLink)) {
353
+ unresolved.push({
354
+ path: relative3,
355
+ reason: "symlink target escapes project root"
356
+ });
357
+ return null;
358
+ }
359
+ return { path: relative3, state: "symlink", linkTarget };
360
+ }
361
+ if (!stat11.isFile()) {
362
+ unresolved.push({ path: relative3, reason: "changed path is not a regular file" });
363
+ return null;
364
+ }
365
+ if (stat11.size > MAX_BLOB_BYTES) {
366
+ unresolved.push({
367
+ path: relative3,
368
+ reason: `file exceeds ${MAX_BLOB_BYTES}-byte checkpoint blob limit`
369
+ });
370
+ return null;
371
+ }
372
+ if (scheduledBytes + stat11.size > MAX_CHECKPOINT_BYTES) {
373
+ unresolved.push({
374
+ path: relative3,
375
+ reason: `checkpoint exceeds ${MAX_CHECKPOINT_BYTES}-byte aggregate blob limit`
376
+ });
377
+ return null;
378
+ }
379
+ scheduledBytes += stat11.size;
380
+ const content = await fsp2.readFile(absolute);
381
+ const blobHash = sha256(content);
382
+ await this.putBlob(blobHash, content);
383
+ return { path: relative3, state: "file", blobHash, mode: stat11.mode & 511 };
384
+ } catch (err) {
385
+ if (err.code === "ENOENT") {
386
+ return { path: relative3, state: "absent" };
387
+ }
388
+ unresolved.push({ path: relative3, reason: toErrorMessage(err) });
389
+ return null;
390
+ }
391
+ }
392
+ );
393
+ const capturedAt = (/* @__PURE__ */ new Date()).toISOString();
394
+ const manifest = {
395
+ version: 1,
396
+ baseHead: baseHead.toLowerCase(),
397
+ coverage: "git-head-plus-dirty",
398
+ entries: captured.filter((entry) => entry !== null),
399
+ unresolved: [...unresolved].sort((a, b) => a.path.localeCompare(b.path))
400
+ };
401
+ const encoded = JSON.stringify(manifest);
402
+ const manifestHash = sha256(encoded);
403
+ await this.putManifest(manifestHash, encoded);
404
+ return {
405
+ manifestHash,
406
+ baseHead: manifest.baseHead,
407
+ entryCount: manifest.entries.length,
408
+ unresolvedCount: manifest.unresolved.length,
409
+ capturedAt,
410
+ coverage: manifest.coverage
411
+ };
412
+ }
413
+ async materialize(checkpoint, targetRoot) {
414
+ const target = path.resolve(targetRoot);
415
+ if (target === this.projectRoot) {
416
+ throw new Error("Refusing to materialize a workspace checkpoint over the parent project root");
417
+ }
418
+ const targetStat = await fsp2.stat(target);
419
+ if (!targetStat.isDirectory()) throw new Error(`Checkpoint target is not a directory: ${target}`);
420
+ const manifest = await this.loadManifest(checkpoint.manifestHash);
421
+ if (manifest.baseHead !== checkpoint.baseHead.toLowerCase() || manifest.coverage !== checkpoint.coverage || manifest.entries.length !== checkpoint.entryCount || manifest.unresolved.length !== checkpoint.unresolvedCount) {
422
+ throw new Error("Workspace checkpoint reference does not match its CAS manifest");
423
+ }
424
+ if (manifest.unresolved.length > 0) {
425
+ throw new Error(
426
+ `Workspace checkpoint has ${manifest.unresolved.length} unresolved path(s); exact materialization refused`
427
+ );
428
+ }
429
+ const targetHead = await this.runGit(["rev-parse", "--verify", "HEAD"], target);
430
+ if (targetHead.code !== 0 || targetHead.stdoutTruncated || targetHead.stdout.trim().toLowerCase() !== manifest.baseHead) {
431
+ throw new Error(
432
+ `Checkpoint target HEAD must equal ${manifest.baseHead}; got ${targetHead.stdout.trim() || "unknown"}`
433
+ );
434
+ }
435
+ const targetStatus = await this.runGit(
436
+ ["status", "--porcelain=v1", "-z", "--untracked-files=all", "--ignored=matching"],
437
+ target
438
+ );
439
+ if (targetStatus.code !== 0 || targetStatus.stdoutTruncated) {
440
+ throw new Error("Unable to verify that the checkpoint target is clean");
441
+ }
442
+ if (targetStatus.stdout.length > 0) {
443
+ throw new Error("Checkpoint target must be a clean checkout before materialization");
444
+ }
445
+ const realTarget = await fsp2.realpath(target);
446
+ const writtenFiles = [];
447
+ const deletedFiles = [];
448
+ const errors = [];
449
+ const prepared = [];
450
+ for (const entry of manifest.entries) {
451
+ try {
452
+ const output = await this.safeOutputPath(target, realTarget, entry.path);
453
+ if (entry.state === "symlink") {
454
+ if (path.isAbsolute(entry.linkTarget)) {
455
+ throw new Error("absolute symlink target refused");
456
+ }
457
+ const resolvedLink = path.resolve(path.dirname(output), entry.linkTarget);
458
+ if (!isInside(target, resolvedLink)) throw new Error("symlink target escapes checkpoint root");
459
+ }
460
+ prepared.push({
461
+ entry,
462
+ output,
463
+ ...entry.state === "file" ? { content: await this.readBlob(entry.blobHash) } : {}
464
+ });
465
+ } catch (err) {
466
+ errors.push(`${entry.path}: ${toErrorMessage(err)}`);
467
+ }
468
+ }
469
+ if (errors.length > 0) {
470
+ return { targetRoot: target, writtenFiles, deletedFiles, errors };
471
+ }
472
+ for (const { entry, output, content } of prepared) {
473
+ try {
474
+ if (entry.state === "absent") {
475
+ await fsp2.unlink(output).catch((err) => {
476
+ if (err.code !== "ENOENT") throw err;
477
+ });
478
+ deletedFiles.push(entry.path);
479
+ continue;
480
+ }
481
+ if (entry.state === "symlink") {
482
+ await fsp2.unlink(output).catch((err) => {
483
+ if (err.code !== "ENOENT") throw err;
484
+ });
485
+ await fsp2.mkdir(path.dirname(output), { recursive: true });
486
+ await fsp2.symlink(entry.linkTarget, output);
487
+ writtenFiles.push(entry.path);
488
+ continue;
489
+ }
490
+ if (!content) throw new Error("CAS blob was not prepared");
491
+ await atomicWrite(output, content, { mode: entry.mode });
492
+ await fsp2.chmod(output, entry.mode).catch(() => void 0);
493
+ writtenFiles.push(entry.path);
494
+ } catch (err) {
495
+ errors.push(`${entry.path}: ${toErrorMessage(err)}`);
496
+ }
497
+ }
498
+ return { targetRoot: target, writtenFiles, deletedFiles, errors };
499
+ }
500
+ objectPath(hash) {
501
+ if (!HASH_RE.test(hash)) throw new Error(`Invalid CAS object hash: ${hash}`);
502
+ return path.join(this.rootDir, "objects", hash.slice(0, 2), hash.slice(2));
503
+ }
504
+ manifestPath(hash) {
505
+ if (!HASH_RE.test(hash)) throw new Error(`Invalid checkpoint manifest hash: ${hash}`);
506
+ return path.join(this.rootDir, "manifests", `${hash}.json`);
507
+ }
508
+ async putBlob(hash, content) {
509
+ const target = this.objectPath(hash);
510
+ await fsp2.mkdir(path.dirname(target), { recursive: true });
511
+ try {
512
+ const existing = await fsp2.readFile(target);
513
+ if (sha256(existing) !== hash) throw new Error(`Corrupt CAS object collision: ${hash}`);
514
+ return;
515
+ } catch (err) {
516
+ if (err.code !== "ENOENT") throw err;
517
+ }
518
+ const temp = path.join(
519
+ path.dirname(target),
520
+ `.${path.basename(target)}.${process.pid}.${randomUUID()}.tmp`
521
+ );
522
+ let handle;
523
+ try {
524
+ handle = await fsp2.open(temp, "wx", 384);
525
+ await handle.writeFile(content);
526
+ await handle.sync();
527
+ await handle.close();
528
+ handle = void 0;
529
+ try {
530
+ await fsp2.link(temp, target);
531
+ } catch (err) {
532
+ if (err.code !== "EEXIST") throw err;
533
+ const existing = await fsp2.readFile(target);
534
+ if (sha256(existing) !== hash) throw new Error(`Corrupt CAS object collision: ${hash}`);
535
+ }
536
+ } finally {
537
+ await handle?.close().catch(() => void 0);
538
+ await fsp2.unlink(temp).catch(() => void 0);
539
+ }
540
+ }
541
+ async putManifest(hash, encoded) {
542
+ const target = this.manifestPath(hash);
543
+ const existing = await fsp2.readFile(target).catch(() => null);
544
+ if (existing) {
545
+ if (sha256(existing) !== hash) throw new Error(`Corrupt checkpoint manifest collision: ${hash}`);
546
+ return;
547
+ }
548
+ await atomicWrite(target, encoded, { mode: 384 });
549
+ }
550
+ async readBlob(hash) {
551
+ const content = await fsp2.readFile(this.objectPath(hash));
552
+ if (sha256(content) !== hash) throw new Error(`CAS blob integrity check failed: ${hash}`);
553
+ return content;
554
+ }
555
+ async loadManifest(hash) {
556
+ const raw = await fsp2.readFile(this.manifestPath(hash));
557
+ if (sha256(raw) !== hash) throw new Error(`Checkpoint manifest integrity check failed: ${hash}`);
558
+ const parsed = JSON.parse(raw.toString("utf8"));
559
+ if (parsed.version !== 1 || typeof parsed.baseHead !== "string" || parsed.coverage !== "git-head-plus-dirty" || !Array.isArray(parsed.entries) || !Array.isArray(parsed.unresolved)) {
560
+ throw new Error(`Invalid workspace checkpoint manifest: ${hash}`);
561
+ }
562
+ const entriesValid = parsed.entries.every((entry) => {
563
+ if (!entry || typeof entry !== "object") return false;
564
+ const candidate = entry;
565
+ if (typeof candidate.path !== "string" || !normalizeRelative(candidate.path)) return false;
566
+ if (candidate.state === "absent") return true;
567
+ if (candidate.state === "symlink") return typeof candidate.linkTarget === "string";
568
+ return candidate.state === "file" && typeof candidate.blobHash === "string" && HASH_RE.test(candidate.blobHash) && typeof candidate.mode === "number" && Number.isInteger(candidate.mode) && candidate.mode >= 0 && candidate.mode <= 511;
569
+ });
570
+ const unresolvedValid = parsed.unresolved.every(
571
+ (entry) => entry !== null && typeof entry === "object" && typeof entry.path === "string" && typeof entry.reason === "string"
572
+ );
573
+ if (!entriesValid || !unresolvedValid) {
574
+ throw new Error(`Invalid workspace checkpoint manifest entries: ${hash}`);
575
+ }
576
+ return parsed;
577
+ }
578
+ async safeOutputPath(target, realTarget, relative3) {
579
+ const normalized = normalizeRelative(relative3);
580
+ if (!normalized) throw new Error("invalid relative path");
581
+ const output = path.resolve(target, ...normalized.split("/"));
582
+ if (!isInside(target, output)) throw new Error("path escapes checkpoint target");
583
+ let probe = output;
584
+ for (; ; ) {
585
+ try {
586
+ const real = await fsp2.realpath(probe);
587
+ if (!isInside(realTarget, real)) throw new Error("path resolves through a symlink outside checkpoint target");
588
+ return output;
589
+ } catch (err) {
590
+ if (err.code !== "ENOENT") throw err;
591
+ const parent = path.dirname(probe);
592
+ if (parent === probe) throw err;
593
+ probe = parent;
594
+ }
595
+ }
596
+ }
597
+ };
598
+ function defaultRunGit(args, cwd) {
599
+ return new Promise((resolve4) => {
600
+ const stdoutChunks = [];
601
+ const stderrChunks = [];
602
+ let stdoutBytes = 0;
603
+ let stderrBytes = 0;
604
+ let stdoutTruncated = false;
605
+ let stderrTruncated = false;
606
+ const child = spawn("git", args, {
607
+ cwd,
608
+ env: buildChildEnv(),
609
+ stdio: ["ignore", "pipe", "pipe"],
610
+ signal: AbortSignal.timeout(3e4),
611
+ windowsHide: true
612
+ });
613
+ child.stdout?.on("data", (chunk) => {
614
+ const remaining = MAX_GIT_OUTPUT - stdoutBytes;
615
+ if (remaining <= 0) {
616
+ stdoutTruncated = true;
617
+ return;
618
+ }
619
+ const kept = chunk.subarray(0, remaining);
620
+ stdoutChunks.push(kept);
621
+ stdoutBytes += kept.length;
622
+ if (kept.length < chunk.length) stdoutTruncated = true;
623
+ });
624
+ child.stderr?.on("data", (chunk) => {
625
+ const remaining = MAX_GIT_OUTPUT - stderrBytes;
626
+ if (remaining <= 0) {
627
+ stderrTruncated = true;
628
+ return;
629
+ }
630
+ const kept = chunk.subarray(0, remaining);
631
+ stderrChunks.push(kept);
632
+ stderrBytes += kept.length;
633
+ if (kept.length < chunk.length) stderrTruncated = true;
634
+ });
635
+ const result = (code, extraStderr) => ({
636
+ code,
637
+ stdout: Buffer.concat(stdoutChunks).toString("utf8"),
638
+ stderr: `${Buffer.concat(stderrChunks).toString("utf8")}${extraStderr ?? ""}`,
639
+ stdoutTruncated,
640
+ stderrTruncated
641
+ });
642
+ child.on("error", (err) => resolve4(result(1, err.message)));
643
+ child.on("close", (code) => resolve4(result(code ?? 1)));
644
+ });
645
+ }
646
+
647
+ // src/storage/session-id.ts
648
+ function sanitizeModel(model) {
649
+ return model.replace(/[^a-zA-Z0-9_-]/g, "-").replace(/-+/g, "-").replace(/^-|-$/g, "").slice(0, 40);
650
+ }
651
+ function generateSessionId(startedAt, _model) {
652
+ const date = startedAt.slice(0, 10);
653
+ const seedTime = Number.isNaN(Date.parse(startedAt)) ? Date.now() : Date.parse(startedAt);
654
+ return `${date}/sess_${ulid(seedTime)}`;
655
+ }
656
+
657
+ // src/storage/session-id-resolver.ts
658
+ import * as path2 from "node:path";
659
+ function resolveSessionId(query, candidateIds) {
660
+ const normalized = query.trim();
661
+ if (!normalized) return { status: "missing", query: normalized };
662
+ const uniqueIds = [...new Set(candidateIds)];
663
+ if (uniqueIds.includes(normalized)) return { status: "resolved", id: normalized };
664
+ const leaf = (id) => path2.posix.basename(id.replace(/\\/g, "/"));
665
+ const exactLeafMatches = uniqueIds.filter((id) => leaf(id) === normalized);
666
+ if (exactLeafMatches.length === 1) {
667
+ return { status: "resolved", id: exactLeafMatches[0] };
668
+ }
669
+ if (exactLeafMatches.length > 1) {
670
+ return {
671
+ status: "ambiguous",
672
+ query: normalized,
673
+ candidates: exactLeafMatches.sort()
674
+ };
675
+ }
676
+ const prefixMatches = uniqueIds.filter(
677
+ (id) => id.startsWith(normalized) || leaf(id).startsWith(normalized)
678
+ );
679
+ if (prefixMatches.length === 1) return { status: "resolved", id: prefixMatches[0] };
680
+ if (prefixMatches.length > 1) {
681
+ return { status: "ambiguous", query: normalized, candidates: prefixMatches.sort() };
682
+ }
683
+ return { status: "missing", query: normalized };
684
+ }
685
+ function sessionIdResolutionError(resolution) {
686
+ if (resolution.status === "ambiguous") {
687
+ return new Error(
688
+ `Ambiguous session id "${resolution.query}". Matches: ${resolution.candidates.join(", ")}`
689
+ );
690
+ }
691
+ return new Error(`Session not found: ${resolution.query}`);
692
+ }
693
+
694
+ // src/storage/session-workspace-checkpoints.ts
695
+ function requiredCheckpointCas(checkpointCas, operation) {
696
+ if (!checkpointCas) {
697
+ throw new Error(`Workspace checkpoint ${operation} requires a projectRoot-aware session store`);
698
+ }
699
+ return checkpointCas;
700
+ }
701
+ function captureCheckpoint(checkpointCas, sessionId, promptIndex) {
702
+ return requiredCheckpointCas(checkpointCas, "capture").capture(sessionId, promptIndex);
703
+ }
704
+ function materializeCheckpoint(checkpointCas, checkpoint, targetRoot) {
705
+ return requiredCheckpointCas(checkpointCas, "materialization").materialize(
706
+ checkpoint,
707
+ targetRoot
708
+ );
709
+ }
710
+
711
+ // src/storage/session-helpers.ts
712
+ function consumeControlString(value, start) {
713
+ let index = start;
714
+ while (index < value.length) {
715
+ const code = value.charCodeAt(index);
716
+ if (code === 7 || code === 156) return index;
717
+ if (code === 27 && value.charCodeAt(index + 1) === 92) return index + 1;
718
+ index++;
719
+ }
720
+ return value.length;
721
+ }
722
+ function consumeCsi(value, start) {
723
+ let index = start;
724
+ while (index < value.length && value.charCodeAt(index) < 64) index++;
725
+ return index;
726
+ }
727
+ function stripTerminalControls(value) {
728
+ let out = "";
729
+ for (let index = 0; index < value.length; index++) {
730
+ const code = value.charCodeAt(index);
731
+ if (code === 27) {
732
+ const family = value.charCodeAt(index + 1);
733
+ if (family === 91) index = consumeCsi(value, index + 2);
734
+ else if ([93, 80, 88, 94, 95].includes(family)) {
735
+ index = consumeControlString(value, index + 2);
736
+ } else index++;
737
+ continue;
738
+ }
739
+ if (code === 155) {
740
+ index = consumeCsi(value, index + 1);
741
+ continue;
742
+ }
743
+ if ([144, 152, 157, 158, 159].includes(code)) {
744
+ index = consumeControlString(value, index + 1);
745
+ continue;
746
+ }
747
+ if (code < 32 && code !== 9 && code !== 10 && code !== 13 || code >= 127 && code <= 159) {
748
+ continue;
749
+ }
750
+ out += value[index];
751
+ }
752
+ return out;
753
+ }
754
+ function sessionContentText(content) {
755
+ const text = typeof content === "string" ? content : content.filter((b) => b.type === "text").map((b) => b.text).join(" ");
756
+ return stripTerminalControls(text).trim().replace(/\s+/g, " ");
757
+ }
758
+ function sessionContentPreview(content, maxLength = 160) {
759
+ const text = sessionContentText(content) || "(non-text input)";
760
+ return text.length > maxLength ? `${text.slice(0, Math.max(0, maxLength - 1))}\u2026` : text;
761
+ }
762
+ function userInputTitle(content) {
763
+ return sessionContentPreview(content, 60);
764
+ }
765
+
766
+ // src/storage/session-read-scrubber.ts
767
+ function scrubPersistedSessionEvent(event, scrubber) {
768
+ return scrubber.scrubObject(event);
769
+ }
770
+ function scrubPersistedSessionData(data, scrubber) {
771
+ return scrubber.scrubObject(data);
772
+ }
773
+ function scrubPersistedSessionSummary(summary, scrubber) {
774
+ const scrubbed = scrubber.scrubObject(summary);
775
+ const { name: _name, lastUserMessage: _lastUserMessage, ...rest } = scrubbed;
776
+ const title = sessionContentText(scrubbed.title) || "(empty session)";
777
+ const name = scrubbed.name === void 0 ? "" : sessionContentText(scrubbed.name);
778
+ const lastUserMessage = scrubbed.lastUserMessage === void 0 ? "" : sessionContentText(scrubbed.lastUserMessage);
779
+ return {
780
+ ...rest,
781
+ title,
782
+ ...name ? { name } : {},
783
+ ...lastUserMessage ? { lastUserMessage } : {}
784
+ };
785
+ }
786
+
787
+ // src/storage/session-recovery.ts
788
+ import { createReadStream } from "node:fs";
789
+ import * as fs from "node:fs/promises";
790
+ import * as path4 from "node:path";
791
+ import { createInterface } from "node:readline";
792
+
793
+ // src/storage/session-store/directory-session-files.ts
794
+ import * as fsp3 from "node:fs/promises";
795
+ import * as path3 from "node:path";
796
+
797
+ // src/storage/session-store/directory-scan.ts
798
+ function shouldSkipSessionDirectoryEntry(name) {
799
+ return name.startsWith(".") && name !== ".wrongstack" || name === "shared" || name === "subagents" || name === "attachments";
800
+ }
801
+
802
+ // src/storage/session-store/directory-session-files.ts
803
+ function sessionIdForFile(prefix, name) {
804
+ const base = name.replace(/\.jsonl$/, "");
805
+ return prefix ? `${prefix}/${base}` : base;
806
+ }
807
+ function childPrefixFor(entry, prefix, depth) {
808
+ return depth === 0 ? entry.name : `${prefix}/${entry.name}`;
809
+ }
810
+ async function collectSessionFiles(dir, prefix = "", depth = 0) {
811
+ let entries;
812
+ try {
813
+ entries = await fsp3.readdir(dir, { withFileTypes: true });
814
+ } catch {
815
+ return [];
816
+ }
817
+ const dirEntries = [];
818
+ const files = [];
819
+ for (const entry of entries) {
820
+ if (shouldSkipSessionDirectoryEntry(entry.name)) continue;
821
+ if (entry.isDirectory()) {
822
+ dirEntries.push(entry);
823
+ } else if (entry.isFile() && isSessionTranscriptFileName(entry.name)) {
824
+ files.push({ id: sessionIdForFile(prefix, entry.name), filePath: path3.join(dir, entry.name) });
825
+ }
826
+ }
827
+ const childFileArrays = await Promise.all(
828
+ dirEntries.map(
829
+ (entry) => collectSessionFiles(path3.join(dir, entry.name), childPrefixFor(entry, prefix, depth), depth + 1)
830
+ )
831
+ );
832
+ return [...childFileArrays.flat(), ...files];
833
+ }
834
+ async function collectSessionIds(dir, prefix = "", depth = 0) {
835
+ let entries;
836
+ try {
837
+ entries = await fsp3.readdir(dir, { withFileTypes: true });
838
+ } catch {
839
+ return [];
840
+ }
841
+ const dirEntries = [];
842
+ const fileIds = [];
843
+ for (const entry of entries) {
844
+ if (shouldSkipSessionDirectoryEntry(entry.name)) continue;
845
+ if (entry.isDirectory()) {
846
+ dirEntries.push(entry);
847
+ } else if (entry.isFile() && isSessionTranscriptFileName(entry.name)) {
848
+ fileIds.push(sessionIdForFile(prefix, entry.name));
849
+ }
850
+ }
851
+ const childIdArrays = await Promise.all(
852
+ dirEntries.map(
853
+ (entry) => collectSessionIds(path3.join(dir, entry.name), childPrefixFor(entry, prefix, depth), depth + 1)
854
+ )
855
+ );
856
+ return [...childIdArrays.flat(), ...fileIds];
857
+ }
858
+
859
+ // src/storage/session-recovery.ts
860
+ function extractInterruptedTools(plan) {
861
+ const tools = [];
862
+ const openCalls = /* @__PURE__ */ new Map();
863
+ let anonymousSeq = 0;
864
+ for (const ev of plan.pendingEvents) {
865
+ if ((ev.type === "tool_use" || ev.type === "tool_call_start") && typeof ev.name === "string") {
866
+ const toolName = ev.name;
867
+ const rawId = ev.id;
868
+ const callId = rawId ?? toolName;
869
+ openCalls.set(callId, {
870
+ id: rawId,
871
+ name: toolName,
872
+ args: ev.input ?? ev.args,
873
+ ts: ev.ts
874
+ });
875
+ } else if (ev.type === "tool_result" || ev.type === "tool_call_end") {
876
+ const callId = ev.id ?? ev.toolUseId ?? ev.name;
877
+ if (callId) openCalls.delete(callId);
878
+ } else if (ev.type === "llm_response" && Array.isArray(ev.content)) {
879
+ for (const block of ev.content) {
880
+ if (block && block.type === "tool_use" && typeof block.name === "string") {
881
+ const rawId = block.id;
882
+ const callId = rawId ?? `${block.name}#${++anonymousSeq}`;
883
+ openCalls.set(callId, {
884
+ id: rawId,
885
+ name: block.name,
886
+ args: block.input,
887
+ ts: ev.ts
888
+ });
889
+ }
890
+ }
891
+ } else if (ev.type === "message_appended" && ev.message) {
892
+ const msg = ev.message;
893
+ if (Array.isArray(msg.content)) {
894
+ for (const block of msg.content) {
895
+ if (block && block.type === "tool_use" && typeof block.name === "string") {
896
+ const rawId = block.id;
897
+ const callId = rawId ?? `${block.name}#${++anonymousSeq}`;
898
+ openCalls.set(callId, {
899
+ id: rawId,
900
+ name: block.name,
901
+ args: block.input,
902
+ ts: ev.ts
903
+ });
904
+ } else if (block && block.type === "tool_result") {
905
+ const callId = block.tool_use_id ?? block.id;
906
+ if (callId) openCalls.delete(callId);
907
+ }
908
+ }
909
+ }
910
+ }
911
+ }
912
+ for (const call of openCalls.values()) {
913
+ let argsSummary;
914
+ if (call.args && typeof call.args === "object") {
915
+ try {
916
+ const str = JSON.stringify(call.args);
917
+ argsSummary = str.length > 80 ? `${str.slice(0, 77)}...` : str;
918
+ } catch {
919
+ }
920
+ }
921
+ tools.push({
922
+ id: call.id,
923
+ name: call.name,
924
+ argsSummary,
925
+ ts: call.ts
926
+ });
927
+ }
928
+ return tools;
929
+ }
930
+ var SessionRecovery = class _SessionRecovery {
931
+ constructor(dir) {
932
+ this.dir = dir;
933
+ }
934
+ dir;
935
+ static MAX_PENDING_EVENTS = 1e4;
936
+ static MAX_PENDING_BYTES = 16 * 1024 * 1024;
937
+ /**
938
+ * Build a recovery plan from ALREADY-LOADED events without touching disk.
939
+ * executeResumeSession uses this because load() has already paid for the
940
+ * transcript read; recover()'s file scan would duplicate it.
941
+ */
942
+ static buildRecoveryPlan(events, sessionId) {
943
+ const pendingEvents = [];
944
+ const pendingSizes = [];
945
+ let pendingBytes = 0;
946
+ let lastCheckpoint = null;
947
+ let latestBoundary = null;
948
+ for (const event of events) {
949
+ if (!event || typeof event !== "object" || typeof event.type !== "string") continue;
950
+ if (event.type === "checkpoint") {
951
+ lastCheckpoint = event;
952
+ pendingEvents.length = 0;
953
+ pendingSizes.length = 0;
954
+ pendingBytes = 0;
955
+ continue;
956
+ }
957
+ if (isLifecycleBoundary(event)) latestBoundary = event;
958
+ const bytes = Buffer.byteLength(JSON.stringify(event), "utf8");
959
+ if (bytes > _SessionRecovery.MAX_PENDING_BYTES) continue;
960
+ while (pendingEvents.length >= _SessionRecovery.MAX_PENDING_EVENTS || pendingBytes + bytes > _SessionRecovery.MAX_PENDING_BYTES) {
961
+ pendingBytes -= pendingSizes.shift();
962
+ pendingEvents.shift();
963
+ }
964
+ pendingEvents.push(event);
965
+ pendingSizes.push(bytes);
966
+ pendingBytes += bytes;
967
+ }
968
+ const inFlightStart = latestBoundary?.type === "in_flight_start" ? latestBoundary : null;
969
+ return {
970
+ sessionId,
971
+ stale: inFlightStart !== null,
972
+ lastCheckpoint,
973
+ pendingEvents,
974
+ inFlightStart,
975
+ context: inFlightStart?.context ?? null
976
+ };
977
+ }
978
+ /**
979
+ * Scan a session log and return a `StaleSession` if and only if the newest
980
+ * lifecycle boundary is an `in_flight_start` without a later
981
+ * `in_flight_end`/`session_end`. Ordinary provider/tool events after the
982
+ * marker do not make the session clean. Returns `null` when:
983
+ * - the log does not exist;
984
+ * - the log is empty;
985
+ * - the latest lifecycle boundary is `in_flight_end` or `session_end`;
986
+ * - there is no lifecycle boundary (legacy/pre-marker log).
987
+ *
988
+ * The reverse scanner is chunked and line-aware. It can cross arbitrarily
989
+ * large JSONL records (for example a large tool result) without imposing a
990
+ * fixed tail-size correctness limit. Clean logs normally return after the
991
+ * first chunk; stale logs continue counting lines so `eventCount` remains
992
+ * the documented total rather than a tail-only approximation.
993
+ */
994
+ async resolveId(query) {
995
+ const resolution = resolveSessionId(query, await collectSessionIds(this.dir));
996
+ if (resolution.status === "resolved") return resolution.id;
997
+ if (resolution.status === "missing") return resolution.query;
998
+ throw sessionIdResolutionError(resolution);
999
+ }
1000
+ async detectStale(sessionId) {
1001
+ const canonicalId = await this.resolveId(sessionId);
1002
+ return this.detectStaleExact(canonicalId);
1003
+ }
1004
+ async detectStaleExact(sessionId) {
1005
+ const fp = this.filePath(sessionId);
1006
+ let stat11;
1007
+ try {
1008
+ stat11 = await fs.stat(fp);
1009
+ } catch {
1010
+ return null;
1011
+ }
1012
+ if (stat11.size === 0) return null;
1013
+ try {
1014
+ const scan = await scanLatestLifecycleBoundary(fp, stat11.size);
1015
+ if (scan?.boundary.type !== "in_flight_start") return null;
1016
+ return {
1017
+ sessionId,
1018
+ path: fp,
1019
+ lastEventTs: scan.boundary.ts,
1020
+ context: scan.boundary.context,
1021
+ eventCount: scan.eventCount
1022
+ };
1023
+ } catch {
1024
+ return null;
1025
+ }
1026
+ }
1027
+ /**
1028
+ * Generate a recovery plan for a session. The plan describes
1029
+ * the persisted tail after the last checkpoint, plus the dangling in-flight
1030
+ * marker if present. SessionStore.resume() independently reconstructs state
1031
+ * from the journal and does not re-run these events as commands.
1032
+ *
1033
+ * Returns a non-null plan for ANY session that has at least
1034
+ * one event after a checkpoint (or, for legacy sessions, at
1035
+ * least one event). Pure read; no mutation.
1036
+ */
1037
+ async recover(sessionId) {
1038
+ const canonicalId = await this.resolveId(sessionId);
1039
+ const fp = this.filePath(canonicalId);
1040
+ const pendingEvents = [];
1041
+ const pendingSizes = [];
1042
+ let pendingBytes = 0;
1043
+ let lastCheckpoint = null;
1044
+ let latestBoundary = null;
1045
+ let sawEvent = false;
1046
+ const stream = createReadStream(fp, { encoding: "utf8" });
1047
+ const lines = createInterface({ input: stream, crlfDelay: Infinity });
1048
+ try {
1049
+ for await (const line of lines) {
1050
+ if (!line.trim()) continue;
1051
+ let event;
1052
+ try {
1053
+ event = JSON.parse(line);
1054
+ } catch {
1055
+ continue;
1056
+ }
1057
+ if (!event || typeof event !== "object" || typeof event.type !== "string") continue;
1058
+ sawEvent = true;
1059
+ if (event.type === "checkpoint") {
1060
+ lastCheckpoint = event;
1061
+ pendingEvents.length = 0;
1062
+ pendingSizes.length = 0;
1063
+ pendingBytes = 0;
1064
+ } else {
1065
+ const bytes = Buffer.byteLength(line, "utf8");
1066
+ if (bytes <= _SessionRecovery.MAX_PENDING_BYTES) {
1067
+ while (pendingEvents.length >= _SessionRecovery.MAX_PENDING_EVENTS || pendingBytes + bytes > _SessionRecovery.MAX_PENDING_BYTES) {
1068
+ pendingEvents.shift();
1069
+ pendingBytes = Math.max(0, pendingBytes - pendingSizes.shift());
1070
+ }
1071
+ pendingEvents.push(event);
1072
+ pendingSizes.push(bytes);
1073
+ pendingBytes += bytes;
1074
+ }
1075
+ }
1076
+ if (isLifecycleBoundary(event)) latestBoundary = event;
1077
+ }
1078
+ } catch {
1079
+ return null;
1080
+ } finally {
1081
+ lines.close();
1082
+ stream.close();
1083
+ }
1084
+ if (!sawEvent) return null;
1085
+ const inFlightStart = latestBoundary?.type === "in_flight_start" ? latestBoundary : null;
1086
+ const context = inFlightStart && inFlightStart.type === "in_flight_start" ? inFlightStart.context : null;
1087
+ return {
1088
+ sessionId: canonicalId,
1089
+ stale: inFlightStart !== null,
1090
+ lastCheckpoint,
1091
+ pendingEvents,
1092
+ inFlightStart,
1093
+ context
1094
+ };
1095
+ }
1096
+ /**
1097
+ * List every stale session in a directory. Returns an array
1098
+ * (possibly empty) sorted by `lastEventTs` descending — most
1099
+ * recent crash first.
1100
+ */
1101
+ async listResumable() {
1102
+ const out = [];
1103
+ const collect = async (dir, prefix, depth) => {
1104
+ let entries;
1105
+ try {
1106
+ entries = await fs.readdir(dir, { withFileTypes: true });
1107
+ } catch {
1108
+ return;
1109
+ }
1110
+ for (const entry of entries) {
1111
+ if (entry.name.startsWith(".")) continue;
1112
+ if (entry.name === "shared" || entry.name === "subagents" || entry.name === "attachments")
1113
+ continue;
1114
+ if (entry.isDirectory()) {
1115
+ if (depth === 0) {
1116
+ await collect(path4.join(dir, entry.name), entry.name, depth + 1);
1117
+ }
1118
+ continue;
1119
+ }
1120
+ if (!entry.isFile() || !isSessionTranscriptFileName(entry.name)) continue;
1121
+ const base = entry.name.slice(0, -".jsonl".length);
1122
+ const sessionId = prefix ? `${prefix}/${base}` : base;
1123
+ const stale = await this.detectStaleExact(sessionId);
1124
+ if (stale) out.push(stale);
1125
+ }
1126
+ };
1127
+ await collect(this.dir, "", 0);
1128
+ return out.sort((a, b) => b.lastEventTs.localeCompare(a.lastEventTs));
1129
+ }
1130
+ // ── Internals ──────────────────────────────────────────────────────────
1131
+ filePath(sessionId) {
1132
+ return sessionScopedPath(this.dir, sessionId, ".jsonl");
1133
+ }
1134
+ };
1135
+ function isLifecycleBoundary(event) {
1136
+ return event.type === "in_flight_start" || event.type === "in_flight_end" || event.type === "session_end";
1137
+ }
1138
+ function parseLifecycleBoundary(line) {
1139
+ try {
1140
+ const parsed = JSON.parse(line.toString("utf8"));
1141
+ return isLifecycleBoundary(parsed) ? parsed : null;
1142
+ } catch {
1143
+ return null;
1144
+ }
1145
+ }
1146
+ function hasNonWhitespace(line) {
1147
+ for (const byte of line) {
1148
+ if (byte !== 9 && byte !== 10 && byte !== 13 && byte !== 32) return true;
1149
+ }
1150
+ return false;
1151
+ }
1152
+ async function scanLatestLifecycleBoundary(filePath, size) {
1153
+ const CHUNK_SIZE2 = 64 * 1024;
1154
+ const handle = await fs.open(filePath, "r");
1155
+ let position = size;
1156
+ let laterLineFragment = Buffer.alloc(0);
1157
+ let latestBoundary = null;
1158
+ let eventCount = 0;
1159
+ const observeLine = (line) => {
1160
+ if (!hasNonWhitespace(line)) return null;
1161
+ eventCount++;
1162
+ return parseLifecycleBoundary(line);
1163
+ };
1164
+ try {
1165
+ while (position > 0) {
1166
+ const length = Math.min(CHUNK_SIZE2, position);
1167
+ position -= length;
1168
+ const chunk = Buffer.allocUnsafe(length);
1169
+ const { bytesRead } = await handle.read(chunk, 0, length, position);
1170
+ const data = Buffer.concat([chunk.subarray(0, bytesRead), laterLineFragment]);
1171
+ let lineEnd = data.length;
1172
+ for (let i = data.length - 1; i >= 0; i--) {
1173
+ if (data[i] !== 10) continue;
1174
+ const boundary2 = observeLine(data.subarray(i + 1, lineEnd));
1175
+ if (!latestBoundary && boundary2) latestBoundary = boundary2;
1176
+ lineEnd = i;
1177
+ }
1178
+ laterLineFragment = data.subarray(0, lineEnd);
1179
+ if (latestBoundary && latestBoundary.type !== "in_flight_start") {
1180
+ return { boundary: latestBoundary, eventCount };
1181
+ }
1182
+ }
1183
+ const boundary = observeLine(laterLineFragment);
1184
+ if (!latestBoundary && boundary) latestBoundary = boundary;
1185
+ return latestBoundary ? { boundary: latestBoundary, eventCount } : null;
1186
+ } finally {
1187
+ await handle.close();
1188
+ }
1189
+ }
1190
+
1191
+ // src/storage/session-store.ts
1192
+ import { randomUUID as randomUUID2 } from "node:crypto";
1193
+ import * as fsp19 from "node:fs/promises";
1194
+ import * as path11 from "node:path";
1195
+
1196
+ // src/storage/file-session-writer.ts
1197
+ import * as fsp7 from "node:fs/promises";
1198
+ import * as path5 from "node:path";
1199
+
1200
+ // src/storage/session-summary-tracker.ts
1201
+ import { createReadStream as createReadStream2 } from "node:fs";
1202
+ import * as fsp4 from "node:fs/promises";
1203
+ import { createInterface as createInterface2 } from "node:readline";
1204
+ var SessionSummaryTracker = class {
1205
+ id;
1206
+ startedAt;
1207
+ meta;
1208
+ resumed;
1209
+ resolveNameCb;
1210
+ summary;
1211
+ tokenIn = 0;
1212
+ tokenOut = 0;
1213
+ baseTokenTotal = 0;
1214
+ iterationCount = 0;
1215
+ toolCallCount = 0;
1216
+ toolErrorCount = 0;
1217
+ toolBreakdown = {};
1218
+ fileChangeCount = 0;
1219
+ compactionCount = 0;
1220
+ messageCount = 0;
1221
+ lastUserMessage;
1222
+ lastActivityAt;
1223
+ outcome = void 0;
1224
+ openToolUses = /* @__PURE__ */ new Set();
1225
+ constructor(opts) {
1226
+ this.id = opts.id;
1227
+ this.startedAt = opts.startedAt;
1228
+ this.meta = opts.meta;
1229
+ this.resumed = opts.resumed ?? false;
1230
+ this.resolveNameCb = opts.resolveName;
1231
+ this.summary = opts.initialSummary ? { ...opts.initialSummary, id: opts.id } : {
1232
+ id: opts.id,
1233
+ title: "(empty session)",
1234
+ startedAt: opts.startedAt,
1235
+ model: opts.meta.model ?? "unknown",
1236
+ provider: opts.meta.provider ?? "unknown",
1237
+ tokenTotal: 0
1238
+ };
1239
+ this.baseTokenTotal = this.summary.tokenTotal;
1240
+ this.iterationCount = this.summary.iterationCount ?? 0;
1241
+ this.toolCallCount = this.summary.toolCallCount ?? 0;
1242
+ this.toolErrorCount = this.summary.toolErrorCount ?? 0;
1243
+ this.toolBreakdown = { ...this.summary.toolBreakdown ?? {} };
1244
+ this.fileChangeCount = this.summary.fileChangeCount ?? 0;
1245
+ this.compactionCount = this.summary.compactionCount ?? 0;
1246
+ this.messageCount = this.summary.messageCount ?? 0;
1247
+ this.lastUserMessage = this.summary.lastUserMessage;
1248
+ this.lastActivityAt = this.summary.lastActivityAt ?? this.summary.endedAt ?? opts.startedAt;
1249
+ this.outcome = this.resumed ? void 0 : this.summary.outcome;
1250
+ }
1251
+ get currentSummary() {
1252
+ return this.summary;
1253
+ }
1254
+ /**
1255
+ * Non-destructive materialization of every live counter into a summary
1256
+ * snapshot. Unlike finalize(), it stamps no endedAt, applies no final
1257
+ * outcome, and resolves no name — mid-session metadata checkpoints use it
1258
+ * so a killed process leaves accurate listing metadata behind without
1259
+ * pretending the session ended cleanly.
1260
+ */
1261
+ snapshot() {
1262
+ const { lastUserMessage: _lastUserMessage, ...rest } = this.summary;
1263
+ return {
1264
+ ...rest,
1265
+ messageCount: this.messageCount,
1266
+ ...this.lastUserMessage !== void 0 ? { lastUserMessage: this.lastUserMessage } : {},
1267
+ iterationCount: this.iterationCount,
1268
+ toolCallCount: this.toolCallCount,
1269
+ toolErrorCount: this.toolErrorCount,
1270
+ fileChangeCount: this.fileChangeCount,
1271
+ compactionCount: this.compactionCount > 0 ? this.compactionCount : void 0,
1272
+ toolBreakdown: { ...this.toolBreakdown },
1273
+ lastActivityAt: this.lastActivityAt,
1274
+ ...this.outcome !== void 0 ? { outcome: this.outcome } : {}
1275
+ };
1276
+ }
1277
+ get pendingToolUses() {
1278
+ return Array.from(this.openToolUses);
1279
+ }
1280
+ observe(event) {
1281
+ const eventActivityMs = Date.parse(event.ts);
1282
+ const lastActivityMs = Date.parse(this.lastActivityAt);
1283
+ if (Number.isFinite(eventActivityMs) && (!Number.isFinite(lastActivityMs) || eventActivityMs > lastActivityMs)) {
1284
+ this.lastActivityAt = event.ts;
1285
+ }
1286
+ if (event.type === "llm_response") {
1287
+ for (const block of event.content) {
1288
+ if (block.type === "tool_use") this.openToolUses.add(block.id);
1289
+ }
1290
+ }
1291
+ if (event.type === "tool_use") {
1292
+ this.openToolUses.add(event.id);
1293
+ } else if (event.type === "tool_call_start") {
1294
+ this.toolCallCount++;
1295
+ this.toolBreakdown[event.name] = (this.toolBreakdown[event.name] ?? 0) + 1;
1296
+ } else if (event.type === "tool_result") {
1297
+ this.openToolUses.delete(event.id);
1298
+ if (event.isError) {
1299
+ this.toolErrorCount++;
1300
+ this.outcome = "error";
1301
+ }
1302
+ } else if (event.type === "file_snapshot") {
1303
+ this.fileChangeCount += event.files.length;
1304
+ } else if (event.type === "compaction") {
1305
+ this.compactionCount++;
1306
+ }
1307
+ if (event.type === "error" || event.type === "provider_error") {
1308
+ this.outcome = "error";
1309
+ }
1310
+ if (event.type === "user_input") {
1311
+ if (this.summary.title === "(empty session)") {
1312
+ this.summary = { ...this.summary, title: userInputTitle(event.content) };
1313
+ }
1314
+ this.lastUserMessage = sessionContentPreview(event.content);
1315
+ this.messageCount++;
1316
+ } else if (event.type === "llm_response") {
1317
+ this.messageCount++;
1318
+ this.tokenIn += event.usage.input;
1319
+ this.tokenOut += event.usage.output;
1320
+ this.summary = {
1321
+ ...this.summary,
1322
+ tokenTotal: this.baseTokenTotal + this.tokenIn + this.tokenOut
1323
+ };
1324
+ } else if (event.type === "session_end") {
1325
+ const total = event.usage.input + event.usage.output;
1326
+ if (total > 0) {
1327
+ this.summary = {
1328
+ ...this.summary,
1329
+ tokenTotal: Math.max(this.summary.tokenTotal, total)
1330
+ };
1331
+ }
1332
+ } else if (event.type === "in_flight_start") {
1333
+ this.iterationCount++;
1334
+ }
1335
+ }
1336
+ async recomputeFromDisk(filePath) {
1337
+ if (!filePath) return;
1338
+ const accessible = await fsp4.access(filePath).then(() => true).catch(() => false);
1339
+ if (!accessible) return;
1340
+ this.iterationCount = 0;
1341
+ this.toolCallCount = 0;
1342
+ this.toolErrorCount = 0;
1343
+ this.toolBreakdown = {};
1344
+ this.fileChangeCount = 0;
1345
+ this.compactionCount = 0;
1346
+ this.messageCount = 0;
1347
+ this.lastUserMessage = void 0;
1348
+ this.lastActivityAt = this.startedAt;
1349
+ this.outcome = void 0;
1350
+ this.openToolUses = /* @__PURE__ */ new Set();
1351
+ this.tokenIn = 0;
1352
+ this.tokenOut = 0;
1353
+ this.baseTokenTotal = 0;
1354
+ const { lastUserMessage: _lastUserMessage, ...summaryWithoutPreview } = this.summary;
1355
+ this.summary = {
1356
+ ...summaryWithoutPreview,
1357
+ title: "(empty session)",
1358
+ tokenTotal: 0,
1359
+ messageCount: 0,
1360
+ lastActivityAt: this.startedAt
1361
+ };
1362
+ const input = createReadStream2(filePath, { encoding: "utf8" });
1363
+ const lines = createInterface2({ input, crlfDelay: Number.POSITIVE_INFINITY });
1364
+ try {
1365
+ for await (const line of lines) {
1366
+ const trimmed = line.trim();
1367
+ if (trimmed.length === 0) continue;
1368
+ try {
1369
+ this.observe(JSON.parse(trimmed));
1370
+ } catch {
1371
+ }
1372
+ }
1373
+ } finally {
1374
+ lines.close();
1375
+ input.destroy();
1376
+ }
1377
+ }
1378
+ async finalize() {
1379
+ const endedAt = (/* @__PURE__ */ new Date()).toISOString();
1380
+ const observedActivityMs = Date.parse(this.lastActivityAt);
1381
+ const endedAtMs = Date.parse(endedAt);
1382
+ const finalActivityAt = Number.isFinite(observedActivityMs) && observedActivityMs > endedAtMs ? this.lastActivityAt : endedAt;
1383
+ const previousName = this.summary.name;
1384
+ const {
1385
+ lastUserMessage: _lastUserMessage,
1386
+ name: _name,
1387
+ ...summaryWithoutDisplay
1388
+ } = this.summary;
1389
+ this.summary = {
1390
+ ...summaryWithoutDisplay,
1391
+ ...previousName !== void 0 ? { name: previousName } : {},
1392
+ endedAt,
1393
+ lastActivityAt: finalActivityAt,
1394
+ messageCount: this.messageCount,
1395
+ ...this.lastUserMessage !== void 0 ? { lastUserMessage: this.lastUserMessage } : {},
1396
+ iterationCount: this.iterationCount,
1397
+ toolCallCount: this.toolCallCount,
1398
+ toolErrorCount: this.toolErrorCount,
1399
+ fileChangeCount: this.fileChangeCount,
1400
+ compactionCount: this.compactionCount > 0 ? this.compactionCount : void 0,
1401
+ toolBreakdown: { ...this.toolBreakdown },
1402
+ outcome: this.outcome ?? "completed"
1403
+ };
1404
+ const resolvedName = this.resolveNameCb ? await this.resolveNameCb().catch(() => null) : null;
1405
+ if (resolvedName !== null) {
1406
+ const { name: _drop, ...summaryWithoutName } = this.summary;
1407
+ this.summary = {
1408
+ ...summaryWithoutName,
1409
+ ...resolvedName.name !== void 0 ? { name: resolvedName.name } : {}
1410
+ };
1411
+ }
1412
+ return this.summary;
1413
+ }
1414
+ reset(resetAt) {
1415
+ const explicitName = this.summary.name;
1416
+ this.baseTokenTotal = 0;
1417
+ this.tokenIn = 0;
1418
+ this.tokenOut = 0;
1419
+ this.iterationCount = 0;
1420
+ this.toolCallCount = 0;
1421
+ this.toolErrorCount = 0;
1422
+ this.toolBreakdown = {};
1423
+ this.fileChangeCount = 0;
1424
+ this.compactionCount = 0;
1425
+ this.messageCount = 0;
1426
+ this.lastUserMessage = void 0;
1427
+ this.lastActivityAt = resetAt;
1428
+ this.outcome = void 0;
1429
+ this.openToolUses = /* @__PURE__ */ new Set();
1430
+ this.summary = {
1431
+ id: this.id,
1432
+ title: "(empty session)",
1433
+ ...explicitName !== void 0 ? { name: explicitName } : {},
1434
+ startedAt: resetAt,
1435
+ model: this.meta.model ?? "unknown",
1436
+ provider: this.meta.provider ?? "unknown",
1437
+ tokenTotal: 0,
1438
+ lastActivityAt: resetAt,
1439
+ messageCount: 0
1440
+ };
1441
+ }
1442
+ };
1443
+
1444
+ // src/storage/session-write-buffer.ts
1445
+ import { closeSync, fsyncSync, openSync, writeSync } from "node:fs";
1446
+ import * as fsp5 from "node:fs/promises";
1447
+ function isClosedHandleError(err) {
1448
+ const code = err?.code;
1449
+ return code === "EBADF" || code === "ERR_CLOSED_RESOURCE" || code === "ERR_INVALID_HANDLE";
1450
+ }
1451
+ var SessionWriteBuffer = class _SessionWriteBuffer {
1452
+ constructor(opts) {
1453
+ this.opts = opts;
1454
+ }
1455
+ opts;
1456
+ static FLUSH_INTERVAL_MS = 500;
1457
+ static FLUSH_SIZE = 50;
1458
+ static WRITE_BUFFER_MAX_EVENTS = 2e3;
1459
+ static WRITE_BUFFER_MAX_BYTES = 16 * 1024 * 1024;
1460
+ writeBuffer = [];
1461
+ writeBufferBytes = 0;
1462
+ flushTimer = null;
1463
+ bufferOverflowCount = 0;
1464
+ lastBufferOverflowWarnAt = 0;
1465
+ appendFailCount = 0;
1466
+ lastAppendWarnAt = 0;
1467
+ writeChain = Promise.resolve();
1468
+ flushPromise = null;
1469
+ /**
1470
+ * Batch currently inside enqueueWrite. `flushSync` may steal it if the
1471
+ * async append has not started, so a dying process writes in-flight +
1472
+ * remaining buffer as one ordered append instead of racing a second fd.
1473
+ */
1474
+ inFlight = null;
1475
+ get isClosed() {
1476
+ return false;
1477
+ }
1478
+ get length() {
1479
+ return this.writeBuffer.length;
1480
+ }
1481
+ eventBytes(event) {
1482
+ try {
1483
+ return Buffer.byteLength(JSON.stringify(event), "utf8") + 1;
1484
+ } catch {
1485
+ return _SessionWriteBuffer.WRITE_BUFFER_MAX_BYTES + 1;
1486
+ }
1487
+ }
1488
+ push(event) {
1489
+ const bytes = this.eventBytes(event);
1490
+ if (this.writeBuffer.length >= _SessionWriteBuffer.WRITE_BUFFER_MAX_EVENTS || bytes > _SessionWriteBuffer.WRITE_BUFFER_MAX_BYTES || this.writeBufferBytes + bytes > _SessionWriteBuffer.WRITE_BUFFER_MAX_BYTES) {
1491
+ this.bufferOverflowCount++;
1492
+ const now = Date.now();
1493
+ if (now - this.lastBufferOverflowWarnAt > 5e3) {
1494
+ console.warn(
1495
+ JSON.stringify({
1496
+ level: "error",
1497
+ event: "session.buffer_overflow",
1498
+ sessionId: this.opts.sessionId,
1499
+ bufferedEvents: this.writeBuffer.length,
1500
+ bufferedBytes: this.writeBufferBytes,
1501
+ droppedEvents: this.bufferOverflowCount,
1502
+ timestamp: (/* @__PURE__ */ new Date()).toISOString()
1503
+ })
1504
+ );
1505
+ this.bufferOverflowCount = 0;
1506
+ this.lastBufferOverflowWarnAt = now;
1507
+ }
1508
+ return false;
1509
+ }
1510
+ this.writeBuffer.push(event);
1511
+ this.writeBufferBytes += bytes;
1512
+ return true;
1513
+ }
1514
+ enqueueWrite(data) {
1515
+ return this.enqueueFlight({ data, stolen: false, started: false });
1516
+ }
1517
+ enqueueFlight(flight) {
1518
+ const write = this.writeChain.then(async () => {
1519
+ if (flight.stolen) return;
1520
+ flight.started = true;
1521
+ if (flight.stolen) return;
1522
+ try {
1523
+ return await this.opts.getHandle().appendFile(flight.data, "utf8");
1524
+ } catch (err) {
1525
+ if (isClosedHandleError(err)) {
1526
+ const reloaded = await fsp5.open(this.opts.filePath, "a", 384);
1527
+ this.opts.setHandle(reloaded);
1528
+ return await reloaded.appendFile(flight.data, "utf8");
1529
+ }
1530
+ throw err;
1531
+ }
1532
+ });
1533
+ this.writeChain = write.then(
1534
+ () => void 0,
1535
+ () => void 0
1536
+ );
1537
+ return write;
1538
+ }
1539
+ scheduleFlush(isClosed = false) {
1540
+ if (this.flushTimer || isClosed) return;
1541
+ this.flushTimer = setTimeout(() => {
1542
+ this.flushTimer = null;
1543
+ this.flushBuffer(isClosed).catch(() => {
1544
+ });
1545
+ }, _SessionWriteBuffer.FLUSH_INTERVAL_MS);
1546
+ }
1547
+ cancelTimer() {
1548
+ if (this.flushTimer) {
1549
+ clearTimeout(this.flushTimer);
1550
+ this.flushTimer = null;
1551
+ }
1552
+ }
1553
+ async flushBuffer(isClosed = false, opts = {}) {
1554
+ if (this.flushPromise) {
1555
+ const joined = this.flushPromise;
1556
+ const continueIfDirty = () => {
1557
+ if (this.writeBuffer.length === 0) return Promise.resolve();
1558
+ return this.flushBuffer(isClosed, opts);
1559
+ };
1560
+ if (opts.datasync === true) {
1561
+ return joined.then(
1562
+ () => this.opts.getHandle().datasync().catch(() => void 0).then(continueIfDirty)
1563
+ );
1564
+ }
1565
+ return joined.then(continueIfDirty);
1566
+ }
1567
+ const flush = (async () => {
1568
+ while (this.writeBuffer.length > 0) await this.flushBufferOnce(isClosed, opts);
1569
+ })().finally(() => {
1570
+ if (this.flushPromise === flush) this.flushPromise = null;
1571
+ });
1572
+ this.flushPromise = flush;
1573
+ return flush;
1574
+ }
1575
+ async flushBufferOnce(isClosed, opts) {
1576
+ if (this.writeBuffer.length === 0) return;
1577
+ const events = this.writeBuffer;
1578
+ const eventCount = events.length;
1579
+ const eventBytes = this.writeBufferBytes;
1580
+ const batch = events.map((e) => JSON.stringify(e)).join("\n") + "\n";
1581
+ this.writeBuffer = [];
1582
+ this.writeBufferBytes = 0;
1583
+ const flight = { data: batch, stolen: false, started: false };
1584
+ this.inFlight = flight;
1585
+ const t0 = Date.now();
1586
+ let outcome = "success";
1587
+ let errorMsg;
1588
+ try {
1589
+ await this.enqueueFlight(flight);
1590
+ if (flight.stolen) {
1591
+ return;
1592
+ }
1593
+ if (opts?.datasync === true) {
1594
+ await this.opts.getHandle().datasync().catch(() => void 0);
1595
+ }
1596
+ } catch (err) {
1597
+ if (flight.stolen) return;
1598
+ outcome = "failure";
1599
+ errorMsg = toErrorMessage(err);
1600
+ const newer = this.writeBuffer;
1601
+ this.writeBuffer = events;
1602
+ this.writeBufferBytes = eventBytes;
1603
+ for (const newerEvent of newer) this.push(newerEvent);
1604
+ this.appendFailCount += eventCount;
1605
+ const now = Date.now();
1606
+ if (now - this.lastAppendWarnAt > 5e3) {
1607
+ const suppressed = this.appendFailCount - 1;
1608
+ console.warn(
1609
+ JSON.stringify({
1610
+ level: "warn",
1611
+ event: "session.flush_failed",
1612
+ sessionId: this.opts.sessionId,
1613
+ message: toErrorMessage(err),
1614
+ ...suppressed > 0 ? { suppressed } : {},
1615
+ timestamp: (/* @__PURE__ */ new Date()).toISOString()
1616
+ })
1617
+ );
1618
+ this.lastAppendWarnAt = now;
1619
+ this.appendFailCount = 0;
1620
+ }
1621
+ if (!isClosed) this.scheduleFlush(isClosed);
1622
+ throw err;
1623
+ } finally {
1624
+ this.opts.events?.emit("storage.write", {
1625
+ sessionId: this.opts.sessionId,
1626
+ store: "session",
1627
+ filePath: this.opts.filePath,
1628
+ operation: "flush",
1629
+ outcome,
1630
+ durationMs: Date.now() - t0,
1631
+ ...errorMsg !== void 0 ? { error: errorMsg } : {},
1632
+ ...eventCount !== void 0 ? { eventCount } : {},
1633
+ ...this.opts.getTraceId?.() ? { traceId: this.opts.getTraceId() } : {}
1634
+ });
1635
+ if (this.inFlight === flight) this.inFlight = null;
1636
+ }
1637
+ }
1638
+ async drainWriteChain() {
1639
+ await this.writeChain;
1640
+ }
1641
+ async drainFlushPromise() {
1642
+ await this.flushPromise?.catch(() => void 0);
1643
+ }
1644
+ /**
1645
+ * Last-gasp synchronous append (SIGKILL/SIGTERM traps, `process.on('exit')`).
1646
+ *
1647
+ * Failure contract: nothing is discarded before the write is known to have
1648
+ * landed. Buffered events stay in `writeBuffer` — it is cleared only after
1649
+ * `fsyncSync` returns — and a stolen in-flight batch is handed back to the
1650
+ * async write chain, so a survivable failure (EACCES, ENOSPC, EMFILE) loses
1651
+ * nothing. Failure is never silent: a structured `session.flush_sync_failed`
1652
+ * warning names exactly what was still pending, which is what SIGKILL-trap
1653
+ * tests assert against.
1654
+ */
1655
+ flushSync() {
1656
+ if (!this.opts.filePath) return;
1657
+ this.cancelTimer();
1658
+ const chunks = [];
1659
+ const flight = this.inFlight;
1660
+ const stole = flight !== null && !flight.started && !flight.stolen;
1661
+ if (stole && flight) {
1662
+ flight.stolen = true;
1663
+ chunks.push(flight.data);
1664
+ }
1665
+ const events = this.writeBuffer;
1666
+ if (events.length > 0) {
1667
+ chunks.push(events.map((e) => JSON.stringify(e)).join("\n") + "\n");
1668
+ }
1669
+ if (chunks.length === 0) return;
1670
+ let fd;
1671
+ try {
1672
+ fd = openSync(this.opts.filePath, "a");
1673
+ writeSync(fd, chunks.join(""), null, "utf8");
1674
+ fsyncSync(fd);
1675
+ if (this.writeBuffer === events) {
1676
+ this.writeBuffer = [];
1677
+ this.writeBufferBytes = 0;
1678
+ }
1679
+ } catch (err) {
1680
+ if (stole && flight) flight.stolen = false;
1681
+ console.warn(
1682
+ JSON.stringify({
1683
+ level: "error",
1684
+ event: "session.flush_sync_failed",
1685
+ sessionId: this.opts.sessionId,
1686
+ filePath: this.opts.filePath,
1687
+ message: toErrorMessage(err),
1688
+ pendingEvents: events.length,
1689
+ pendingBytes: this.writeBufferBytes,
1690
+ hadInFlightBatch: stole,
1691
+ timestamp: (/* @__PURE__ */ new Date()).toISOString()
1692
+ })
1693
+ );
1694
+ } finally {
1695
+ if (fd !== void 0) {
1696
+ try {
1697
+ closeSync(fd);
1698
+ } catch {
1699
+ }
1700
+ }
1701
+ }
1702
+ }
1703
+ clear() {
1704
+ this.cancelTimer();
1705
+ this.writeBuffer = [];
1706
+ this.writeBufferBytes = 0;
1707
+ this.appendFailCount = 0;
1708
+ this.lastAppendWarnAt = 0;
1709
+ this.bufferOverflowCount = 0;
1710
+ this.lastBufferOverflowWarnAt = 0;
1711
+ }
1712
+ shouldFlushNow() {
1713
+ return this.writeBuffer.length >= _SessionWriteBuffer.FLUSH_SIZE;
1714
+ }
1715
+ };
1716
+
1717
+ // src/storage/session-writer-scrubber.ts
1718
+ function scrubSessionWriterEvent(event, secretScrubber) {
1719
+ const persistMessage = (message) => {
1720
+ const { _estTokens: _ignored, ...persisted } = message;
1721
+ return {
1722
+ ...persisted,
1723
+ content: typeof persisted.content === "string" ? secretScrubber?.scrub(persisted.content) ?? persisted.content : secretScrubber?.scrubObject(persisted.content) ?? persisted.content
1724
+ };
1725
+ };
1726
+ if (event.type === "context_snapshot" || event.type === "messages_replaced") {
1727
+ return { ...event, messages: event.messages.map(persistMessage) };
1728
+ }
1729
+ if (event.type === "message_appended" || event.type === "message_updated") {
1730
+ return { ...event, message: persistMessage(event.message) };
1731
+ }
1732
+ if (!secretScrubber) return event;
1733
+ if (event.type === "user_input") {
1734
+ return {
1735
+ ...event,
1736
+ content: typeof event.content === "string" ? secretScrubber.scrub(event.content) : secretScrubber.scrubObject(event.content)
1737
+ };
1738
+ }
1739
+ if (event.type === "llm_response") {
1740
+ return { ...event, content: secretScrubber.scrubObject(event.content) };
1741
+ }
1742
+ if (event.type === "file_snapshot") {
1743
+ return {
1744
+ ...event,
1745
+ files: event.files.map((f) => ({
1746
+ ...f,
1747
+ before: f.before !== null ? secretScrubber.scrub(f.before) : null,
1748
+ after: f.after !== null ? secretScrubber.scrub(f.after) : null
1749
+ }))
1750
+ };
1751
+ }
1752
+ return event;
1753
+ }
1754
+
1755
+ // src/storage/session-writer-truncate.ts
1756
+ import * as fsp6 from "node:fs/promises";
1757
+ var CHUNK_SIZE = 65536;
1758
+ async function findSessionCheckpointTruncatePlan(filePath, targetPromptIndex) {
1759
+ let fd;
1760
+ let fileOffset = 0;
1761
+ let pendingLine = Buffer.alloc(0);
1762
+ let checkpointByteOffset = -1;
1763
+ let removedCount = 0;
1764
+ try {
1765
+ fd = await fsp6.open(filePath, "r", 384);
1766
+ while (true) {
1767
+ const buf = Buffer.alloc(CHUNK_SIZE);
1768
+ const { bytesRead } = await fd.read(buf, 0, CHUNK_SIZE, fileOffset);
1769
+ if (bytesRead === 0) break;
1770
+ const carriedBytes = pendingLine.length;
1771
+ const chunk = carriedBytes === 0 ? buf.subarray(0, bytesRead) : Buffer.concat([pendingLine, buf.subarray(0, bytesRead)]);
1772
+ const chunkFileOffset = fileOffset - carriedBytes;
1773
+ let chunkPos = 0;
1774
+ while (chunkPos < chunk.length) {
1775
+ const idx = chunk.indexOf("\n", chunkPos);
1776
+ if (idx === -1) break;
1777
+ const lineStartOffset = chunkFileOffset + chunkPos;
1778
+ if (checkpointByteOffset !== -1) {
1779
+ removedCount++;
1780
+ } else {
1781
+ const lineBytes = chunk.subarray(chunkPos, idx);
1782
+ const line = new TextDecoder("utf-8", { fatal: false }).decode(lineBytes);
1783
+ if (line.trim()) {
1784
+ try {
1785
+ const event = JSON.parse(line);
1786
+ if (event.type === "checkpoint") {
1787
+ if (event.promptIndex === targetPromptIndex) {
1788
+ checkpointByteOffset = lineStartOffset;
1789
+ } else if (event.promptIndex !== void 0 && event.promptIndex > targetPromptIndex) {
1790
+ checkpointByteOffset = lineStartOffset;
1791
+ }
1792
+ }
1793
+ } catch {
1794
+ }
1795
+ }
1796
+ }
1797
+ chunkPos = idx + 1;
1798
+ }
1799
+ pendingLine = chunk.subarray(chunkPos);
1800
+ fileOffset += bytesRead;
1801
+ }
1802
+ } finally {
1803
+ await fd?.close();
1804
+ }
1805
+ if (checkpointByteOffset === -1) return null;
1806
+ return { checkpointByteOffset, removedCount };
1807
+ }
1808
+ async function rewriteSessionToCheckpoint(filePath, checkpointByteOffset) {
1809
+ const tmpPath = `${filePath}.rewind.tmp`;
1810
+ const src = await fsp6.open(filePath, "r", 384);
1811
+ let srcClosed = false;
1812
+ const closeSrc = async () => {
1813
+ if (srcClosed) return;
1814
+ srcClosed = true;
1815
+ await src.close();
1816
+ };
1817
+ try {
1818
+ const statResult = await src.stat();
1819
+ const totalSize = statResult.size;
1820
+ const prefixBytes = checkpointByteOffset;
1821
+ let newlineAfterCheckpoint = prefixBytes;
1822
+ if (prefixBytes < totalSize) {
1823
+ const probeBuf = Buffer.alloc(Math.min(CHUNK_SIZE, totalSize - prefixBytes));
1824
+ const { bytesRead: probeRead } = await src.read(probeBuf, 0, probeBuf.length, prefixBytes);
1825
+ if (probeRead > 0) {
1826
+ const nl = probeBuf.indexOf("\n");
1827
+ newlineAfterCheckpoint = nl !== -1 ? prefixBytes + nl + 1 : totalSize;
1828
+ }
1829
+ } else {
1830
+ newlineAfterCheckpoint = totalSize;
1831
+ }
1832
+ const writeFd = await fsp6.open(tmpPath, "w", 384);
1833
+ try {
1834
+ let readOffset = 0;
1835
+ while (readOffset < newlineAfterCheckpoint) {
1836
+ const toCopy = Math.min(CHUNK_SIZE, newlineAfterCheckpoint - readOffset);
1837
+ const copyBuf = Buffer.alloc(toCopy);
1838
+ const { bytesRead: r } = await src.read(copyBuf, 0, toCopy, readOffset);
1839
+ if (r === 0) break;
1840
+ await writeFd.write(copyBuf, 0, r);
1841
+ readOffset += r;
1842
+ }
1843
+ await writeFd.sync();
1844
+ } finally {
1845
+ await writeFd.close();
1846
+ }
1847
+ await closeSrc();
1848
+ await fsp6.rename(tmpPath, filePath);
1849
+ } catch (err) {
1850
+ await closeSrc().catch(() => void 0);
1851
+ await fsp6.unlink(tmpPath).catch(() => void 0);
1852
+ throw err;
1853
+ }
1854
+ }
1855
+
1856
+ // src/storage/file-session-writer.ts
1857
+ function isClosedHandleError2(err) {
1858
+ const code = err?.code;
1859
+ return code === "EBADF" || code === "ERR_CLOSED_RESOURCE" || code === "ERR_INVALID_HANDLE";
1860
+ }
1861
+ var CRITICAL_EVENT_TYPES = /* @__PURE__ */ new Set([
1862
+ "user_input",
1863
+ "llm_response",
1864
+ "checkpoint",
1865
+ "in_flight_start",
1866
+ "in_flight_end"
1867
+ ]);
1868
+ function isCriticalEvent(event) {
1869
+ return CRITICAL_EVENT_TYPES.has(event.type);
1870
+ }
1871
+ var METADATA_CHECKPOINT_INTERVAL_MS = 1e4;
1872
+ var FileSessionWriter = class _FileSessionWriter {
1873
+ constructor(id, handle, startedAt, meta, events, opts = {}, traceId) {
1874
+ this.id = id;
1875
+ this.handle = handle;
1876
+ this.startedAt = startedAt;
1877
+ this.meta = meta;
1878
+ this.events = events;
1879
+ this.resumed = opts.resumed ?? false;
1880
+ this.manifestFile = opts.dir ? path5.join(opts.dir, `${path5.basename(id)}.summary.json`) : "";
1881
+ this.filePath = opts.filePath ?? "";
1882
+ this.secretScrubber = opts.secretScrubber;
1883
+ this.checkpointCas = opts.checkpointCas;
1884
+ this._onAppend = opts.onAppend;
1885
+ this._onAppendBatch = opts.onAppendBatch;
1886
+ this.onCloseCb = opts.onClose;
1887
+ this.onMetadataCheckpointCb = opts.onMetadataCheckpoint;
1888
+ this.metadataCheckpointMs = opts.metadataCheckpointMs ?? METADATA_CHECKPOINT_INTERVAL_MS;
1889
+ this.summaryTracker = new SessionSummaryTracker({
1890
+ id,
1891
+ startedAt,
1892
+ meta,
1893
+ resumed: this.resumed,
1894
+ initialSummary: opts.initialSummary,
1895
+ resolveName: opts.resolveName
1896
+ });
1897
+ this.buffer = new SessionWriteBuffer({
1898
+ sessionId: id,
1899
+ filePath: this.filePath,
1900
+ getHandle: () => this.handle,
1901
+ setHandle: (h) => {
1902
+ this.handle = h;
1903
+ },
1904
+ events: this.events,
1905
+ getTraceId: () => this.traceId
1906
+ });
1907
+ this.traceId = traceId;
1908
+ }
1909
+ id;
1910
+ handle;
1911
+ startedAt;
1912
+ meta;
1913
+ events;
1914
+ closed = false;
1915
+ closePromise = null;
1916
+ manifestFile;
1917
+ summaryTracker;
1918
+ buffer;
1919
+ filePath;
1920
+ get transcriptPath() {
1921
+ return this.filePath || void 0;
1922
+ }
1923
+ /**
1924
+ * Lazy session_start/session_resumed init, shared by all appenders.
1925
+ * A single promise (not a boolean) so a second append racing the first
1926
+ * can't push its event into the buffer BEFORE the first append's event —
1927
+ * every appender awaits the same init and resumes in FIFO call order.
1928
+ */
1929
+ initPromise = null;
1930
+ ensureInit() {
1931
+ if (!this.initPromise) this.initPromise = this.writeSessionStartLazy();
1932
+ return this.initPromise;
1933
+ }
1934
+ resumed;
1935
+ secretScrubber;
1936
+ checkpointCas;
1937
+ /** Mutable slot for onAppend callback — constructor opts seed it, setOnAppend replaces it. */
1938
+ _onAppend;
1939
+ /** Implements SessionWriter.onAppend — public getter/setter for direct property access. */
1940
+ get onAppend() {
1941
+ return this._onAppend;
1942
+ }
1943
+ set onAppend(cb) {
1944
+ this._onAppend = cb;
1945
+ }
1946
+ /** Mutable slot for onAppendBatch callback. */
1947
+ _onAppendBatch;
1948
+ /** Implements SessionWriter.onAppendBatch — public getter/setter for direct property access. */
1949
+ get onAppendBatch() {
1950
+ return this._onAppendBatch;
1951
+ }
1952
+ set onAppendBatch(cb) {
1953
+ this._onAppendBatch = cb;
1954
+ }
1955
+ onCloseCb;
1956
+ /** Mid-session metadata checkpoint throttle. 0 disables checkpointing. */
1957
+ metadataCheckpointMs;
1958
+ /** One-shot guard for the "interval set but no sink" warning below. */
1959
+ _checkpointNoSinkWarned = false;
1960
+ onMetadataCheckpointCb;
1961
+ /** Set whenever summary counters changed since the last metadata checkpoint. */
1962
+ metadataDirty = false;
1963
+ metadataTimer = null;
1964
+ metadataCheckpointInFlight = null;
1965
+ /** Implements SessionWriter.traceId — propagated from ContextInit.traceId. */
1966
+ traceId;
1967
+ /**
1968
+ * Set or replace the onAppend callback. Used by telemetry bridges that
1969
+ * receive the writer as an already-constructed dependency.
1970
+ */
1971
+ setOnAppend(cb) {
1972
+ this._onAppend = cb;
1973
+ }
1974
+ /**
1975
+ * Set or replace the onAppendBatch callback.
1976
+ */
1977
+ setOnAppendBatch(cb) {
1978
+ this._onAppendBatch = cb;
1979
+ }
1980
+ pendingFileSnapshots = [];
1981
+ pendingFileSnapshotBytes = 0;
1982
+ static PENDING_FILE_SNAPSHOT_MAX_ENTRIES = 256;
1983
+ static PENDING_FILE_SNAPSHOT_MAX_BYTES = 16 * 1024 * 1024;
1984
+ /** Prompt whose tool work is currently executing. Set by writeCheckpoint. */
1985
+ activePromptIndex = null;
1986
+ /**
1987
+ * Buffer an event from a synchronous Context callback. ensureInit() starts
1988
+ * by pushing the lifecycle preamble synchronously, so session_start always
1989
+ * precedes observations even though these callbacks cannot await append().
1990
+ */
1991
+ bufferSynchronousEvent(event) {
1992
+ if (this.closed) return;
1993
+ void this.ensureInit();
1994
+ const appendEvent = scrubSessionWriterEvent(event, this.secretScrubber);
1995
+ this.summaryTracker.observe(appendEvent);
1996
+ this.metadataDirty = true;
1997
+ this.scheduleMetadataCheckpoint();
1998
+ try {
1999
+ this._onAppend?.(appendEvent);
2000
+ } catch {
2001
+ }
2002
+ const critical = isCriticalEvent(appendEvent);
2003
+ if (!this.buffer.push(appendEvent)) {
2004
+ this.buffer.cancelTimer();
2005
+ void this.buffer.flushBuffer(this.closed, { datasync: true }).catch(() => void 0).then(() => {
2006
+ if (this.buffer.push(appendEvent)) {
2007
+ if (!critical) return;
2008
+ this.buffer.cancelTimer();
2009
+ void this.buffer.flushBuffer(this.closed, { datasync: true }).catch(() => void 0);
2010
+ return;
2011
+ }
2012
+ void this.buffer.drainWriteChain().then(() => this.buffer.enqueueWrite(`${JSON.stringify(appendEvent)}
2013
+ `)).then(() => {
2014
+ if (!critical) return;
2015
+ return this.handle.datasync().catch(() => void 0);
2016
+ }).catch(() => void 0);
2017
+ });
2018
+ } else if (critical || this.buffer.shouldFlushNow()) {
2019
+ this.buffer.cancelTimer();
2020
+ void this.buffer.flushBuffer(this.closed, { datasync: true }).catch(() => {
2021
+ });
2022
+ } else {
2023
+ this.buffer.scheduleFlush(this.closed);
2024
+ }
2025
+ }
2026
+ recordFileChange(input) {
2027
+ if (this.closed) return;
2028
+ if (this.activePromptIndex === null) {
2029
+ const bytes = Buffer.byteLength(input.path, "utf8") + (input.before ? Buffer.byteLength(input.before, "utf8") : 0) + (input.after ? Buffer.byteLength(input.after, "utf8") : 0);
2030
+ if (this.pendingFileSnapshots.length >= _FileSessionWriter.PENDING_FILE_SNAPSHOT_MAX_ENTRIES || bytes > _FileSessionWriter.PENDING_FILE_SNAPSHOT_MAX_BYTES || this.pendingFileSnapshotBytes + bytes > _FileSessionWriter.PENDING_FILE_SNAPSHOT_MAX_BYTES) {
2031
+ console.warn(
2032
+ JSON.stringify({
2033
+ level: "error",
2034
+ event: "session.file_snapshot_buffer_overflow",
2035
+ sessionId: this.id,
2036
+ bufferedFiles: this.pendingFileSnapshots.length,
2037
+ bufferedBytes: this.pendingFileSnapshotBytes,
2038
+ timestamp: (/* @__PURE__ */ new Date()).toISOString()
2039
+ })
2040
+ );
2041
+ return;
2042
+ }
2043
+ this.pendingFileSnapshots.push(input);
2044
+ this.pendingFileSnapshotBytes += bytes;
2045
+ return;
2046
+ }
2047
+ const event = {
2048
+ type: "file_snapshot",
2049
+ ts: (/* @__PURE__ */ new Date()).toISOString(),
2050
+ promptIndex: this.activePromptIndex,
2051
+ files: [input]
2052
+ };
2053
+ this.bufferSynchronousEvent(event);
2054
+ }
2055
+ recordFileObservation(input) {
2056
+ if (!input.path || !/^[a-f\d]{64}$/i.test(input.hash) || !Number.isFinite(input.mtimeMs))
2057
+ return;
2058
+ this.bufferSynchronousEvent({
2059
+ type: "file_observation",
2060
+ ts: (/* @__PURE__ */ new Date()).toISOString(),
2061
+ path: input.path,
2062
+ hash: input.hash.toLowerCase(),
2063
+ mtimeMs: input.mtimeMs,
2064
+ source: input.source
2065
+ });
2066
+ }
2067
+ recordSideEffect(input) {
2068
+ this.append({
2069
+ type: "side_effect",
2070
+ ts: (/* @__PURE__ */ new Date()).toISOString(),
2071
+ toolUseId: input.toolUseId,
2072
+ toolName: input.toolName,
2073
+ input: input.input,
2074
+ outcome: input.outcome,
2075
+ risk: input.risk
2076
+ }).catch(() => {
2077
+ });
2078
+ }
2079
+ get pendingToolUses() {
2080
+ return this.summaryTracker.pendingToolUses;
2081
+ }
2082
+ async writeSessionStartLazy() {
2083
+ this.buffer.push({
2084
+ type: this.resumed ? "session_resumed" : "session_start",
2085
+ ts: this.startedAt,
2086
+ id: this.id,
2087
+ model: this.meta.model ?? "unknown",
2088
+ provider: this.meta.provider ?? "unknown"
2089
+ });
2090
+ }
2091
+ /**
2092
+ * Arm the mid-session metadata checkpoint timer if it is not already armed.
2093
+ * Called after every observed event; the timer itself is unref'd so an idle
2094
+ * session never keeps the process alive for a cosmetic sidecar refresh.
2095
+ */
2096
+ scheduleMetadataCheckpoint() {
2097
+ if (this.closed || this.metadataTimer) return;
2098
+ if (this.metadataCheckpointMs <= 0) return;
2099
+ if (!this.onMetadataCheckpointCb && !this.manifestFile) {
2100
+ if (!this._checkpointNoSinkWarned) {
2101
+ this._checkpointNoSinkWarned = true;
2102
+ console.warn(
2103
+ JSON.stringify({
2104
+ level: "warn",
2105
+ event: "session.metadata_checkpoint_no_sink",
2106
+ sessionId: this.id,
2107
+ message: "metadataCheckpointMs set but neither onMetadataCheckpoint nor manifestFile configured; mid-session checkpoints disabled.",
2108
+ timestamp: (/* @__PURE__ */ new Date()).toISOString()
2109
+ })
2110
+ );
2111
+ }
2112
+ return;
2113
+ }
2114
+ this.metadataTimer = setTimeout(() => {
2115
+ this.metadataTimer = null;
2116
+ void this.runMetadataCheckpoint();
2117
+ }, this.metadataCheckpointMs);
2118
+ this.metadataTimer.unref?.();
2119
+ }
2120
+ /**
2121
+ * Persist a mid-session summary snapshot: the `.summary.json` sidecar under
2122
+ * the manifest lock, then the store-level index row / catalog upsert via
2123
+ * `onMetadataCheckpoint`. Runs at most once per throttle interval and only
2124
+ * when summary counters changed since the last checkpoint; a failed
2125
+ * checkpoint stays dirty and retries on the next armed tick.
2126
+ */
2127
+ runMetadataCheckpoint() {
2128
+ if (this.closed || !this.metadataDirty) return Promise.resolve();
2129
+ if (this.metadataCheckpointInFlight) return this.metadataCheckpointInFlight;
2130
+ const {
2131
+ endedAt: _priorEndedAt,
2132
+ outcome: _priorOutcome,
2133
+ ...snapshot
2134
+ } = this.summaryTracker.snapshot();
2135
+ const run = (async () => {
2136
+ const t0 = Date.now();
2137
+ let outcome = "success";
2138
+ let errorMsg;
2139
+ try {
2140
+ if (this.manifestFile) {
2141
+ await withFileLock(this.manifestFile, async () => {
2142
+ await atomicWrite(this.manifestFile, JSON.stringify(snapshot), { mode: 384 });
2143
+ });
2144
+ }
2145
+ this.metadataDirty = false;
2146
+ await this.onMetadataCheckpointCb?.(snapshot);
2147
+ if (this.metadataDirty && !this.closed) this.scheduleMetadataCheckpoint();
2148
+ } catch (err) {
2149
+ outcome = "failure";
2150
+ errorMsg = toErrorMessage(err);
2151
+ this.metadataDirty = true;
2152
+ this.scheduleMetadataCheckpoint();
2153
+ } finally {
2154
+ this.metadataCheckpointInFlight = null;
2155
+ this.events?.emit("storage.write", {
2156
+ sessionId: this.id,
2157
+ store: "session",
2158
+ filePath: this.manifestFile || this.filePath,
2159
+ operation: "metadata_checkpoint",
2160
+ outcome,
2161
+ durationMs: Date.now() - t0,
2162
+ ...errorMsg !== void 0 ? { error: errorMsg } : {},
2163
+ ...this.traceId !== void 0 ? { traceId: this.traceId } : {}
2164
+ });
2165
+ }
2166
+ })();
2167
+ this.metadataCheckpointInFlight = run;
2168
+ return run;
2169
+ }
2170
+ async append(event) {
2171
+ if (this.closed) return;
2172
+ await this.ensureInit();
2173
+ const scrubbed = scrubSessionWriterEvent(event, this.secretScrubber);
2174
+ this.summaryTracker.observe(scrubbed);
2175
+ this.metadataDirty = true;
2176
+ this.scheduleMetadataCheckpoint();
2177
+ try {
2178
+ this._onAppend?.(scrubbed);
2179
+ } catch {
2180
+ }
2181
+ let pushed = this.buffer.push(scrubbed);
2182
+ if (!pushed) {
2183
+ this.buffer.cancelTimer();
2184
+ await this.buffer.flushBuffer(this.closed, { datasync: true }).catch(() => void 0);
2185
+ pushed = this.buffer.push(scrubbed);
2186
+ if (!pushed) {
2187
+ await this.buffer.drainWriteChain().then(() => this.buffer.enqueueWrite(`${JSON.stringify(scrubbed)}
2188
+ `)).then(() => {
2189
+ if (!isCriticalEvent(scrubbed)) return;
2190
+ return this.handle.datasync().catch(() => void 0);
2191
+ }).catch(() => void 0);
2192
+ }
2193
+ }
2194
+ if (isCriticalEvent(scrubbed) || this.buffer.shouldFlushNow()) {
2195
+ this.buffer.cancelTimer();
2196
+ await this.buffer.flushBuffer(this.closed, { datasync: true }).catch(() => {
2197
+ });
2198
+ } else {
2199
+ this.buffer.scheduleFlush(this.closed);
2200
+ }
2201
+ }
2202
+ async appendBatch(events) {
2203
+ if (this.closed || events.length === 0) return;
2204
+ await this.ensureInit();
2205
+ const scrubbedBatch = [];
2206
+ for (const event of events) {
2207
+ const scrubbed = scrubSessionWriterEvent(event, this.secretScrubber);
2208
+ this.summaryTracker.observe(scrubbed);
2209
+ try {
2210
+ this._onAppend?.(scrubbed);
2211
+ } catch {
2212
+ }
2213
+ let pushed = this.buffer.push(scrubbed);
2214
+ if (!pushed) {
2215
+ this.buffer.cancelTimer();
2216
+ await this.buffer.flushBuffer(this.closed, { datasync: true }).catch(() => void 0);
2217
+ pushed = this.buffer.push(scrubbed);
2218
+ if (!pushed) {
2219
+ await this.buffer.drainWriteChain().then(() => this.buffer.enqueueWrite(`${JSON.stringify(scrubbed)}
2220
+ `)).then(() => {
2221
+ if (!isCriticalEvent(scrubbed)) return;
2222
+ return this.handle.datasync().catch(() => void 0);
2223
+ }).catch(() => void 0);
2224
+ }
2225
+ }
2226
+ scrubbedBatch.push(scrubbed);
2227
+ }
2228
+ if (scrubbedBatch.length > 0) {
2229
+ this.metadataDirty = true;
2230
+ this.scheduleMetadataCheckpoint();
2231
+ }
2232
+ try {
2233
+ this._onAppendBatch?.(scrubbedBatch);
2234
+ } catch {
2235
+ }
2236
+ const hasCritical = scrubbedBatch.some(isCriticalEvent);
2237
+ if (hasCritical || this.buffer.shouldFlushNow()) {
2238
+ this.buffer.cancelTimer();
2239
+ await this.buffer.flushBuffer(this.closed, { datasync: true }).catch(() => {
2240
+ });
2241
+ } else {
2242
+ this.buffer.scheduleFlush(this.closed);
2243
+ }
2244
+ }
2245
+ /**
2246
+ * Flush buffered events to disk immediately. Critical events
2247
+ * (user_input, llm_response, checkpoint, in_flight_*) already flush
2248
+ * themselves inside append()/appendBatch(), so calling this matters for
2249
+ * non-critical tails that would otherwise sit in the in-memory buffer
2250
+ * for up to 500ms.
2251
+ *
2252
+ * Idempotent — cancels any pending timer, writes whatever has accumulated,
2253
+ * then asks the OS to synchronize the file data before resolving. Even an
2254
+ * empty-buffer flush synchronizes any earlier timer-driven append.
2255
+ */
2256
+ async flush() {
2257
+ if (this.closed) return;
2258
+ this.buffer.cancelTimer();
2259
+ await this.buffer.flushBuffer(this.closed, { datasync: true });
2260
+ await this.buffer.drainWriteChain();
2261
+ try {
2262
+ await this.handle.datasync();
2263
+ } catch (err) {
2264
+ if (isClosedHandleError2(err)) {
2265
+ this.handle = await fsp7.open(this.filePath, "a", 384);
2266
+ return;
2267
+ }
2268
+ throw err;
2269
+ }
2270
+ }
2271
+ /**
2272
+ * Last-gasp synchronous drain for hard-exit paths (process.exit after
2273
+ * rapid Ctrl+C). The async write chain cannot be awaited when the process
2274
+ * is about to die, but whatever still sits in the in-memory buffer CAN be
2275
+ * saved with a blocking append. A failed sync append leaves the buffer
2276
+ * intact so a subsequent close()/flush() can retry. An in-flight async
2277
+ * write may still be cut off by a hard exit.
2278
+ */
2279
+ flushSync() {
2280
+ this.buffer.flushSync();
2281
+ }
2282
+ async close() {
2283
+ if (this.closePromise) return this.closePromise;
2284
+ this.closePromise = this.doClose().catch(async (err) => {
2285
+ if (this.metadataTimer) {
2286
+ clearTimeout(this.metadataTimer);
2287
+ this.metadataTimer = null;
2288
+ }
2289
+ await this.metadataCheckpointInFlight?.catch(() => void 0);
2290
+ this.closed = false;
2291
+ this.closePromise = null;
2292
+ if (this.buffer.length > 0) this.buffer.scheduleFlush(this.closed);
2293
+ if (this.metadataDirty) this.scheduleMetadataCheckpoint();
2294
+ throw err;
2295
+ });
2296
+ return this.closePromise;
2297
+ }
2298
+ async doClose() {
2299
+ if (this.metadataTimer) {
2300
+ clearTimeout(this.metadataTimer);
2301
+ this.metadataTimer = null;
2302
+ }
2303
+ await this.metadataCheckpointInFlight?.catch(() => void 0);
2304
+ await this.ensureInit();
2305
+ if (this.pendingFileSnapshots.length > 0) {
2306
+ await this.writeFileSnapshot(this.activePromptIndex ?? 0, [...this.pendingFileSnapshots]);
2307
+ this.pendingFileSnapshots = [];
2308
+ this.pendingFileSnapshotBytes = 0;
2309
+ }
2310
+ this.closed = true;
2311
+ if (this.metadataTimer) {
2312
+ clearTimeout(this.metadataTimer);
2313
+ this.metadataTimer = null;
2314
+ }
2315
+ await this.metadataCheckpointInFlight?.catch(() => void 0);
2316
+ this.buffer.cancelTimer();
2317
+ await this.buffer.flushBuffer(this.closed, { datasync: true });
2318
+ await this.buffer.drainWriteChain();
2319
+ try {
2320
+ await this.handle.datasync();
2321
+ } catch (err) {
2322
+ if (!isClosedHandleError2(err)) throw err;
2323
+ }
2324
+ const summary = await this.summaryTracker.finalize();
2325
+ const manifestT0 = Date.now();
2326
+ let manifestOutcome = "success";
2327
+ let manifestError;
2328
+ const idxT0 = Date.now();
2329
+ let idxOutcome = "success";
2330
+ let idxError;
2331
+ const persistSummary = async () => {
2332
+ if (this.manifestFile) {
2333
+ try {
2334
+ await atomicWrite(this.manifestFile, JSON.stringify(summary), { mode: 384 });
2335
+ } catch (err) {
2336
+ manifestOutcome = "failure";
2337
+ manifestError = toErrorMessage(err);
2338
+ }
2339
+ }
2340
+ try {
2341
+ await this.onCloseCb?.(summary);
2342
+ } catch (err) {
2343
+ idxOutcome = "failure";
2344
+ idxError = toErrorMessage(err);
2345
+ }
2346
+ };
2347
+ if (this.manifestFile) await withFileLock(this.manifestFile, persistSummary);
2348
+ else await persistSummary();
2349
+ if (this.manifestFile) {
2350
+ this.events?.emit("storage.write", {
2351
+ sessionId: this.id,
2352
+ store: "session",
2353
+ filePath: this.manifestFile,
2354
+ operation: "close",
2355
+ outcome: manifestOutcome,
2356
+ durationMs: Date.now() - manifestT0,
2357
+ ...manifestError !== void 0 ? { error: manifestError } : {},
2358
+ ...this.traceId !== void 0 ? { traceId: this.traceId } : {}
2359
+ });
2360
+ }
2361
+ this.events?.emit("storage.write", {
2362
+ sessionId: summary.id,
2363
+ store: "session",
2364
+ filePath: this.filePath,
2365
+ operation: "index_append",
2366
+ outcome: idxOutcome,
2367
+ durationMs: Date.now() - idxT0,
2368
+ ...idxError !== void 0 ? { error: idxError } : {},
2369
+ ...this.traceId !== void 0 ? { traceId: this.traceId } : {}
2370
+ });
2371
+ try {
2372
+ await this.handle.close();
2373
+ } catch {
2374
+ }
2375
+ }
2376
+ async writeCheckpoint(promptIndex, promptPreview) {
2377
+ const fileCount = this.pendingFileSnapshots.length;
2378
+ if (fileCount > 0) {
2379
+ await this.writeFileSnapshot(promptIndex, [...this.pendingFileSnapshots]);
2380
+ this.pendingFileSnapshots = [];
2381
+ this.pendingFileSnapshotBytes = 0;
2382
+ }
2383
+ let workspaceCheckpoint;
2384
+ try {
2385
+ workspaceCheckpoint = await this.checkpointCas?.capture(this.id, promptIndex);
2386
+ } catch (err) {
2387
+ console.warn(
2388
+ JSON.stringify({
2389
+ level: "warn",
2390
+ event: "session.workspace_checkpoint_capture_failed",
2391
+ sessionId: this.id,
2392
+ promptIndex,
2393
+ message: toErrorMessage(err),
2394
+ timestamp: (/* @__PURE__ */ new Date()).toISOString()
2395
+ })
2396
+ );
2397
+ }
2398
+ await this.append({
2399
+ type: "checkpoint",
2400
+ ts: (/* @__PURE__ */ new Date()).toISOString(),
2401
+ promptIndex,
2402
+ promptPreview,
2403
+ ...workspaceCheckpoint ? { workspaceCheckpoint } : {}
2404
+ });
2405
+ this.activePromptIndex = promptIndex;
2406
+ this.events?.emit("checkpoint.written", {
2407
+ sessionId: this.id,
2408
+ promptIndex,
2409
+ promptPreview,
2410
+ ts: (/* @__PURE__ */ new Date()).toISOString(),
2411
+ fileCount,
2412
+ ...workspaceCheckpoint ? { workspaceCheckpoint } : {}
2413
+ });
2414
+ }
2415
+ async writeFileSnapshot(promptIndex, files) {
2416
+ await this.append({
2417
+ type: "file_snapshot",
2418
+ ts: (/* @__PURE__ */ new Date()).toISOString(),
2419
+ promptIndex,
2420
+ files
2421
+ });
2422
+ }
2423
+ /**
2424
+ * Truncate the session file to the checkpoint with the given promptIndex,
2425
+ * removing all events that follow it. Uses a single-pass byte-offset scan
2426
+ * so post-checkpoint content is never read or parsed — O(1) memory instead
2427
+ * of O(N) JSON.parse calls over the full file.
2428
+ */
2429
+ async truncateToCheckpoint(targetPromptIndex, revertedFiles = []) {
2430
+ if (!this.filePath) return 0;
2431
+ this.buffer.cancelTimer();
2432
+ await this.buffer.flushBuffer(this.closed, { datasync: true });
2433
+ await this.buffer.drainWriteChain();
2434
+ if (this.metadataTimer) {
2435
+ clearTimeout(this.metadataTimer);
2436
+ this.metadataTimer = null;
2437
+ }
2438
+ await this.metadataCheckpointInFlight?.catch(() => void 0);
2439
+ const plan = await findSessionCheckpointTruncatePlan(this.filePath, targetPromptIndex).catch(
2440
+ (err) => {
2441
+ this.scheduleMetadataCheckpoint();
2442
+ throw err;
2443
+ }
2444
+ );
2445
+ if (!plan) {
2446
+ this.scheduleMetadataCheckpoint();
2447
+ return 0;
2448
+ }
2449
+ await this.buffer.drainWriteChain();
2450
+ try {
2451
+ await this.handle.close();
2452
+ } catch {
2453
+ }
2454
+ try {
2455
+ await rewriteSessionToCheckpoint(this.filePath, plan.checkpointByteOffset);
2456
+ this.handle = await fsp7.open(this.filePath, "a", 384);
2457
+ } catch (err) {
2458
+ this.handle = await fsp7.open(this.filePath, "a", 384).catch(() => this.handle);
2459
+ this.scheduleMetadataCheckpoint();
2460
+ throw err;
2461
+ }
2462
+ await this.summaryTracker.recomputeFromDisk(this.filePath);
2463
+ const reverted = [...revertedFiles];
2464
+ await this.append({
2465
+ type: "rewound",
2466
+ ts: (/* @__PURE__ */ new Date()).toISOString(),
2467
+ toPromptIndex: targetPromptIndex,
2468
+ revertedFiles: reverted
2469
+ });
2470
+ this.activePromptIndex = targetPromptIndex;
2471
+ this.events?.emit("session.rewound", {
2472
+ sessionId: this.id,
2473
+ toPromptIndex: targetPromptIndex,
2474
+ revertedFiles: reverted,
2475
+ removedEvents: plan.removedCount
2476
+ });
2477
+ return plan.removedCount;
2478
+ }
2479
+ async clearSession() {
2480
+ if (!this.filePath) return;
2481
+ this.buffer.cancelTimer();
2482
+ await this.buffer.drainFlushPromise();
2483
+ this.buffer.clear();
2484
+ await this.buffer.drainWriteChain();
2485
+ if (this.metadataTimer) {
2486
+ clearTimeout(this.metadataTimer);
2487
+ this.metadataTimer = null;
2488
+ }
2489
+ await this.metadataCheckpointInFlight?.catch(() => void 0);
2490
+ const resetAt = (/* @__PURE__ */ new Date()).toISOString();
2491
+ const record = `${JSON.stringify({
2492
+ type: "session_start",
2493
+ ts: resetAt,
2494
+ id: this.id,
2495
+ model: this.meta.model ?? "unknown",
2496
+ provider: this.meta.provider ?? "unknown"
2497
+ })}
2498
+ `;
2499
+ await this.handle.close();
2500
+ await atomicWrite(this.filePath, record, { mode: 384 });
2501
+ this.summaryTracker.reset(resetAt);
2502
+ this.metadataDirty = true;
2503
+ this.scheduleMetadataCheckpoint();
2504
+ this.activePromptIndex = null;
2505
+ this.pendingFileSnapshots = [];
2506
+ this.pendingFileSnapshotBytes = 0;
2507
+ }
2508
+ /**
2509
+ * Write an in-flight marker. The agent loop should call
2510
+ * this at the start of each long-running operation; a matching
2511
+ * `clearInFlightMarker` follows on clean exit. A stale marker
2512
+ * (no end) is what `SessionRecovery.detectStale` looks for.
2513
+ */
2514
+ async writeInFlightMarker(context) {
2515
+ if (!context || context.length > 500) {
2516
+ throw new Error("In-flight context must be 1..500 chars");
2517
+ }
2518
+ await this.append({
2519
+ type: "in_flight_start",
2520
+ ts: (/* @__PURE__ */ new Date()).toISOString(),
2521
+ context
2522
+ });
2523
+ this.events?.emit("in_flight.started", {
2524
+ sessionId: this.id,
2525
+ context,
2526
+ ts: (/* @__PURE__ */ new Date()).toISOString()
2527
+ });
2528
+ }
2529
+ /**
2530
+ * Close the in-flight marker. Idempotent in spirit
2531
+ * (you can call it after a successful iteration even if you
2532
+ * didn't open one this round) — but the session log records
2533
+ * every call so postmortem tooling can see "the agent finished
2534
+ * cleanly X times, then died without finishing Y".
2535
+ */
2536
+ async clearInFlightMarker(reason) {
2537
+ if (this.pendingFileSnapshots.length > 0) {
2538
+ await this.writeFileSnapshot(this.activePromptIndex ?? 0, [...this.pendingFileSnapshots]);
2539
+ this.pendingFileSnapshots = [];
2540
+ this.pendingFileSnapshotBytes = 0;
2541
+ }
2542
+ await this.append({
2543
+ type: "in_flight_end",
2544
+ ts: (/* @__PURE__ */ new Date()).toISOString(),
2545
+ reason
2546
+ });
2547
+ this.events?.emit("in_flight.ended", {
2548
+ sessionId: this.id,
2549
+ reason,
2550
+ ts: (/* @__PURE__ */ new Date()).toISOString()
2551
+ });
2552
+ }
2553
+ };
2554
+
2555
+ // src/storage/session-store/delete-session-artifacts.ts
2556
+ import * as fsp8 from "node:fs/promises";
2557
+ import * as path7 from "node:path";
2558
+
2559
+ // src/storage/session-store/paths.ts
2560
+ import * as path6 from "node:path";
2561
+ function sessionPath(storeDir, id, ext) {
2562
+ return sessionScopedPath(storeDir, id, ext);
2563
+ }
2564
+ function shardManifestPath(storeDir, shardKey) {
2565
+ return shardKey ? path6.join(storeDir, shardKey, "_manifest.json") : path6.join(storeDir, "_manifest.json");
2566
+ }
2567
+ function shardKeyForSessionId(id) {
2568
+ const dirName = path6.dirname(id);
2569
+ return dirName === "." ? "" : dirName;
2570
+ }
2571
+ async function ensureShardDir(storeDir, id) {
2572
+ const dirPath = path6.dirname(sessionScopedPath(storeDir, id, ""));
2573
+ await ensureDir(dirPath);
2574
+ return dirPath;
2575
+ }
2576
+
2577
+ // src/storage/session-store/delete-session-artifacts.ts
2578
+ async function deleteSessionArtifacts({
2579
+ rootDir,
2580
+ id,
2581
+ jsonlPath
2582
+ }) {
2583
+ const shardDir = path7.dirname(jsonlPath);
2584
+ const base = path7.basename(id);
2585
+ const sessDir = path7.join(shardDir, base);
2586
+ const deletions = [
2587
+ fsp8.unlink(jsonlPath),
2588
+ fsp8.unlink(sessionPath(rootDir, id, ".summary.json")),
2589
+ fsp8.unlink(sessionPath(rootDir, id, ".plan.json")),
2590
+ fsp8.unlink(sessionPath(rootDir, id, ".tasks.json")),
2591
+ fsp8.unlink(sessionPath(rootDir, id, ".todos.json"))
2592
+ ];
2593
+ const results = await Promise.allSettled(deletions);
2594
+ for (const result of results) {
2595
+ if (result.status === "rejected") {
2596
+ warnDeleteFailure(id, result.reason);
2597
+ }
2598
+ }
2599
+ await fsp8.rm(sessDir, { recursive: true, force: true }).catch((err) => {
2600
+ console.warn(
2601
+ JSON.stringify({
2602
+ level: "warn",
2603
+ event: "session_store.rmdir_failed",
2604
+ sessionId: id,
2605
+ message: toErrorMessage(err),
2606
+ timestamp: (/* @__PURE__ */ new Date()).toISOString()
2607
+ })
2608
+ );
2609
+ });
2610
+ }
2611
+ function warnDeleteFailure(id, reason) {
2612
+ const msg = reason instanceof Error ? reason.message : String(reason);
2613
+ if (reason?.code === "ENOENT") return;
2614
+ console.warn(
2615
+ JSON.stringify({
2616
+ level: "warn",
2617
+ event: "session_store.delete_failed",
2618
+ sessionId: id,
2619
+ message: msg,
2620
+ timestamp: (/* @__PURE__ */ new Date()).toISOString()
2621
+ })
2622
+ );
2623
+ }
2624
+
2625
+ // src/storage/session-store/delete-session-guards.ts
2626
+ async function assertSessionCanBeDeleted(sessionId, isSessionInUse) {
2627
+ if (!isSessionInUse) return;
2628
+ const reason = await isSessionInUse(sessionId);
2629
+ if (reason) {
2630
+ throw new Error(`Session ${sessionId} is in use (${reason}) and cannot be deleted.`);
2631
+ }
2632
+ }
2633
+
2634
+ // src/storage/session-store/events.ts
2635
+ function emitSessionStoreRead(events, sessionId, filePath, operation, outcome, durationMs, error) {
2636
+ events?.emit("storage.read", {
2637
+ sessionId,
2638
+ store: "session",
2639
+ filePath,
2640
+ operation,
2641
+ outcome,
2642
+ durationMs,
2643
+ ...error !== void 0 ? { error } : {}
2644
+ });
2645
+ }
2646
+ function emitSessionStoreWrite(events, sessionId, filePath, operation, outcome, durationMs, eventCount, error) {
2647
+ events?.emit("storage.write", {
2648
+ sessionId,
2649
+ store: "session",
2650
+ filePath,
2651
+ operation,
2652
+ outcome,
2653
+ durationMs,
2654
+ ...eventCount !== void 0 ? { eventCount } : {},
2655
+ ...error !== void 0 ? { error } : {}
2656
+ });
2657
+ }
2658
+ function emitSessionStoreError(events, sessionId, filePath, operation, error, recoverable) {
2659
+ events?.emit("storage.error", {
2660
+ sessionId,
2661
+ store: "session",
2662
+ filePath,
2663
+ operation,
2664
+ error,
2665
+ recoverable
2666
+ });
2667
+ }
2668
+
2669
+ // src/storage/session-store/fork-session.ts
2670
+ import { createHash as createHash2 } from "node:crypto";
2671
+
2672
+ // src/storage/session-store/replay.ts
2673
+ function isReplayableMessage(value) {
2674
+ if (!value || typeof value !== "object") return false;
2675
+ const candidate = value;
2676
+ return (candidate.role === "user" || candidate.role === "assistant" || candidate.role === "system") && (typeof candidate.content === "string" || Array.isArray(candidate.content));
2677
+ }
2678
+ function trackMessageToolState(message, openToolUses) {
2679
+ if (!Array.isArray(message.content)) return;
2680
+ for (const block of message.content) {
2681
+ if (block.type === "tool_use") openToolUses.add(block.id);
2682
+ else if (block.type === "tool_result") openToolUses.delete(block.tool_use_id);
2683
+ }
2684
+ }
2685
+ function replayableMessage(value, fallbackTs) {
2686
+ if (!isReplayableMessage(value)) return null;
2687
+ const { _estTokens: _ignored, ...message } = value;
2688
+ return message.ts === void 0 && fallbackTs ? { ...message, ts: fallbackTs } : message;
2689
+ }
2690
+ function applyContextSnapshot(target, openToolUses, snapshot) {
2691
+ if (!Array.isArray(snapshot) || !snapshot.every(isReplayableMessage)) return false;
2692
+ target.length = 0;
2693
+ openToolUses.clear();
2694
+ for (const raw of snapshot) {
2695
+ const message = replayableMessage(raw);
2696
+ if (!message) return false;
2697
+ target.push(message);
2698
+ trackMessageToolState(message, openToolUses);
2699
+ }
2700
+ return true;
2701
+ }
2702
+ function inheritsIntoFork(event) {
2703
+ switch (event.type) {
2704
+ case "session_start":
2705
+ case "session_resumed":
2706
+ case "session_forked":
2707
+ case "session_end":
2708
+ case "in_flight_start":
2709
+ case "in_flight_end":
2710
+ case "rewound":
2711
+ return false;
2712
+ // Parent snapshots describe mutations owned by the parent journal. A
2713
+ // child that shares the current workspace must not inherit authority to
2714
+ // rewind those historical side effects.
2715
+ case "file_snapshot":
2716
+ return false;
2717
+ default:
2718
+ return true;
2719
+ }
2720
+ }
2721
+
2722
+ // src/storage/session-store/fork-session.ts
2723
+ async function forkSession(host, id, opts = {}) {
2724
+ const parentEvents = await host.readRawEvents(id);
2725
+ let boundary = parentEvents.length - 1;
2726
+ let targetCheckpoint;
2727
+ if (opts.checkpointPromptIndex !== void 0) {
2728
+ boundary = -1;
2729
+ for (let i = 0; i < parentEvents.length; i++) {
2730
+ const event = parentEvents[i];
2731
+ if (event?.type === "checkpoint" && event.promptIndex === opts.checkpointPromptIndex) {
2732
+ boundary = i;
2733
+ targetCheckpoint = event;
2734
+ }
2735
+ }
2736
+ if (boundary === -1) {
2737
+ throw new Error(`Checkpoint ${opts.checkpointPromptIndex} not found in session "${id}"`);
2738
+ }
2739
+ }
2740
+ const parentPrefix = parentEvents.slice(0, boundary + 1);
2741
+ const workspaceCheckpoint = targetCheckpoint?.workspaceCheckpoint;
2742
+ const checkpointHash = createHash2("sha256").update(parentPrefix.map((event) => JSON.stringify(event)).join("\n") + "\n", "utf8").digest("hex");
2743
+ const inherited = parentPrefix.filter(inheritsIntoFork);
2744
+ const activeIdentity = [...parentEvents].reverse().find(
2745
+ (event) => event.type === "session_start" || event.type === "session_resumed"
2746
+ );
2747
+ const writer = await host.create({
2748
+ id: "",
2749
+ title: "",
2750
+ model: activeIdentity?.model,
2751
+ provider: activeIdentity?.provider
2752
+ });
2753
+ try {
2754
+ await writer.append({
2755
+ type: "session_forked",
2756
+ ts: (/* @__PURE__ */ new Date()).toISOString(),
2757
+ parentSessionId: id,
2758
+ parentCheckpointPromptIndex: opts.checkpointPromptIndex,
2759
+ parentCheckpointHash: checkpointHash,
2760
+ workspace: "shared-current",
2761
+ workspaceCheckpointHash: workspaceCheckpoint?.manifestHash
2762
+ });
2763
+ const batchSize = 250;
2764
+ for (let offset = 0; offset < inherited.length; offset += batchSize) {
2765
+ await writer.appendBatch(inherited.slice(offset, offset + batchSize));
2766
+ }
2767
+ await writer.flush();
2768
+ await writer.close();
2769
+ const data = await host.load(writer.id);
2770
+ return {
2771
+ id: writer.id,
2772
+ data,
2773
+ parentSessionId: id,
2774
+ checkpointPromptIndex: opts.checkpointPromptIndex,
2775
+ checkpointHash,
2776
+ workspace: "shared-current",
2777
+ workspaceCheckpoint
2778
+ };
2779
+ } catch (err) {
2780
+ await writer.close().catch(() => void 0);
2781
+ await host.delete(writer.id).catch(() => void 0);
2782
+ throw err;
2783
+ }
2784
+ }
2785
+
2786
+ // src/storage/session-store/load-cache.ts
2787
+ var LOAD_CACHE_MAX_ENTRIES = 50;
2788
+ var LOAD_CACHE_MAX_BYTES = 64 * 1024 * 1024;
2789
+ var SessionLoadCache = class {
2790
+ constructor(entries = /* @__PURE__ */ new Map()) {
2791
+ this.entries = entries;
2792
+ }
2793
+ entries;
2794
+ bytes = 0;
2795
+ clear(sessionId) {
2796
+ if (sessionId !== void 0) {
2797
+ this.delete(sessionId);
2798
+ return;
2799
+ }
2800
+ this.entries.clear();
2801
+ this.bytes = 0;
2802
+ }
2803
+ /**
2804
+ * A hit hands back fresh `messages` / `events` arrays over the cached
2805
+ * contents.
2806
+ *
2807
+ * The entry outlives every caller, and callers treat what they get as their
2808
+ * own: `resume()` passes `messages` straight into a live conversation, and
2809
+ * anything walking `events` may splice it. Returning the cached arrays
2810
+ * themselves let one caller's edit rewrite what the next one loads. The
2811
+ * elements are still shared — copying them would defeat the cache — so
2812
+ * entries remain read-only *contents* behind private containers.
2813
+ */
2814
+ getFresh(id, stat11, full) {
2815
+ const cached = this.entries.get(id);
2816
+ if (!cached || cached.mtimeMs !== stat11.mtimeMs || cached.size !== stat11.size) {
2817
+ return null;
2818
+ }
2819
+ this.entries.delete(id);
2820
+ this.entries.set(id, cached);
2821
+ return {
2822
+ ...cached.data,
2823
+ messages: full ? [...cached.data.messages] : [],
2824
+ events: [...cached.data.events]
2825
+ };
2826
+ }
2827
+ set(id, stat11, data) {
2828
+ this.delete(id);
2829
+ while (stat11.size <= LOAD_CACHE_MAX_BYTES && (this.entries.size >= LOAD_CACHE_MAX_ENTRIES || this.bytes + stat11.size > LOAD_CACHE_MAX_BYTES)) {
2830
+ const oldest = this.entries.keys().next().value;
2831
+ if (oldest === void 0) break;
2832
+ this.delete(oldest);
2833
+ }
2834
+ if (stat11.size <= LOAD_CACHE_MAX_BYTES) {
2835
+ this.entries.set(id, { mtimeMs: stat11.mtimeMs, size: stat11.size, data });
2836
+ this.bytes += stat11.size;
2837
+ }
2838
+ }
2839
+ delete(sessionId) {
2840
+ const cached = this.entries.get(sessionId);
2841
+ if (cached) this.bytes = Math.max(0, this.bytes - cached.size);
2842
+ this.entries.delete(sessionId);
2843
+ }
2844
+ };
2845
+
2846
+ // src/storage/session-store/load-session-data.ts
2847
+ import { createReadStream as createReadStream3 } from "node:fs";
2848
+ import { createInterface as createInterface3 } from "node:readline";
2849
+
2850
+ // src/storage/session-tool-call-ends.ts
2851
+ function extractToolCallEnds(events) {
2852
+ const out = [];
2853
+ for (const event of events) {
2854
+ if (event.type !== "tool_call_end") continue;
2855
+ out.push({
2856
+ name: event.name,
2857
+ id: event.id,
2858
+ durationMs: event.durationMs,
2859
+ ok: event.ok ?? true,
2860
+ outputBytes: event.outputBytes ?? event.outputSize,
2861
+ outputTokens: event.outputTokens,
2862
+ outputLines: event.outputLines
2863
+ });
2864
+ }
2865
+ return out;
2866
+ }
2867
+
2868
+ // src/storage/session-store/load-session-data.ts
2869
+ var DEFAULT_MAX_RETAINED_EVENT_BYTES = 96 * 1024 * 1024;
2870
+ var EVICT_TO_FRACTION = 0.9;
2871
+ var SNAPSHOT_ENVELOPE_BYTES = 256;
2872
+ async function loadSessionDataFromFile(params) {
2873
+ const events = [];
2874
+ const eventBytes = [];
2875
+ const eventBudget = params.maxRetainedEventBytes ?? DEFAULT_MAX_RETAINED_EVENT_BYTES;
2876
+ const evictTo = Math.floor(eventBudget * EVICT_TO_FRACTION);
2877
+ let retainedBytes = 0;
2878
+ let eventsDropped = 0;
2879
+ let lastSnapshotAbsIndex = -1;
2880
+ let sessionStartEvent;
2881
+ let sessionEndEvent;
2882
+ let sessionModel;
2883
+ let sessionProvider;
2884
+ let sessionPendingToolUses;
2885
+ let sessionForkedEvent;
2886
+ const messages = params.full ? [] : void 0;
2887
+ const openToolUses = params.full ? /* @__PURE__ */ new Set() : void 0;
2888
+ let exactJournalActive = false;
2889
+ let usage = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 };
2890
+ let lastSnapshot;
2891
+ const stream = createReadStream3(params.file, { encoding: "utf8" });
2892
+ const rl = createInterface3({ input: stream, crlfDelay: Infinity });
2893
+ try {
2894
+ for await (const line of rl) {
2895
+ if (!line.trim()) continue;
2896
+ try {
2897
+ const parsed = JSON.parse(line);
2898
+ if (!isSessionEventLike(parsed)) continue;
2899
+ const ev = scrubPersistedSessionEvent(parsed, params.secretScrubber);
2900
+ events.push(ev);
2901
+ eventBytes.push(line.length);
2902
+ retainedBytes += line.length;
2903
+ if (isSnapshotEvent(ev)) {
2904
+ if (lastSnapshot !== void 0) {
2905
+ stripSnapshotPayload(lastSnapshot);
2906
+ const slot = lastSnapshotAbsIndex - eventsDropped;
2907
+ if (slot >= 0 && slot < eventBytes.length) {
2908
+ const refund = eventBytes[slot] - SNAPSHOT_ENVELOPE_BYTES;
2909
+ if (refund > 0) {
2910
+ retainedBytes -= refund;
2911
+ eventBytes[slot] = SNAPSHOT_ENVELOPE_BYTES;
2912
+ }
2913
+ }
2914
+ }
2915
+ lastSnapshot = ev;
2916
+ lastSnapshotAbsIndex = eventsDropped + events.length - 1;
2917
+ }
2918
+ if (retainedBytes > eventBudget) {
2919
+ let cut = 0;
2920
+ while (cut < eventBytes.length && retainedBytes > evictTo) {
2921
+ retainedBytes -= eventBytes[cut];
2922
+ cut++;
2923
+ }
2924
+ events.splice(0, cut);
2925
+ eventBytes.splice(0, cut);
2926
+ eventsDropped += cut;
2927
+ }
2928
+ if (ev.type === "session_start" && !sessionStartEvent) {
2929
+ sessionStartEvent = ev;
2930
+ sessionModel = ev.model;
2931
+ sessionProvider = ev.provider;
2932
+ }
2933
+ if (ev.type === "session_end") {
2934
+ sessionEndEvent = ev;
2935
+ sessionPendingToolUses = ev.pendingToolUses;
2936
+ }
2937
+ if (ev.type === "session_forked" && !sessionForkedEvent) {
2938
+ sessionForkedEvent = ev;
2939
+ }
2940
+ if (params.full && messages !== void 0 && openToolUses !== void 0) {
2941
+ const replayState = replaySessionEvent({
2942
+ ev,
2943
+ id: params.id,
2944
+ events: params.events,
2945
+ messages,
2946
+ openToolUses,
2947
+ exactJournalActive,
2948
+ usage
2949
+ });
2950
+ exactJournalActive = replayState.exactJournalActive;
2951
+ usage = replayState.usage ?? usage;
2952
+ } else if (ev.type === "llm_response") {
2953
+ usage = accumulateUsage(usage, ev);
2954
+ }
2955
+ } catch {
2956
+ }
2957
+ }
2958
+ } finally {
2959
+ rl.close();
2960
+ stream.close();
2961
+ }
2962
+ let finalMessages = [];
2963
+ if (params.full && messages !== void 0 && openToolUses !== void 0) {
2964
+ if (openToolUses.size > 0) {
2965
+ params.events?.emit("session.damaged", {
2966
+ sessionId: params.id,
2967
+ detail: `${openToolUses.size} tool_use blocks without matching results - replay repaired`
2968
+ });
2969
+ }
2970
+ const repaired = repairToolUseAdjacency(messages);
2971
+ if (repaired.report.changed) {
2972
+ params.events?.emit("session.damaged", {
2973
+ sessionId: params.id,
2974
+ detail: `Repaired replay adjacency: removed ${repaired.report.removedToolUses.length} tool_use, ${repaired.report.removedToolResults.length} tool_result, ${repaired.report.removedMessages} empty messages`
2975
+ });
2976
+ }
2977
+ finalMessages = repaired.messages;
2978
+ }
2979
+ const meta = {
2980
+ id: params.id,
2981
+ startedAt: sessionStartEvent?.ts ?? (/* @__PURE__ */ new Date(0)).toISOString(),
2982
+ endedAt: sessionEndEvent?.ts,
2983
+ model: sessionModel,
2984
+ provider: sessionProvider,
2985
+ pendingToolUses: sessionPendingToolUses,
2986
+ forkedFrom: sessionForkedEvent ? {
2987
+ sessionId: sessionForkedEvent.parentSessionId,
2988
+ checkpointPromptIndex: sessionForkedEvent.parentCheckpointPromptIndex,
2989
+ checkpointHash: sessionForkedEvent.parentCheckpointHash,
2990
+ workspace: sessionForkedEvent.workspace,
2991
+ workspaceCheckpointHash: sessionForkedEvent.workspaceCheckpointHash
2992
+ } : void 0
2993
+ };
2994
+ const toolCallEnds = extractToolCallEnds(events);
2995
+ const pendingToolUseCount = openToolUses && openToolUses.size > 0 ? openToolUses.size : void 0;
2996
+ return {
2997
+ metadata: meta,
2998
+ events,
2999
+ messages: finalMessages,
3000
+ usage,
3001
+ toolCallEnds,
3002
+ ...pendingToolUseCount !== void 0 ? { pendingToolUseCount } : {},
3003
+ ...eventsDropped > 0 ? { eventsDropped } : {}
3004
+ };
3005
+ }
3006
+ function isSnapshotEvent(event) {
3007
+ return event.type === "messages_replaced" || event.type === "context_snapshot";
3008
+ }
3009
+ function stripSnapshotPayload(event) {
3010
+ if (event.messages.length === 0) return;
3011
+ event.messagesOmitted = event.messages.length;
3012
+ event.messages = [];
3013
+ }
3014
+ function isStrippedSnapshot(event) {
3015
+ return event.messages.length === 0 && typeof event.messagesOmitted === "number" && event.messagesOmitted > 0;
3016
+ }
3017
+ function isSessionEventLike(value) {
3018
+ return value !== null && typeof value === "object" && typeof value.type === "string" && typeof value.ts === "string";
3019
+ }
3020
+ function replaySessionEvent(params) {
3021
+ const { ev, messages, openToolUses } = params;
3022
+ let exactJournalActive = params.exactJournalActive;
3023
+ if (ev.type === "message_appended" && ev.version === 1) {
3024
+ const message = replayableMessage(ev.message, ev.ts);
3025
+ if (message) {
3026
+ if (!exactJournalActive) {
3027
+ messages.length = 0;
3028
+ openToolUses.clear();
3029
+ exactJournalActive = true;
3030
+ }
3031
+ messages.push(message);
3032
+ trackMessageToolState(message, openToolUses);
3033
+ } else {
3034
+ emitDamaged(params, "Ignored malformed message_appended event");
3035
+ }
3036
+ } else if (ev.type === "message_updated" && ev.version === 1) {
3037
+ const message = replayableMessage(ev.message, ev.ts);
3038
+ if (message && exactJournalActive && ev.index >= 0 && ev.index < messages.length) {
3039
+ messages[ev.index] = message;
3040
+ openToolUses.clear();
3041
+ for (const current of messages) trackMessageToolState(current, openToolUses);
3042
+ } else {
3043
+ emitDamaged(params, `Ignored malformed message_updated event at index ${ev.index}`);
3044
+ }
3045
+ } else if (ev.type === "messages_replaced" && ev.version === 1) {
3046
+ if (isStrippedSnapshot(ev)) {
3047
+ emitDamaged(
3048
+ params,
3049
+ `Ignored messages_replaced event whose payload was stripped before persistence (${String(ev.messagesOmitted)} messages)`
3050
+ );
3051
+ } else if (applyContextSnapshot(messages, openToolUses, ev.messages)) {
3052
+ exactJournalActive = true;
3053
+ } else {
3054
+ emitDamaged(params, "Ignored malformed messages_replaced event");
3055
+ }
3056
+ } else if (ev.type === "messages_dropped" && ev.version === 1) {
3057
+ if (exactJournalActive && Number.isInteger(ev.count) && ev.count > 0) {
3058
+ messages.splice(0, Math.min(ev.count, messages.length));
3059
+ openToolUses.clear();
3060
+ for (const current of messages) trackMessageToolState(current, openToolUses);
3061
+ } else if (!exactJournalActive) {
3062
+ emitDamaged(params, "Ignored messages_dropped event outside the exact journal");
3063
+ } else {
3064
+ emitDamaged(params, `Ignored malformed messages_dropped event (count ${String(ev.count)})`);
3065
+ }
3066
+ } else if (ev.type === "context_snapshot") {
3067
+ if (isStrippedSnapshot(ev)) {
3068
+ emitDamaged(
3069
+ params,
3070
+ `Ignored context_snapshot event whose payload was stripped before persistence (${String(ev.messagesOmitted)} messages)`
3071
+ );
3072
+ } else if (!applyContextSnapshot(messages, openToolUses, ev.messages)) {
3073
+ emitDamaged(params, "Ignored malformed context_snapshot event");
3074
+ }
3075
+ } else if (ev.type === "messages_replaced" || ev.type === "message_appended" || ev.type === "message_updated" || ev.type === "messages_dropped") {
3076
+ emitDamaged(params, `Ignored ${ev.type} event with unsupported version`);
3077
+ } else if (!exactJournalActive && ev.type === "user_input") {
3078
+ openToolUses.clear();
3079
+ messages.push({ role: "user", content: ev.content, ts: ev.ts });
3080
+ } else if (ev.type === "llm_response") {
3081
+ if (!exactJournalActive) {
3082
+ messages.push({ role: "assistant", content: ev.content, ts: ev.ts });
3083
+ for (const b of ev.content) {
3084
+ if (b.type === "tool_use") openToolUses.add(b.id);
3085
+ }
3086
+ }
3087
+ return { exactJournalActive, usage: accumulateUsage(params.usage, ev) };
3088
+ } else if (!exactJournalActive && ev.type === "tool_result") {
3089
+ if (!openToolUses.has(ev.id)) {
3090
+ emitDamaged(params, `Orphan tool_result "${ev.id}" has no matching tool_use`);
3091
+ return { exactJournalActive };
3092
+ }
3093
+ openToolUses.delete(ev.id);
3094
+ const resultBlock = {
3095
+ type: "tool_result",
3096
+ tool_use_id: ev.id,
3097
+ content: typeof ev.content === "string" ? ev.content : JSON.stringify(ev.content),
3098
+ is_error: ev.isError
3099
+ };
3100
+ const last = messages[messages.length - 1];
3101
+ const lastIsToolResultUser = last?.role === "user" && Array.isArray(last.content) && last.content.every((b) => b.type === "tool_result");
3102
+ if (lastIsToolResultUser && Array.isArray(last.content)) {
3103
+ last.content.push(resultBlock);
3104
+ } else {
3105
+ messages.push({ role: "user", content: [resultBlock], ts: ev.ts });
3106
+ }
3107
+ }
3108
+ return { exactJournalActive };
3109
+ }
3110
+ function accumulateUsage(usage, ev) {
3111
+ return {
3112
+ input: usage.input + (ev.usage.input ?? 0),
3113
+ output: usage.output + (ev.usage.output ?? 0),
3114
+ cacheRead: (usage.cacheRead ?? 0) + (ev.usage.cacheRead ?? 0),
3115
+ cacheWrite: (usage.cacheWrite ?? 0) + (ev.usage.cacheWrite ?? 0)
3116
+ };
3117
+ }
3118
+ function emitDamaged(params, detail) {
3119
+ params.events?.emit("session.damaged", {
3120
+ sessionId: params.id,
3121
+ detail
3122
+ });
3123
+ }
3124
+
3125
+ // src/storage/session-store/prune-helpers.ts
3126
+ import * as fsp9 from "node:fs/promises";
3127
+ import * as path8 from "node:path";
3128
+ function isPrunableSessionJsonl(name) {
3129
+ return isSessionTranscriptFileName(name);
3130
+ }
3131
+ async function pruneSessionFiles(storeDir, maxAgeDays, deleteSession) {
3132
+ const cutoff = Date.now() - maxAgeDays * 864e5;
3133
+ let deleted = 0;
3134
+ const pruneFile = async (dir, name, prefix) => {
3135
+ const jsonlPath = path8.join(dir, name);
3136
+ try {
3137
+ const stat11 = await fsp9.stat(jsonlPath);
3138
+ if (stat11.mtimeMs >= cutoff) return;
3139
+ } catch {
3140
+ return;
3141
+ }
3142
+ const base = name.replace(/\.jsonl$/, "");
3143
+ const id = prefix ? `${prefix}/${base}` : base;
3144
+ await deleteSession(id);
3145
+ deleted++;
3146
+ };
3147
+ const entries = await fsp9.readdir(storeDir, { withFileTypes: true }).catch(() => []);
3148
+ for (const entry of entries) {
3149
+ if (entry.isFile()) {
3150
+ if (isPrunableSessionJsonl(entry.name)) await pruneFile(storeDir, entry.name, "");
3151
+ continue;
3152
+ }
3153
+ if (!entry.isDirectory()) continue;
3154
+ const dateDir = path8.join(storeDir, entry.name);
3155
+ const files = await fsp9.readdir(dateDir, { withFileTypes: true }).catch(() => []);
3156
+ for (const file of files) {
3157
+ if (!file.isFile() || !isPrunableSessionJsonl(file.name)) continue;
3158
+ await pruneFile(dateDir, file.name, entry.name);
3159
+ }
3160
+ }
3161
+ for (const entry of entries) {
3162
+ if (!entry.isDirectory()) continue;
3163
+ const dateDir = path8.join(storeDir, entry.name);
3164
+ try {
3165
+ const remaining = await fsp9.readdir(dateDir);
3166
+ if (remaining.length === 0) {
3167
+ await fsp9.rmdir(dateDir).catch(() => void 0);
3168
+ }
3169
+ } catch {
3170
+ }
3171
+ }
3172
+ return deleted;
3173
+ }
3174
+
3175
+ // src/storage/session-store/rename-session.ts
3176
+ import * as fsp10 from "node:fs/promises";
3177
+ async function executeRenameSession(params) {
3178
+ const {
3179
+ id,
3180
+ name,
3181
+ manifest,
3182
+ jsonlPath,
3183
+ events,
3184
+ secretScrubber,
3185
+ readSummaryManifest,
3186
+ summaryFor,
3187
+ appendToIndexStrict: appendToIndexStrict2
3188
+ } = params;
3189
+ const trimmed = sessionContentText(secretScrubber.scrub(name));
3190
+ try {
3191
+ await fsp10.stat(jsonlPath);
3192
+ } catch (err) {
3193
+ const code = err?.code;
3194
+ if (code === "ENOENT") {
3195
+ throw new Error(`Session not found: ${id}`);
3196
+ }
3197
+ throw err;
3198
+ }
3199
+ const t0 = Date.now();
3200
+ let outcome = "success";
3201
+ let errorMsg;
3202
+ let updated;
3203
+ try {
3204
+ updated = await withFileLock(manifest, async () => {
3205
+ const summary = await readSummaryManifest(id) ?? await summaryFor(id);
3206
+ const { name: _drop, ...rest } = summary;
3207
+ const next = trimmed ? { ...rest, name: trimmed } : rest;
3208
+ await atomicWrite(manifest, JSON.stringify(next), { mode: 384 });
3209
+ try {
3210
+ await appendToIndexStrict2(next);
3211
+ } catch (err) {
3212
+ await atomicWrite(manifest, JSON.stringify(summary), { mode: 384 });
3213
+ throw err;
3214
+ }
3215
+ return next;
3216
+ });
3217
+ } catch (err) {
3218
+ outcome = "failure";
3219
+ errorMsg = toErrorMessage(err);
3220
+ emitSessionStoreError(events, id, manifest, "rename", errorMsg, false);
3221
+ throw err;
3222
+ } finally {
3223
+ emitSessionStoreWrite(
3224
+ events,
3225
+ id,
3226
+ manifest,
3227
+ "rename",
3228
+ outcome,
3229
+ Date.now() - t0,
3230
+ void 0,
3231
+ errorMsg
3232
+ );
3233
+ }
3234
+ return updated;
3235
+ }
3236
+
3237
+ // src/storage/session-store/resume-session.ts
3238
+ import * as fsp12 from "node:fs/promises";
3239
+ import * as path10 from "node:path";
3240
+
3241
+ // src/storage/session-resume-validation.ts
3242
+ import { createHash as createHash3 } from "node:crypto";
3243
+ import * as fsp11 from "node:fs/promises";
3244
+ import * as path9 from "node:path";
3245
+ var MAX_REVALIDATE_BYTES = 5 * 1024 * 1024;
3246
+ var VALIDATION_CONCURRENCY = 8;
3247
+ var NOTICE_PATH_LIMIT = 20;
3248
+ function isInside2(root, target) {
3249
+ const relative3 = path9.relative(root, target);
3250
+ return relative3 === "" || !relative3.startsWith("..") && !path9.isAbsolute(relative3);
3251
+ }
3252
+ function errno(err) {
3253
+ return err && typeof err === "object" && "code" in err ? String(err.code) : void 0;
3254
+ }
3255
+ function latestObservations(events, projectRoot) {
3256
+ const latest = /* @__PURE__ */ new Map();
3257
+ for (const event of events) {
3258
+ if (event.type !== "file_observation" || typeof event.path !== "string" || event.path.length === 0 || typeof event.hash !== "string" || !/^[a-f\d]{64}$/i.test(event.hash)) {
3259
+ continue;
3260
+ }
3261
+ const normalized = path9.resolve(projectRoot, event.path);
3262
+ latest.set(normalized, {
3263
+ path: normalized,
3264
+ hash: event.hash.toLowerCase(),
3265
+ ts: event.ts
3266
+ });
3267
+ }
3268
+ return [...latest.values()];
3269
+ }
3270
+ async function validateOne(observation, lexicalRoot, realRoot) {
3271
+ const base = {
3272
+ path: observation.path,
3273
+ observedAt: observation.ts,
3274
+ expectedHash: observation.hash
3275
+ };
3276
+ if (!isInside2(lexicalRoot, observation.path)) {
3277
+ return {
3278
+ ...base,
3279
+ status: "outside_project",
3280
+ detail: "Recorded path is outside the active project root."
3281
+ };
3282
+ }
3283
+ let realFile;
3284
+ try {
3285
+ realFile = await fsp11.realpath(observation.path);
3286
+ } catch (err) {
3287
+ if (errno(err) === "ENOENT") return { ...base, status: "deleted" };
3288
+ return { ...base, status: "unreadable", detail: toErrorMessage(err) };
3289
+ }
3290
+ if (!isInside2(realRoot, realFile)) {
3291
+ return {
3292
+ ...base,
3293
+ status: "outside_project",
3294
+ detail: "Recorded path resolves through a symlink outside the active project root."
3295
+ };
3296
+ }
3297
+ try {
3298
+ const stat11 = await fsp11.stat(realFile);
3299
+ if (!stat11.isFile()) {
3300
+ return { ...base, status: "unreadable", detail: "Path is no longer a regular file." };
3301
+ }
3302
+ if (stat11.size > MAX_REVALIDATE_BYTES) {
3303
+ return {
3304
+ ...base,
3305
+ status: "unreadable",
3306
+ detail: `File now exceeds the ${MAX_REVALIDATE_BYTES}-byte validation limit.`
3307
+ };
3308
+ }
3309
+ const content = await fsp11.readFile(realFile, "utf8");
3310
+ const actualHash = createHash3("sha256").update(content, "utf8").digest("hex");
3311
+ if (actualHash === observation.hash) return null;
3312
+ return { ...base, status: "modified", actualHash };
3313
+ } catch (err) {
3314
+ if (errno(err) === "ENOENT") return { ...base, status: "deleted" };
3315
+ return { ...base, status: "unreadable", detail: toErrorMessage(err) };
3316
+ }
3317
+ }
3318
+ async function validateResumeFileObservations(events, projectRoot) {
3319
+ const lexicalRoot = path9.resolve(projectRoot);
3320
+ const realRoot = await fsp11.realpath(lexicalRoot).catch(() => lexicalRoot);
3321
+ const observations = latestObservations(events, lexicalRoot);
3322
+ const results = await mapWithConcurrency(
3323
+ observations,
3324
+ VALIDATION_CONCURRENCY,
3325
+ (observation) => validateOne(observation, lexicalRoot, realRoot)
3326
+ );
3327
+ return {
3328
+ checkedAt: (/* @__PURE__ */ new Date()).toISOString(),
3329
+ checkedFileCount: observations.length,
3330
+ staleFiles: results.filter((entry) => entry !== null)
3331
+ };
3332
+ }
3333
+ var RESUME_NOTICE_HEADERS = [
3334
+ "[SESSION RESUME FILE VALIDATION]",
3335
+ "[SESSION RESUME INTERRUPTED WORK]",
3336
+ "[SESSION RESUME CRASH RECOVERY]"
3337
+ ];
3338
+ function formatCrashRecoveryNotice(interruptedTools, lastContext) {
3339
+ if (interruptedTools.length === 0) return null;
3340
+ const plural = interruptedTools.length === 1 ? "call was" : "calls were";
3341
+ const lines = [
3342
+ "[SESSION RESUME CRASH RECOVERY]",
3343
+ `The previous run stopped mid-iteration${lastContext ? ` while: ${lastContext}` : ""} \u2014 ${interruptedTools.length} tool ${plural} left without a recorded result.`,
3344
+ "Those interrupted tool calls were removed from the restored conversation and were NOT re-executed; their workspace side effects were NOT rolled back."
3345
+ ];
3346
+ for (const tool of interruptedTools.slice(0, NOTICE_PATH_LIMIT)) {
3347
+ lines.push(`- ${tool.name}${tool.argsSummary ? ` (${tool.argsSummary})` : ""}`);
3348
+ }
3349
+ return lines.join("\n");
3350
+ }
3351
+ function isResumeNoticeMessage(message) {
3352
+ if (message.role !== "system" || typeof message.content !== "string") return false;
3353
+ return RESUME_NOTICE_HEADERS.some((header) => message.content === header || message.content.startsWith(`${header}
3354
+ `));
3355
+ }
3356
+ function formatResumeValidationNotice(validation, projectRoot) {
3357
+ if (validation.staleFiles.length === 0) return null;
3358
+ const root = path9.resolve(projectRoot);
3359
+ const shown = validation.staleFiles.slice(0, NOTICE_PATH_LIMIT).map((entry) => {
3360
+ const relative3 = path9.relative(root, entry.path);
3361
+ const display = isInside2(root, entry.path) ? relative3 || "." : entry.path;
3362
+ return `- ${JSON.stringify(display)} [${entry.status}]`;
3363
+ });
3364
+ const omitted = validation.staleFiles.length - shown.length;
3365
+ if (omitted > 0) shown.push(`- ... and ${omitted} more stale path(s)`);
3366
+ return [
3367
+ "[SESSION RESUME FILE VALIDATION]",
3368
+ `Journal hash validation found ${validation.staleFiles.length} previously observed file(s) whose current state cannot be trusted.`,
3369
+ "Prior tool results and reasoning based on these paths are stale. Re-read each relevant file before relying on it or editing it.",
3370
+ ...shown
3371
+ ].join("\n");
3372
+ }
3373
+ function formatInterruptedToolNotice(pendingToolUseCount) {
3374
+ if (!Number.isFinite(pendingToolUseCount) || pendingToolUseCount <= 0) return null;
3375
+ const plural = pendingToolUseCount === 1 ? "tool call was" : "tool calls were";
3376
+ return [
3377
+ "[SESSION RESUME INTERRUPTED WORK]",
3378
+ `The previous run left ${pendingToolUseCount} ${plural} in flight \u2014 no result was recorded before it stopped (crash or interrupt).`,
3379
+ "Those interrupted tool calls were removed from the restored conversation and were NOT re-executed. Re-run the work if it still needs doing."
3380
+ ].join("\n");
3381
+ }
3382
+
3383
+ // src/storage/session-store/summary-builder.ts
3384
+ import { createReadStream as createReadStream4 } from "node:fs";
3385
+ import { createInterface as createInterface4 } from "node:readline";
3386
+ async function summarizeSessionFile(opts) {
3387
+ return summarizeSessionEventSequence({
3388
+ id: opts.id,
3389
+ events: iterateSessionEvents(opts.file, opts.secretScrubber),
3390
+ mtime: opts.mtime
3391
+ });
3392
+ }
3393
+ async function summarizeSessionEvents(opts) {
3394
+ return summarizeSessionEventSequence(opts);
3395
+ }
3396
+ async function summarizeSessionEventSequence(opts) {
3397
+ const { id, events, mtime } = opts;
3398
+ try {
3399
+ let title = "(empty session)";
3400
+ let startedAt = (/* @__PURE__ */ new Date(0)).toISOString();
3401
+ let endedAt;
3402
+ let model = "unknown";
3403
+ let provider = "unknown";
3404
+ let tokenIn = 0;
3405
+ let tokenOut = 0;
3406
+ let iterationCount = 0;
3407
+ let toolCallCount = 0;
3408
+ let toolErrorCount = 0;
3409
+ let fileChangeCount = 0;
3410
+ let messageCount = 0;
3411
+ let lastUserMessage;
3412
+ let lastUserMessageMs = Number.NEGATIVE_INFINITY;
3413
+ let lastActivityAt = startedAt;
3414
+ let lastActivityMs = Date.parse(startedAt);
3415
+ if (lastActivityMs === 0 || !Number.isFinite(lastActivityMs)) {
3416
+ const mtimeMs = Date.parse(mtime);
3417
+ if (Number.isFinite(mtimeMs) && mtimeMs > 0) {
3418
+ lastActivityAt = mtime;
3419
+ lastActivityMs = mtimeMs;
3420
+ }
3421
+ }
3422
+ const toolBreakdown = {};
3423
+ let outcome;
3424
+ let lastEventType;
3425
+ let hasError = false;
3426
+ let sawStart = false;
3427
+ for await (const e of events) {
3428
+ lastEventType = e.type;
3429
+ const eventActivityMs = Date.parse(e.ts);
3430
+ if (Number.isFinite(eventActivityMs) && (!Number.isFinite(lastActivityMs) || eventActivityMs > lastActivityMs)) {
3431
+ lastActivityAt = e.ts;
3432
+ lastActivityMs = eventActivityMs;
3433
+ }
3434
+ if (e.type === "session_start") {
3435
+ if (!sawStart) {
3436
+ sawStart = true;
3437
+ startedAt = e.ts;
3438
+ model = e.model ?? "unknown";
3439
+ provider = e.provider ?? "unknown";
3440
+ }
3441
+ } else if (e.type === "session_resumed") {
3442
+ if (!sawStart) {
3443
+ sawStart = true;
3444
+ startedAt = e.ts;
3445
+ }
3446
+ if (e.model) model = e.model;
3447
+ if (e.provider) provider = e.provider;
3448
+ } else if (e.type === "session_end") {
3449
+ endedAt = e.ts;
3450
+ } else if (e.type === "user_input") {
3451
+ if (title === "(empty session)") title = userInputTitle(e.content);
3452
+ const userMessageMs = Date.parse(e.ts);
3453
+ if (!Number.isFinite(userMessageMs) || userMessageMs >= lastUserMessageMs) {
3454
+ lastUserMessage = sessionContentPreview(e.content);
3455
+ if (Number.isFinite(userMessageMs)) lastUserMessageMs = userMessageMs;
3456
+ }
3457
+ messageCount++;
3458
+ } else if (e.type === "message_appended" && e.version === 1 && e.message) {
3459
+ messageCount++;
3460
+ if (e.message.role === "user") {
3461
+ if (title === "(empty session)") title = userInputTitle(e.message.content);
3462
+ const userMessageMs = Date.parse(e.ts);
3463
+ if (!Number.isFinite(userMessageMs) || userMessageMs >= lastUserMessageMs) {
3464
+ lastUserMessage = sessionContentPreview(e.message.content);
3465
+ if (Number.isFinite(userMessageMs)) lastUserMessageMs = userMessageMs;
3466
+ }
3467
+ }
3468
+ } else if (e.type === "llm_response") {
3469
+ messageCount++;
3470
+ tokenIn += e.usage.input ?? 0;
3471
+ tokenOut += e.usage.output ?? 0;
3472
+ } else if (e.type === "in_flight_start") iterationCount++;
3473
+ else if (e.type === "tool_call_start") {
3474
+ toolCallCount++;
3475
+ toolBreakdown[e.name] = (toolBreakdown[e.name] ?? 0) + 1;
3476
+ } else if (e.type === "tool_result" && e.isError) toolErrorCount++;
3477
+ else if (e.type === "file_snapshot") fileChangeCount += e.files.length;
3478
+ else if (e.type === "error" || e.type === "provider_error") hasError = true;
3479
+ }
3480
+ if (lastEventType === "session_end") {
3481
+ outcome = "completed";
3482
+ } else if (lastEventType === "in_flight_start") {
3483
+ outcome = "aborted";
3484
+ } else if (hasError) {
3485
+ outcome = "error";
3486
+ }
3487
+ return {
3488
+ id,
3489
+ title,
3490
+ startedAt,
3491
+ endedAt,
3492
+ model,
3493
+ provider,
3494
+ tokenTotal: tokenIn + tokenOut,
3495
+ lastActivityAt,
3496
+ messageCount,
3497
+ ...lastUserMessage !== void 0 ? { lastUserMessage } : {},
3498
+ iterationCount: iterationCount > 0 ? iterationCount : void 0,
3499
+ toolCallCount: toolCallCount > 0 ? toolCallCount : void 0,
3500
+ toolErrorCount: toolErrorCount > 0 ? toolErrorCount : void 0,
3501
+ fileChangeCount: fileChangeCount > 0 ? fileChangeCount : void 0,
3502
+ toolBreakdown: Object.keys(toolBreakdown).length > 0 ? toolBreakdown : {},
3503
+ outcome
3504
+ };
3505
+ } catch {
3506
+ return {
3507
+ id,
3508
+ title: "(damaged)",
3509
+ startedAt: mtime,
3510
+ model: "unknown",
3511
+ provider: "unknown",
3512
+ tokenTotal: 0,
3513
+ lastActivityAt: mtime,
3514
+ messageCount: 0
3515
+ };
3516
+ }
3517
+ }
3518
+ async function* iterateSessionEvents(file, secretScrubber) {
3519
+ const stream = createReadStream4(file, { encoding: "utf8" });
3520
+ const lines = createInterface4({ input: stream, crlfDelay: Infinity });
3521
+ try {
3522
+ for await (const line of lines) {
3523
+ if (!line.trim()) continue;
3524
+ try {
3525
+ const parsed = JSON.parse(line);
3526
+ if (parsed !== null && typeof parsed === "object" && typeof parsed.type === "string" && typeof parsed.ts === "string") {
3527
+ yield scrubPersistedSessionEvent(parsed, secretScrubber);
3528
+ }
3529
+ } catch {
3530
+ }
3531
+ }
3532
+ } finally {
3533
+ lines.close();
3534
+ stream.destroy();
3535
+ }
3536
+ }
3537
+
3538
+ // src/storage/session-store/resume-session.ts
3539
+ async function executeResumeSession(params) {
3540
+ const {
3541
+ canonicalId,
3542
+ file,
3543
+ projectRoot,
3544
+ events,
3545
+ secretScrubber,
3546
+ checkpointCas,
3547
+ onAppend,
3548
+ onAppendBatch,
3549
+ load,
3550
+ readSummaryManifest,
3551
+ searchEvents,
3552
+ persistCatalogSummary,
3553
+ logWarn,
3554
+ sessionsDir
3555
+ } = params;
3556
+ const t0 = Date.now();
3557
+ const data = await load(canonicalId);
3558
+ const persistedSummary = await readSummaryManifest(canonicalId);
3559
+ const fileStat = await fsp12.stat(file);
3560
+ const eventsDropped = data.eventsDropped ?? 0;
3561
+ const derivedSummary = eventsDropped > 0 ? await summarizeSessionFile({
3562
+ id: canonicalId,
3563
+ file,
3564
+ mtime: fileStat.mtime.toISOString(),
3565
+ secretScrubber
3566
+ }) : await summarizeSessionEvents({
3567
+ id: canonicalId,
3568
+ events: data.events,
3569
+ mtime: fileStat.mtime.toISOString()
3570
+ });
3571
+ const initialSummary = {
3572
+ ...derivedSummary,
3573
+ ...persistedSummary?.name !== void 0 ? { name: persistedSummary.name } : {}
3574
+ };
3575
+ let recoveryPlan = SessionRecovery.buildRecoveryPlan(data.events, canonicalId);
3576
+ if (eventsDropped > 0 && sessionsDir) {
3577
+ const fromDisk = await new SessionRecovery(sessionsDir).recover(canonicalId);
3578
+ if (fromDisk) recoveryPlan = fromDisk;
3579
+ }
3580
+ const interruptedTools = recoveryPlan.stale ? extractInterruptedTools(recoveryPlan) : [];
3581
+ const synthesizedResults = interruptedTools.flatMap(
3582
+ (tool) => typeof tool.id === "string" ? [
3583
+ {
3584
+ type: "tool_result",
3585
+ ts: (/* @__PURE__ */ new Date()).toISOString(),
3586
+ id: tool.id,
3587
+ content: "[interrupted] No result was recorded \u2014 the previous process stopped before this call completed. Re-run it if still needed.",
3588
+ isError: true
3589
+ }
3590
+ ] : []
3591
+ );
3592
+ if (synthesizedResults.length > 0) data.events.push(...synthesizedResults);
3593
+ const noticeMessages = [];
3594
+ let resumeValidation;
3595
+ if (projectRoot) {
3596
+ try {
3597
+ const validationEvents = eventsDropped > 0 ? (await searchEvents(
3598
+ canonicalId,
3599
+ (ev) => ev.type === "file_observation"
3600
+ )).map((h) => h.event) : data.events;
3601
+ resumeValidation = await validateResumeFileObservations(validationEvents, projectRoot);
3602
+ const notice = formatResumeValidationNotice(resumeValidation, projectRoot);
3603
+ if (notice) {
3604
+ noticeMessages.push({
3605
+ role: "system",
3606
+ content: notice,
3607
+ ts: resumeValidation.checkedAt
3608
+ });
3609
+ }
3610
+ } catch (err) {
3611
+ emitSessionStoreError(
3612
+ events,
3613
+ canonicalId,
3614
+ file,
3615
+ "resume_validation",
3616
+ toErrorMessage(err),
3617
+ true
3618
+ );
3619
+ }
3620
+ }
3621
+ const interruptedNotice = recoveryPlan.stale ? null : formatInterruptedToolNotice(data.pendingToolUseCount ?? 0);
3622
+ if (interruptedNotice) {
3623
+ noticeMessages.push({
3624
+ role: "system",
3625
+ content: interruptedNotice,
3626
+ ts: (/* @__PURE__ */ new Date()).toISOString()
3627
+ });
3628
+ }
3629
+ const crashNotice = formatCrashRecoveryNotice(interruptedTools, recoveryPlan.context);
3630
+ if (crashNotice) {
3631
+ noticeMessages.push({
3632
+ role: "system",
3633
+ content: crashNotice,
3634
+ ts: (/* @__PURE__ */ new Date()).toISOString()
3635
+ });
3636
+ }
3637
+ const carriedMessages = data.messages.filter((message) => !isResumeNoticeMessage(message));
3638
+ const resumedData = {
3639
+ ...data,
3640
+ ...resumeValidation ? { resumeValidation } : {},
3641
+ messages: [...carriedMessages, ...noticeMessages]
3642
+ };
3643
+ let handle;
3644
+ try {
3645
+ handle = await openSessionForAppend(file);
3646
+ } catch (err) {
3647
+ emitSessionStoreError(events, canonicalId, file, "resume", toErrorMessage(err), false);
3648
+ throw new Error(
3649
+ `Failed to open session "${canonicalId}" for append: ${toErrorMessage(err)}`,
3650
+ { cause: err }
3651
+ );
3652
+ }
3653
+ try {
3654
+ const writer = new FileSessionWriter(
3655
+ canonicalId,
3656
+ handle,
3657
+ (/* @__PURE__ */ new Date()).toISOString(),
3658
+ {
3659
+ id: canonicalId,
3660
+ model: data.metadata.model,
3661
+ provider: data.metadata.provider
3662
+ },
3663
+ events,
3664
+ {
3665
+ resumed: true,
3666
+ initialSummary,
3667
+ dir: path10.dirname(file),
3668
+ filePath: file,
3669
+ secretScrubber,
3670
+ checkpointCas,
3671
+ onAppend,
3672
+ onAppendBatch,
3673
+ resolveName: async () => {
3674
+ const current = await readSummaryManifest(canonicalId);
3675
+ if (!current) return null;
3676
+ return current.name === void 0 ? {} : { name: sessionContentText(secretScrubber.scrub(current.name)) };
3677
+ },
3678
+ onClose: (s) => persistCatalogSummary(s),
3679
+ // Resumed sessions checkpoint their index/catalog metadata mid-flight
3680
+ // too, so a kill during a resumed session still leaves fresh listing
3681
+ // state behind.
3682
+ onMetadataCheckpoint: (s) => persistCatalogSummary(s)
3683
+ }
3684
+ );
3685
+ if (synthesizedResults.length > 0) {
3686
+ await writer.appendBatch(synthesizedResults);
3687
+ await writer.flush();
3688
+ }
3689
+ if (recoveryPlan.stale) {
3690
+ await writer.clearInFlightMarker("recovered");
3691
+ await writer.flush();
3692
+ }
3693
+ emitSessionStoreWrite(events, canonicalId, file, "resume", "success", Date.now() - t0);
3694
+ return { writer, data: resumedData };
3695
+ } catch (err) {
3696
+ await handle.close().catch(
3697
+ (e) => logWarn("Session handle close failed", {
3698
+ event: "session_store.handle_close_failed",
3699
+ message: e instanceof Error ? e.message : String(e)
3700
+ })
3701
+ );
3702
+ emitSessionStoreError(events, canonicalId, file, "resume", toErrorMessage(err), true);
3703
+ throw err;
3704
+ }
3705
+ }
3706
+ async function openSessionForAppend(file) {
3707
+ const handle = await fsp12.open(file, "a+", 384);
3708
+ try {
3709
+ const stat11 = await handle.stat();
3710
+ if (stat11.size > 0) {
3711
+ const tail = Buffer.allocUnsafe(1);
3712
+ const { bytesRead } = await handle.read(tail, 0, 1, stat11.size - 1);
3713
+ if (bytesRead === 1 && tail[0] !== 10) {
3714
+ await handle.appendFile("\n", "utf8");
3715
+ }
3716
+ }
3717
+ return handle;
3718
+ } catch (err) {
3719
+ await handle.close().catch(() => void 0);
3720
+ throw err;
3721
+ }
3722
+ }
3723
+
3724
+ // src/storage/session-store/search-events.ts
3725
+ import * as fsp13 from "node:fs/promises";
3726
+ async function searchSessionEvents(args) {
3727
+ const { file, secretScrubber, predicate, limit, signal } = args;
3728
+ const out = [];
3729
+ let stat11;
3730
+ try {
3731
+ stat11 = await fsp13.stat(file);
3732
+ } catch (err) {
3733
+ if (err.code === "ENOENT") return [];
3734
+ throw err;
3735
+ }
3736
+ if (stat11.size === 0) return [];
3737
+ let fh;
3738
+ try {
3739
+ fh = await fsp13.open(file, "r");
3740
+ const CHUNK = 64 * 1024;
3741
+ const buf = Buffer.alloc(CHUNK);
3742
+ let leftover = "";
3743
+ let eventIndex = 0;
3744
+ for (let position = 0; ; position += buf.byteLength) {
3745
+ if (signal?.aborted) {
3746
+ const reason = signal.reason ?? new DOMException("Aborted", "AbortError");
3747
+ throw reason;
3748
+ }
3749
+ const { bytesRead } = await fh.read(buf, 0, CHUNK, position);
3750
+ if (bytesRead === 0) break;
3751
+ const text = leftover + buf.subarray(0, bytesRead).toString("utf8");
3752
+ const parts = text.split("\n");
3753
+ leftover = parts.pop() ?? "";
3754
+ for (const line of parts) {
3755
+ if (!line) continue;
3756
+ const ev = parseSessionEventLine(line, secretScrubber);
3757
+ if (!ev) continue;
3758
+ if (predicate(ev, eventIndex, ev.ts)) {
3759
+ out.push({ event: ev, eventIndex, ts: ev.ts });
3760
+ if (limit !== void 0 && out.length >= limit) return out;
3761
+ }
3762
+ eventIndex++;
3763
+ }
3764
+ }
3765
+ const trailing = parseSessionEventLine(leftover.trim() ? leftover : "", secretScrubber);
3766
+ if (trailing && predicate(trailing, eventIndex, trailing.ts)) {
3767
+ out.push({ event: trailing, eventIndex, ts: trailing.ts });
3768
+ }
3769
+ return out;
3770
+ } finally {
3771
+ if (fh) await fh.close().catch(() => void 0);
3772
+ }
3773
+ }
3774
+ function parseSessionEventLine(line, secretScrubber) {
3775
+ if (!line) return null;
3776
+ try {
3777
+ const parsed = JSON.parse(line);
3778
+ if (parsed === null || typeof parsed !== "object" || typeof parsed.type !== "string" || typeof parsed.ts !== "string") {
3779
+ return null;
3780
+ }
3781
+ return scrubPersistedSessionEvent(parsed, secretScrubber);
3782
+ } catch {
3783
+ return null;
3784
+ }
3785
+ }
3786
+
3787
+ // src/storage/session-store/session-store-clear.ts
3788
+ import * as fsp14 from "node:fs/promises";
3789
+ async function executeClearSessionHistory(params) {
3790
+ const {
3791
+ canonical,
3792
+ catalogClient,
3793
+ maintenanceHolderId,
3794
+ ensureShardDir: ensureShardDir2,
3795
+ sessionPath: sessionPath2
3796
+ } = params;
3797
+ const maintenance = catalogClient ? await catalogClient.call("acquire_maintenance", {
3798
+ sessionId: canonical,
3799
+ operation: "clear",
3800
+ holderId: maintenanceHolderId,
3801
+ holderPid: process.pid
3802
+ }) : void 0;
3803
+ await ensureShardDir2(canonical);
3804
+ const file = sessionPath2(canonical, ".jsonl");
3805
+ const meta = sessionPath2(canonical, ".summary.json");
3806
+ const backupSuffix = maintenance ? `.${maintenance.leaseId}.clear-backup` : void 0;
3807
+ const fileBackup = backupSuffix ? `${file}${backupSuffix}` : void 0;
3808
+ const metaBackup = backupSuffix ? `${meta}${backupSuffix}` : void 0;
3809
+ let fileStaged = false;
3810
+ let metaStaged = false;
3811
+ const record = `${JSON.stringify({
3812
+ type: "session_start",
3813
+ ts: (/* @__PURE__ */ new Date()).toISOString(),
3814
+ id: canonical,
3815
+ model: "unknown",
3816
+ provider: "unknown"
3817
+ })}
3818
+ `;
3819
+ try {
3820
+ if (fileBackup) {
3821
+ try {
3822
+ await fsp14.rename(file, fileBackup);
3823
+ fileStaged = true;
3824
+ } catch (error) {
3825
+ if (error.code !== "ENOENT") throw error;
3826
+ }
3827
+ }
3828
+ if (metaBackup) {
3829
+ try {
3830
+ await fsp14.rename(meta, metaBackup);
3831
+ metaStaged = true;
3832
+ } catch (error) {
3833
+ if (error.code !== "ENOENT") throw error;
3834
+ }
3835
+ }
3836
+ await atomicWrite(file, record);
3837
+ if (!metaBackup) await fsp14.unlink(meta).catch(() => void 0);
3838
+ if (catalogClient) {
3839
+ const now = (/* @__PURE__ */ new Date()).toISOString();
3840
+ await catalogClient.call("upsert_summary", {
3841
+ summary: {
3842
+ id: canonical,
3843
+ title: "",
3844
+ startedAt: now,
3845
+ model: "unknown",
3846
+ provider: "unknown",
3847
+ tokenTotal: 0,
3848
+ lastActivityAt: now
3849
+ },
3850
+ transcriptRelativePath: `${canonical}.jsonl`,
3851
+ summaryRelativePath: `${canonical}.summary.json`
3852
+ });
3853
+ }
3854
+ if (fileStaged && fileBackup) await fsp14.unlink(fileBackup).catch(() => void 0);
3855
+ if (metaStaged && metaBackup) await fsp14.unlink(metaBackup).catch(() => void 0);
3856
+ } catch (error) {
3857
+ if (fileStaged && fileBackup) {
3858
+ await fsp14.unlink(file).catch(() => void 0);
3859
+ await fsp14.rename(fileBackup, file).catch(() => void 0);
3860
+ }
3861
+ if (metaStaged && metaBackup) {
3862
+ await fsp14.unlink(meta).catch(() => void 0);
3863
+ await fsp14.rename(metaBackup, meta).catch(() => void 0);
3864
+ }
3865
+ throw error;
3866
+ } finally {
3867
+ if (maintenance && catalogClient) {
3868
+ await catalogClient.call("release_maintenance", { lease: maintenance }).catch(() => void 0);
3869
+ }
3870
+ }
3871
+ }
3872
+
3873
+ // src/storage/session-store/session-store-index.ts
3874
+ import * as fsp16 from "node:fs/promises";
3875
+
3876
+ // src/storage/session-summary.ts
3877
+ function compareSessionSummaries(a, b) {
3878
+ const aActivity = a.lastActivityAt ?? a.endedAt ?? a.startedAt;
3879
+ const bActivity = b.lastActivityAt ?? b.endedAt ?? b.startedAt;
3880
+ if (aActivity < bActivity) return 1;
3881
+ if (aActivity > bActivity) return -1;
3882
+ if (a.startedAt < b.startedAt) return 1;
3883
+ if (a.startedAt > b.startedAt) return -1;
3884
+ return a.id.localeCompare(b.id);
3885
+ }
3886
+ function matchesSessionFilter(summary, criteria) {
3887
+ if (criteria.since && summary.startedAt < criteria.since) return false;
3888
+ if (criteria.until && summary.startedAt > criteria.until) return false;
3889
+ if (criteria.provider && summary.provider !== criteria.provider) return false;
3890
+ if (criteria.model && summary.model !== criteria.model) return false;
3891
+ if (criteria.minTokens !== void 0 && summary.tokenTotal < criteria.minTokens) return false;
3892
+ if (criteria.titleContains) {
3893
+ const needle = criteria.titleContains.toLocaleLowerCase();
3894
+ if (!summary.title.toLocaleLowerCase().includes(needle)) return false;
3895
+ }
3896
+ return true;
3897
+ }
3898
+
3899
+ // src/storage/session-store/index-reader.ts
3900
+ import * as fsp15 from "node:fs/promises";
3901
+ function applySessionIndexLines(raw, byId, deleted) {
3902
+ for (const line of raw.split("\n")) {
3903
+ if (!line.trim()) continue;
3904
+ try {
3905
+ const entry = JSON.parse(line);
3906
+ if (entry.action === "delete" && entry.id) {
3907
+ deleted.add(entry.id);
3908
+ byId.delete(entry.id);
3909
+ continue;
3910
+ }
3911
+ if (entry.action === "create" && entry.id) {
3912
+ deleted.delete(entry.id);
3913
+ byId.delete(entry.id);
3914
+ continue;
3915
+ }
3916
+ if (entry.id && !deleted.has(entry.id)) {
3917
+ byId.set(entry.id, entry);
3918
+ }
3919
+ } catch {
3920
+ }
3921
+ }
3922
+ }
3923
+ async function readFileRange(file, start, end) {
3924
+ const length = end - start;
3925
+ if (length <= 0) return { raw: "", end: start };
3926
+ const buffer = Buffer.allocUnsafe(length);
3927
+ let offset = 0;
3928
+ let handle;
3929
+ try {
3930
+ handle = await fsp15.open(file, "r");
3931
+ while (offset < length) {
3932
+ const { bytesRead } = await handle.read(buffer, offset, length - offset, start + offset);
3933
+ if (bytesRead === 0) return null;
3934
+ offset += bytesRead;
3935
+ }
3936
+ let completeLength = buffer.length;
3937
+ if (buffer.at(-1) !== 10) {
3938
+ const lastNewline = buffer.lastIndexOf(10);
3939
+ const trailing = buffer.subarray(lastNewline + 1).toString("utf8").trim();
3940
+ try {
3941
+ if (trailing) JSON.parse(trailing);
3942
+ else completeLength = lastNewline + 1;
3943
+ } catch {
3944
+ completeLength = lastNewline + 1;
3945
+ }
3946
+ }
3947
+ return {
3948
+ raw: buffer.subarray(0, completeLength).toString("utf8"),
3949
+ end: start + completeLength
3950
+ };
3951
+ } catch {
3952
+ return null;
3953
+ } finally {
3954
+ await handle?.close().catch(() => void 0);
3955
+ }
3956
+ }
3957
+
3958
+ // src/storage/session-store/session-store-index.ts
3959
+ var COMPACT_EVERY = 30;
3960
+ var NO_DELETED_IDS = /* @__PURE__ */ new Set();
3961
+ async function appendToIndexStrict(dir, indexFile, summary, invalidateShard, onAppended, compactInner) {
3962
+ await ensureDir(dir);
3963
+ let shouldCompact = false;
3964
+ await withFileLock(indexFile, async () => {
3965
+ await invalidateShard(summary.id);
3966
+ const line = JSON.stringify(summary) + "\n";
3967
+ await fsp16.appendFile(indexFile, line, { encoding: "utf8", mode: SECRET_FILE_MODE });
3968
+ const res = onAppended();
3969
+ shouldCompact = res.shouldCompact;
3970
+ });
3971
+ if (shouldCompact) {
3972
+ await withFileLock(indexFile, () => compactInner());
3973
+ }
3974
+ }
3975
+ async function writeTombstone(dir, indexFile, id, invalidateShard, onTombstone) {
3976
+ try {
3977
+ await ensureDir(dir);
3978
+ await withFileLock(indexFile, async () => {
3979
+ const line = JSON.stringify({ action: "delete", id }) + "\n";
3980
+ await fsp16.appendFile(indexFile, line, { encoding: "utf8", mode: SECRET_FILE_MODE });
3981
+ onTombstone();
3982
+ await invalidateShard(id);
3983
+ });
3984
+ } catch {
3985
+ }
3986
+ }
3987
+ async function readIndexFile(indexFile, currentCache) {
3988
+ let stat11;
3989
+ try {
3990
+ const s = await fsp16.stat(indexFile);
3991
+ stat11 = { mtimeMs: s.mtimeMs, size: s.size, ino: s.ino, birthtimeMs: s.birthtimeMs };
3992
+ } catch {
3993
+ return { summaries: [], deletedIds: NO_DELETED_IDS, cache: null };
3994
+ }
3995
+ if (currentCache !== null && currentCache.mtimeMs === stat11.mtimeMs && currentCache.size === stat11.size && currentCache.ino === stat11.ino && currentCache.birthtimeMs === stat11.birthtimeMs) {
3996
+ return {
3997
+ summaries: currentCache.summaries,
3998
+ deletedIds: currentCache.deleted,
3999
+ cache: currentCache
4000
+ };
4001
+ }
4002
+ const cached = currentCache;
4003
+ const sameFile = cached !== null && cached.ino === stat11.ino && cached.birthtimeMs === stat11.birthtimeMs;
4004
+ if (cached && sameFile && stat11.size > cached.size) {
4005
+ const appended = await readFileRange(indexFile, cached.size, stat11.size);
4006
+ if (appended !== null) {
4007
+ applySessionIndexLines(appended.raw, cached.byId, cached.deleted);
4008
+ const summaries2 = Array.from(cached.byId.values()).sort(compareSessionSummaries);
4009
+ const nextCache2 = {
4010
+ ...stat11,
4011
+ size: appended.end,
4012
+ summaries: summaries2,
4013
+ byId: cached.byId,
4014
+ deleted: cached.deleted
4015
+ };
4016
+ return { summaries: summaries2, deletedIds: cached.deleted, cache: nextCache2 };
4017
+ }
4018
+ }
4019
+ let raw;
4020
+ try {
4021
+ raw = await fsp16.readFile(indexFile, "utf8");
4022
+ } catch {
4023
+ return { summaries: [], deletedIds: NO_DELETED_IDS, cache: null };
4024
+ }
4025
+ const deleted = /* @__PURE__ */ new Set();
4026
+ const byId = /* @__PURE__ */ new Map();
4027
+ applySessionIndexLines(raw, byId, deleted);
4028
+ const summaries = Array.from(byId.values());
4029
+ summaries.sort(compareSessionSummaries);
4030
+ const nextCache = { ...stat11, summaries, byId, deleted };
4031
+ return { summaries, deletedIds: deleted, cache: nextCache };
4032
+ }
4033
+ async function compactIndexInner(indexFile, entries, deletedIds) {
4034
+ const parts = entries.map((s) => JSON.stringify(s));
4035
+ if (deletedIds) {
4036
+ for (const id of deletedIds) {
4037
+ parts.push(JSON.stringify({ action: "delete", id }));
4038
+ }
4039
+ }
4040
+ if (parts.length === 0) return;
4041
+ const lines = parts.join("\n") + "\n";
4042
+ await atomicWrite(indexFile, lines, { mode: 384 });
4043
+ }
4044
+
4045
+ // src/storage/session-store/shard-manifest.ts
4046
+ import * as fsp17 from "node:fs/promises";
4047
+ async function readOrBuildShardManifestEntry(opts) {
4048
+ try {
4049
+ const raw = await fsp17.readFile(opts.manifestPath, "utf8");
4050
+ const parsed = JSON.parse(raw);
4051
+ return {
4052
+ summaries: Array.isArray(parsed.summaries) ? parsed.summaries : [],
4053
+ ids: Array.isArray(parsed.ids) ? parsed.ids : []
4054
+ };
4055
+ } catch {
4056
+ }
4057
+ const refs = await opts.collectSessionFilesInShard(opts.shardKey);
4058
+ const candidates = await mapWithConcurrency(
4059
+ refs,
4060
+ opts.concurrency,
4061
+ async (ref) => {
4062
+ const manifest = await opts.readSummaryManifest(ref.id);
4063
+ if (manifest) return { summary: manifest, needsBackfill: false };
4064
+ const summary = await opts.summaryHeaderFor(ref);
4065
+ if (!summary) return null;
4066
+ const hydrated = await opts.summaryFor(summary.id).catch(() => summary);
4067
+ return { summary: hydrated, needsBackfill: false };
4068
+ }
4069
+ );
4070
+ const summaries = candidates.filter((candidate) => candidate !== null).map((candidate) => candidate.summary);
4071
+ summaries.sort(compareSessionSummaries);
4072
+ const entry = { summaries, ids: summaries.map((summary) => summary.id) };
4073
+ await atomicWrite(opts.manifestPath, JSON.stringify(entry), { mode: 384 }).catch(
4074
+ () => void 0
4075
+ );
4076
+ return entry;
4077
+ }
4078
+
4079
+ // src/storage/session-store/strict-empty-check.ts
4080
+ import { createReadStream as createReadStream5 } from "node:fs";
4081
+ import { createInterface as createInterface5 } from "node:readline";
4082
+ var EMPTY_SESSION_EVENT_TYPES = /* @__PURE__ */ new Set(["session_start", "session_resumed", "session_end"]);
4083
+ async function isStrictlyEmptySessionFile(file) {
4084
+ const input = createReadStream5(file, { encoding: "utf8" });
4085
+ const lines = createInterface5({ input, crlfDelay: Infinity });
4086
+ let sawSessionStart = false;
4087
+ try {
4088
+ for await (const line of lines) {
4089
+ if (!line.trim()) continue;
4090
+ let event;
4091
+ try {
4092
+ event = JSON.parse(line);
4093
+ } catch {
4094
+ return false;
4095
+ }
4096
+ if (event === null || typeof event !== "object" || Array.isArray(event)) return false;
4097
+ const type = event.type;
4098
+ if (typeof type !== "string" || !EMPTY_SESSION_EVENT_TYPES.has(type)) return false;
4099
+ if (type === "session_start") sawSessionStart = true;
4100
+ }
4101
+ } catch {
4102
+ return false;
4103
+ } finally {
4104
+ lines.close();
4105
+ input.destroy();
4106
+ }
4107
+ return sawSessionStart;
4108
+ }
4109
+
4110
+ // src/storage/session-store/summary-header.ts
4111
+ import * as fsp18 from "node:fs/promises";
4112
+ async function readSessionSummaryHeader(ref, secretScrubber) {
4113
+ let mtime = (/* @__PURE__ */ new Date(0)).toISOString();
4114
+ try {
4115
+ const stat11 = await fsp18.stat(ref.filePath);
4116
+ if (!stat11.isFile()) return damagedSummary(ref.id, stat11.mtime.toISOString());
4117
+ mtime = stat11.mtime.toISOString();
4118
+ } catch {
4119
+ return null;
4120
+ }
4121
+ try {
4122
+ for await (const event of iterateSessionEvents(ref.filePath, secretScrubber)) {
4123
+ if (event.type === "session_start") {
4124
+ return {
4125
+ id: ref.id,
4126
+ title: "(empty session)",
4127
+ startedAt: event.ts,
4128
+ model: event.model ?? "unknown",
4129
+ provider: event.provider ?? "unknown",
4130
+ tokenTotal: 0
4131
+ };
4132
+ }
4133
+ }
4134
+ return emptySessionSummary(ref.id, (/* @__PURE__ */ new Date(0)).toISOString());
4135
+ } catch {
4136
+ return damagedSummary(ref.id, mtime);
4137
+ }
4138
+ }
4139
+ function emptySessionSummary(id, startedAt) {
4140
+ return {
4141
+ id,
4142
+ title: "(empty session)",
4143
+ startedAt,
4144
+ model: "unknown",
4145
+ provider: "unknown",
4146
+ tokenTotal: 0
4147
+ };
4148
+ }
4149
+ function damagedSummary(id, startedAt) {
4150
+ return {
4151
+ id,
4152
+ title: "(damaged)",
4153
+ startedAt,
4154
+ model: "unknown",
4155
+ provider: "unknown",
4156
+ tokenTotal: 0
4157
+ };
4158
+ }
4159
+
4160
+ // src/storage/session-store.ts
4161
+ var SESSION_FILTER_POOL_LIMIT = 1e4;
4162
+ var DefaultSessionStore = class _DefaultSessionStore {
4163
+ dir;
4164
+ events;
4165
+ secretScrubber;
4166
+ projectRoot;
4167
+ checkpointCas;
4168
+ isSessionInUse;
4169
+ logger;
4170
+ onAppend;
4171
+ onAppendBatch;
4172
+ catalogClient;
4173
+ maintenanceHolderId = randomUUID2();
4174
+ _loadCache = /* @__PURE__ */ new Map();
4175
+ loadCache = new SessionLoadCache(this._loadCache);
4176
+ _indexCache = null;
4177
+ /**
4178
+ * Tombstoned ids — hidden even if their JSONL remains on disk.
4179
+ * Convention: readIndex() REASSIGNS this set from the parsed index file
4180
+ * MERGED with _manualTombstones; writeTombstone() adds in-place immediately
4181
+ * so an incremental cache rebuild can never resurrect a just-deleted
4182
+ * session.
4183
+ */
4184
+ _indexDeletedIds = /* @__PURE__ */ new Set();
4185
+ /**
4186
+ * Tombstones added by THIS store between reads. Merged into every fresh
4187
+ * snapshot so a read racing writeTombstone cannot drop an in-flight
4188
+ * deletion; entries are pruned once the parsed index file itself carries
4189
+ * them.
4190
+ */
4191
+ _manualTombstones = /* @__PURE__ */ new Set();
4192
+ /**
4193
+ * File-truth tombstones from the last readIndex() parse (EXCLUDES
4194
+ * _manualTombstones additions). compactIndexInner persists THIS snapshot so
4195
+ * concurrent writeTombstones that landed after the parse are not written
4196
+ * prematurely — they persist through their own append path instead.
4197
+ */
4198
+ _indexFileDeletedIds = /* @__PURE__ */ new Set();
4199
+ shardManifestCache = /* @__PURE__ */ new Map();
4200
+ static LIST_SCAN_CONCURRENCY = 32;
4201
+ indexAppendCount = 0;
4202
+ constructor(opts) {
4203
+ this.dir = opts.dir;
4204
+ this.projectRoot = opts.projectRoot ? path11.resolve(opts.projectRoot) : void 0;
4205
+ this.checkpointCas = this.projectRoot ? new SessionCheckpointCas({
4206
+ rootDir: path11.join(this.dir, "_cas"),
4207
+ projectRoot: this.projectRoot
4208
+ }) : void 0;
4209
+ this.events = opts.events;
4210
+ this.secretScrubber = opts.secretScrubber ?? new DefaultSecretScrubber();
4211
+ this.isSessionInUse = opts.isSessionInUse;
4212
+ this.logger = opts.logger;
4213
+ this.onAppend = opts.onAppend;
4214
+ this.onAppendBatch = opts.onAppendBatch;
4215
+ const builtRuntime = import.meta.url.includes("/dist/");
4216
+ this.catalogClient = this.projectRoot && (builtRuntime || process.env["WRONGSTACK_SESSION_CATALOG_FORCE"] === "1") && resolveSessionCatalogProjectServerUrl() ? new SessionCatalogProjectClient({
4217
+ projectDir: path11.dirname(this.dir),
4218
+ projectRoot: this.projectRoot
4219
+ }) : void 0;
4220
+ }
4221
+ logWarn(msg, ctx) {
4222
+ if (this.logger) {
4223
+ this.logger.warn(msg, ctx);
4224
+ } else {
4225
+ console.warn(JSON.stringify({ ...ctx, message: msg, timestamp: (/* @__PURE__ */ new Date()).toISOString() }));
4226
+ }
4227
+ }
4228
+ scrubSummaries(summaries) {
4229
+ return summaries.map((summary) => scrubPersistedSessionSummary(summary, this.secretScrubber));
4230
+ }
4231
+ clearLoadCache(sessionId) {
4232
+ this.loadCache.clear(sessionId);
4233
+ }
4234
+ async dispose() {
4235
+ await this.catalogClient?.close();
4236
+ this.clearLoadCache();
4237
+ }
4238
+ get indexFile() {
4239
+ return path11.join(this.dir, "_index.jsonl");
4240
+ }
4241
+ sessionPath(id, ext) {
4242
+ return sessionPath(this.dir, id, ext);
4243
+ }
4244
+ shardManifestPath(shardKey) {
4245
+ return shardManifestPath(this.dir, shardKey);
4246
+ }
4247
+ shardKeyForSessionId(id) {
4248
+ return shardKeyForSessionId(id);
4249
+ }
4250
+ async invalidateShardManifestBySessionId(id) {
4251
+ const shardKey = this.shardKeyForSessionId(id);
4252
+ const manifestPath = this.shardManifestPath(shardKey);
4253
+ await withFileLock(manifestPath, async () => {
4254
+ this.shardManifestCache.delete(shardKey);
4255
+ try {
4256
+ await fsp19.unlink(manifestPath);
4257
+ } catch (err) {
4258
+ if (err.code !== "ENOENT") throw err;
4259
+ }
4260
+ });
4261
+ }
4262
+ async ensureShardDir(id) {
4263
+ return ensureShardDir(this.dir, id);
4264
+ }
4265
+ /**
4266
+ * Create a fresh session writer.
4267
+ *
4268
+ * @threadSafety Failure-prone steps (manifest invalidation, sidecar
4269
+ * removal, catalog upsert, the durable `{action:'create'}` index row)
4270
+ * run BEFORE the truncating `'w'` open, so no rejection path can destroy
4271
+ * prior bytes. Ordinary index summary rows never undelete a tombstone
4272
+ * (the parser only honors `{action:'create'}`), so a swallowed create-row
4273
+ * would leave a live writer whose id stays hidden forever — that append
4274
+ * is therefore required, not best-effort.
4275
+ */
4276
+ async create(meta) {
4277
+ const startedAt = (/* @__PURE__ */ new Date()).toISOString();
4278
+ const id = meta.id && meta.id.length > 0 ? meta.id : generateSessionId(startedAt);
4279
+ const shardDir = await this.ensureShardDir(id);
4280
+ const file = this.sessionPath(id, ".jsonl");
4281
+ const inUseBy = this.isSessionInUse ? await this.isSessionInUse(id) : null;
4282
+ if (inUseBy) {
4283
+ throw new Error(`Refusing to create session ${id}: in use (${inUseBy}).`);
4284
+ }
4285
+ const t0 = Date.now();
4286
+ try {
4287
+ await this.invalidateShardManifestBySessionId(id);
4288
+ } catch (cause) {
4289
+ throw new Error(
4290
+ `Failed to invalidate stale shard manifest for ${id}: ${toErrorMessage(cause)}`,
4291
+ { cause }
4292
+ );
4293
+ }
4294
+ const sidecar = path11.join(shardDir, `${path11.basename(id)}.summary.json`);
4295
+ try {
4296
+ await fsp19.rm(sidecar, { force: true });
4297
+ } catch (cause) {
4298
+ emitSessionStoreError(this.events, id, sidecar, "create", toErrorMessage(cause), true);
4299
+ try {
4300
+ await fsp19.access(sidecar);
4301
+ throw new Error(
4302
+ `Failed to remove stale session sidecar for ${id}: ${toErrorMessage(cause)}`,
4303
+ { cause }
4304
+ );
4305
+ } catch (accessErr) {
4306
+ const code = accessErr.code;
4307
+ if (code !== "ENOENT" && code !== "ENAMETOOLONG") throw accessErr;
4308
+ }
4309
+ }
4310
+ if (this.catalogClient) {
4311
+ await this.catalogClient.call("upsert_summary", {
4312
+ summary: {
4313
+ id,
4314
+ title: meta.title ?? "",
4315
+ startedAt,
4316
+ model: meta.model ?? "",
4317
+ provider: meta.provider ?? "",
4318
+ tokenTotal: 0,
4319
+ lastActivityAt: startedAt
4320
+ },
4321
+ transcriptRelativePath: `${id}.jsonl`,
4322
+ summaryRelativePath: `${id}.summary.json`
4323
+ });
4324
+ }
4325
+ try {
4326
+ await withFileLock(this.indexFile, async () => {
4327
+ try {
4328
+ await fsp19.appendFile(this.indexFile, `${JSON.stringify({ action: "create", id })}
4329
+ `, {
4330
+ encoding: "utf8",
4331
+ mode: 384
4332
+ });
4333
+ } finally {
4334
+ this._indexCache = null;
4335
+ }
4336
+ this._manualTombstones.delete(id);
4337
+ this._indexDeletedIds.delete(id);
4338
+ });
4339
+ } catch (cause) {
4340
+ throw new Error(
4341
+ `Failed to record session create in the index for ${id}: ${toErrorMessage(cause)}`,
4342
+ { cause }
4343
+ );
4344
+ }
4345
+ let handle;
4346
+ try {
4347
+ handle = await fsp19.open(file, "w", 384);
4348
+ } catch (err) {
4349
+ emitSessionStoreError(this.events, id, file, "create", toErrorMessage(err), false);
4350
+ throw new Error(`Failed to open session file: ${toErrorMessage(err)}`, { cause: err });
4351
+ }
4352
+ await this.invalidateShardManifestBySessionId(id).catch(() => void 0);
4353
+ try {
4354
+ const writer = new FileSessionWriter(id, handle, startedAt, meta, this.events, {
4355
+ dir: shardDir,
4356
+ filePath: file,
4357
+ secretScrubber: this.secretScrubber,
4358
+ checkpointCas: this.checkpointCas,
4359
+ onAppend: this.onAppend,
4360
+ onAppendBatch: this.onAppendBatch,
4361
+ resolveName: async () => {
4362
+ const current = await this.readSummaryManifest(id);
4363
+ if (!current) return null;
4364
+ return current.name === void 0 ? {} : { name: sessionContentText(this.secretScrubber.scrub(current.name)) };
4365
+ },
4366
+ onClose: (s) => this.persistCatalogSummary(s),
4367
+ // Mid-session metadata checkpoints reuse the same sink as close so
4368
+ // killed sessions leave accurate index rows / catalog entries behind.
4369
+ onMetadataCheckpoint: (s) => this.persistCatalogSummary(s)
4370
+ });
4371
+ emitSessionStoreWrite(this.events, id, file, "create", "success", Date.now() - t0);
4372
+ return writer;
4373
+ } catch (err) {
4374
+ await handle.close().catch(
4375
+ (e) => this.logWarn("Session handle close failed", {
4376
+ event: "session_store.handle_close_failed",
4377
+ message: e instanceof Error ? e.message : String(e)
4378
+ })
4379
+ );
4380
+ emitSessionStoreError(this.events, id, file, "create", toErrorMessage(err), true);
4381
+ throw err;
4382
+ }
4383
+ }
4384
+ async fork(id, opts = {}) {
4385
+ return forkSession(this, id, opts);
4386
+ }
4387
+ async readRawEvents(id) {
4388
+ const hits = await this.searchEvents(id, () => true);
4389
+ return hits.map((hit) => hit.event);
4390
+ }
4391
+ async captureWorkspaceCheckpoint(sessionId, promptIndex) {
4392
+ return captureCheckpoint(this.checkpointCas, sessionId, promptIndex);
4393
+ }
4394
+ async materializeWorkspaceCheckpoint(checkpoint, targetRoot) {
4395
+ return materializeCheckpoint(this.checkpointCas, checkpoint, targetRoot);
4396
+ }
4397
+ async resolveId(query) {
4398
+ if (this.catalogClient) {
4399
+ return this.catalogClient.call("resolve_id", { query });
4400
+ }
4401
+ const normalized = query.trim();
4402
+ if (!normalized) throw new Error("Session not found: (empty query)");
4403
+ if (normalized) {
4404
+ try {
4405
+ const stat11 = await fsp19.stat(this.sessionPath(normalized, ".jsonl"));
4406
+ if (stat11.isFile()) return normalized;
4407
+ } catch {
4408
+ }
4409
+ }
4410
+ const ids = await this.collectSessionIds(this.dir);
4411
+ const resolution = resolveSessionId(normalized, ids);
4412
+ if (resolution.status === "resolved") return resolution.id;
4413
+ throw sessionIdResolutionError(resolution);
4414
+ }
4415
+ async resume(id) {
4416
+ const canonicalId = await this.resolveId(id);
4417
+ const file = this.sessionPath(canonicalId, ".jsonl");
4418
+ return executeResumeSession({
4419
+ id,
4420
+ canonicalId,
4421
+ file,
4422
+ projectRoot: this.projectRoot,
4423
+ events: this.events,
4424
+ secretScrubber: this.secretScrubber,
4425
+ checkpointCas: this.checkpointCas,
4426
+ onAppend: this.onAppend,
4427
+ onAppendBatch: this.onAppendBatch,
4428
+ load: (loadId) => this.load(loadId),
4429
+ readSummaryManifest: (summaryId) => this.readSummaryManifest(summaryId),
4430
+ searchEvents: (searchId, pred) => this.searchEvents(searchId, pred),
4431
+ persistCatalogSummary: (sum) => this.persistCatalogSummary(sum),
4432
+ logWarn: (msg, ctx) => this.logWarn(msg, ctx),
4433
+ sessionsDir: this.dir
4434
+ });
4435
+ }
4436
+ async load(id) {
4437
+ return this.loadInternal(id, { full: true });
4438
+ }
4439
+ async loadEventsOnly(id) {
4440
+ return this.loadInternal(id, { full: false });
4441
+ }
4442
+ async loadInternal(id, mode) {
4443
+ const file = this.sessionPath(id, ".jsonl");
4444
+ const t0 = Date.now();
4445
+ let outcome = "success";
4446
+ let errorMsg;
4447
+ let cacheHit = false;
4448
+ try {
4449
+ const s = await fsp19.stat(file);
4450
+ const stat11 = { mtimeMs: s.mtimeMs, size: s.size };
4451
+ const cached = this.loadCache.getFresh(id, stat11, mode.full);
4452
+ if (cached) {
4453
+ cacheHit = true;
4454
+ return cached;
4455
+ }
4456
+ const data = await loadSessionDataFromFile({
4457
+ id,
4458
+ file,
4459
+ full: mode.full,
4460
+ events: this.events,
4461
+ secretScrubber: this.secretScrubber
4462
+ });
4463
+ if (mode.full) {
4464
+ this.loadCache.set(id, stat11, data);
4465
+ }
4466
+ return data;
4467
+ } catch (err) {
4468
+ outcome = "failure";
4469
+ errorMsg = toErrorMessage(err);
4470
+ throw err;
4471
+ } finally {
4472
+ emitSessionStoreRead(
4473
+ this.events,
4474
+ id,
4475
+ file,
4476
+ mode.full ? "load" : "load_events_only",
4477
+ outcome,
4478
+ Date.now() - t0,
4479
+ errorMsg
4480
+ );
4481
+ if (cacheHit) {
4482
+ this.events?.emit("storage.cache_hit", {
4483
+ sessionId: id,
4484
+ store: "session",
4485
+ filePath: file,
4486
+ operation: mode.full ? "load" : "load_events_only",
4487
+ durationMs: Date.now() - t0
4488
+ });
4489
+ }
4490
+ }
4491
+ }
4492
+ async searchEvents(id, predicate, opts) {
4493
+ return searchSessionEvents({
4494
+ file: this.sessionPath(id, ".jsonl"),
4495
+ secretScrubber: this.secretScrubber,
4496
+ predicate,
4497
+ limit: opts?.limit,
4498
+ signal: opts?.signal
4499
+ });
4500
+ }
4501
+ async list(limit = 20) {
4502
+ if (this.catalogClient) {
4503
+ const records = await this.catalogClient.call("list_catalog", { limit });
4504
+ return this.scrubSummaries(records);
4505
+ }
4506
+ try {
4507
+ const [indexed, scanned] = await Promise.all([
4508
+ this.readIndex(),
4509
+ // Wide scan bound: mergeIndexWithScan slices to `limit`, so killed
4510
+ // sessions deep in history stay visible instead of being dropped by
4511
+ // the user-facing page size before the union runs.
4512
+ this.listFromDirectoryScan(SESSION_FILTER_POOL_LIMIT).catch(() => [])
4513
+ ]);
4514
+ return this.scrubSummaries(this.mergeIndexWithScan(indexed, scanned, limit));
4515
+ } catch {
4516
+ return [];
4517
+ }
4518
+ }
4519
+ async listFiltered(criteria) {
4520
+ const limit = criteria.limit ?? 100;
4521
+ if (this.catalogClient) {
4522
+ const records = await this.catalogClient.call("list_catalog", {
4523
+ limit,
4524
+ ...criteria
4525
+ });
4526
+ return this.scrubSummaries(records);
4527
+ }
4528
+ try {
4529
+ const [indexed, scanned] = await Promise.all([
4530
+ this.readIndex(),
4531
+ // Same best-effort contract as list(): scan failures enrich nothing
4532
+ // but must not blank the filtered result set.
4533
+ this.listFromDirectoryScan(SESSION_FILTER_POOL_LIMIT).catch(() => [])
4534
+ ]);
4535
+ const pool = this.mergeIndexWithScan(indexed, scanned, SESSION_FILTER_POOL_LIMIT);
4536
+ return this.scrubSummaries(pool).filter((s) => matchesSessionFilter(s, criteria)).slice(0, limit);
4537
+ } catch {
4538
+ return [];
4539
+ }
4540
+ }
4541
+ async appendToIndexStrict(summary) {
4542
+ await appendToIndexStrict(
4543
+ this.dir,
4544
+ this.indexFile,
4545
+ summary,
4546
+ (id) => this.invalidateShardManifestBySessionId(id),
4547
+ () => {
4548
+ this._indexCache = null;
4549
+ this.indexAppendCount++;
4550
+ const shouldCompact = this.indexAppendCount >= COMPACT_EVERY;
4551
+ if (shouldCompact) this.indexAppendCount = 0;
4552
+ return { shouldCompact };
4553
+ },
4554
+ () => this.compactIndexInner()
4555
+ );
4556
+ }
4557
+ async appendToIndex(summary) {
4558
+ await this.appendToIndexStrict(summary).catch(() => void 0);
4559
+ }
4560
+ async persistCatalogSummary(summary) {
4561
+ if (!this.catalogClient) {
4562
+ await this.appendToIndex(summary);
4563
+ return;
4564
+ }
4565
+ await this.catalogClient.call("upsert_summary", {
4566
+ summary,
4567
+ transcriptRelativePath: `${summary.id}.jsonl`,
4568
+ summaryRelativePath: `${summary.id}.summary.json`
4569
+ });
4570
+ }
4571
+ async writeTombstone(id) {
4572
+ await writeTombstone(
4573
+ this.dir,
4574
+ this.indexFile,
4575
+ id,
4576
+ (sid) => this.invalidateShardManifestBySessionId(sid),
4577
+ () => {
4578
+ this._manualTombstones.add(id);
4579
+ this._indexDeletedIds.add(id);
4580
+ this._indexCache = null;
4581
+ this.indexAppendCount++;
4582
+ }
4583
+ );
4584
+ }
4585
+ async compactIndex() {
4586
+ const t0 = Date.now();
4587
+ let outcome = "success";
4588
+ let errorMsg;
4589
+ try {
4590
+ await withFileLock(this.indexFile, () => this.compactIndexInner());
4591
+ } catch (err) {
4592
+ outcome = "failure";
4593
+ errorMsg = toErrorMessage(err);
4594
+ } finally {
4595
+ emitSessionStoreWrite(
4596
+ this.events,
4597
+ "~compact~",
4598
+ this.indexFile,
4599
+ "compact",
4600
+ outcome,
4601
+ Date.now() - t0,
4602
+ void 0,
4603
+ errorMsg
4604
+ );
4605
+ }
4606
+ }
4607
+ /**
4608
+ * Compact the local index in place.
4609
+ *
4610
+ * Contract carried into the shared compactIndexInner helper
4611
+ * (session-store-index.ts): `entries` MUST already exclude tombstoned ids,
4612
+ * and the deleted-set argument is persisted VERBATIM — neither the helper
4613
+ * nor its callers may resurrect filtered rows or invent deletions.
4614
+ * Locking: callers MUST already hold the indexFile lock (both do:
4615
+ * compactIndex() below and the appendToIndexStrict compaction hook);
4616
+ * readIndex() inside reads that same locked file, so no second lock may
4617
+ * be taken here (non-reentrant → deadlock).
4618
+ *
4619
+ * That same lock is what makes the _indexFileDeletedIds snapshot safe to
4620
+ * pass across the await below: writeTombstone() appends under the identical
4621
+ * non-reentrant indexFile lock, so no tombstone can land between our
4622
+ * readIndex() and the snapshot handed to the helper. Compaction would
4623
+ * otherwise be racing a delete it cannot see.
4624
+ */
4625
+ async compactIndexInner() {
4626
+ const entries = await this.readIndex();
4627
+ await compactIndexInner(this.indexFile, entries, this._indexFileDeletedIds);
4628
+ this._indexCache = null;
4629
+ }
4630
+ async readIndex() {
4631
+ const { summaries, deletedIds, cache } = await readIndexFile(this.indexFile, this._indexCache);
4632
+ this._indexCache = cache;
4633
+ const merged = new Set(deletedIds);
4634
+ for (const manual of this._manualTombstones) {
4635
+ merged.add(manual);
4636
+ if (deletedIds.has(manual)) this._manualTombstones.delete(manual);
4637
+ }
4638
+ this._indexFileDeletedIds = deletedIds;
4639
+ this._indexDeletedIds = merged;
4640
+ return summaries;
4641
+ }
4642
+ /**
4643
+ * Merge close-time index rows with directory-scan results, keyed by id.
4644
+ * Scanned entries win — their metadata is re-derived from the transcript,
4645
+ * so it reflects mid-session activity that index rows (written on close)
4646
+ * cannot know about. Indexed-only ids fill gaps; duplicates within the
4647
+ * index resolve last-wins, matching append order.
4648
+ */
4649
+ mergeIndexWithScan(indexed, scanned, limit) {
4650
+ const byId = /* @__PURE__ */ new Map();
4651
+ for (const row of indexed) byId.set(row.id, row);
4652
+ for (const row of scanned) byId.set(row.id, row);
4653
+ return [...byId.values()].filter((row) => !this._indexDeletedIds.has(row.id)).sort(compareSessionSummaries).slice(0, limit);
4654
+ }
4655
+ /**
4656
+ * Rebuild the index from what is actually on disk.
4657
+ *
4658
+ * @returns the number of healthy, live entries in the rebuilt index. Both
4659
+ * backends report that same quantity: ids whose summary could not be derived
4660
+ * are excluded (the catalog counts them as `damaged`; the local scan drops
4661
+ * them when `summaryFor` rejects), and ids carrying a surviving tombstone are
4662
+ * excluded (the catalog rebuilds only from live files; the local branch skips
4663
+ * them explicitly). It is NOT a count of rows written to the file — tombstone
4664
+ * rows are persisted but never counted.
4665
+ */
4666
+ async rebuildIndex() {
4667
+ if (this.catalogClient) {
4668
+ const result = await this.catalogClient.call("rebuild_catalog", {}, { timeoutMs: 12e4 });
4669
+ return result.indexed;
4670
+ }
4671
+ return withFileLock(this.indexFile, async () => {
4672
+ await this.readIndex();
4673
+ const ids = await this.collectSessionIds(this.dir);
4674
+ const summaries = await Promise.all(ids.map((id) => this.summaryFor(id).catch(() => null)));
4675
+ const valid = summaries.filter((s) => s !== null);
4676
+ const parts = [];
4677
+ let tombstoned = 0;
4678
+ for (const s of valid) {
4679
+ if (this._indexDeletedIds.has(s.id)) {
4680
+ tombstoned++;
4681
+ continue;
4682
+ }
4683
+ parts.push(JSON.stringify(s));
4684
+ }
4685
+ for (const id of this._indexDeletedIds) {
4686
+ parts.push(JSON.stringify({ action: "delete", id }));
4687
+ }
4688
+ const lines = parts.join("\n") + "\n";
4689
+ await atomicWrite(this.indexFile, lines, { mode: 384 });
4690
+ this._indexCache = null;
4691
+ return valid.length - tombstoned;
4692
+ });
4693
+ }
4694
+ async listFromDirectoryScan(limit) {
4695
+ const shardKeys = await this.collectShardKeys();
4696
+ const shardEntries = await mapWithConcurrency(
4697
+ shardKeys,
4698
+ _DefaultSessionStore.LIST_SCAN_CONCURRENCY,
4699
+ async (shardKey) => await this.readOrBuildShardManifest(shardKey)
4700
+ );
4701
+ const out = [];
4702
+ for (const entry of shardEntries) {
4703
+ for (const summary of entry.summaries) {
4704
+ out.push({ summary, needsBackfill: false });
4705
+ }
4706
+ }
4707
+ out.sort((a, b) => compareSessionSummaries(a.summary, b.summary));
4708
+ const selected = out.slice(0, limit);
4709
+ const summaries = await mapWithConcurrency(
4710
+ selected,
4711
+ Math.min(_DefaultSessionStore.LIST_SCAN_CONCURRENCY, Math.max(1, limit)),
4712
+ async (candidate) => candidate.summary
4713
+ );
4714
+ return summaries.filter((s) => s !== null);
4715
+ }
4716
+ async collectShardKeys() {
4717
+ let entries;
4718
+ try {
4719
+ entries = await fsp19.readdir(this.dir, { withFileTypes: true });
4720
+ } catch {
4721
+ return [""];
4722
+ }
4723
+ const shardKeys = [""];
4724
+ for (const entry of entries) {
4725
+ if (shouldSkipSessionDirectoryEntry(entry.name)) continue;
4726
+ if (entry.isDirectory()) shardKeys.push(entry.name);
4727
+ }
4728
+ return shardKeys;
4729
+ }
4730
+ async readOrBuildShardManifest(shardKey) {
4731
+ const manifestPath = this.shardManifestPath(shardKey);
4732
+ const cached = await this.freshShardManifestCacheEntry(shardKey, manifestPath);
4733
+ if (cached) return cached;
4734
+ return withFileLock(manifestPath, async () => {
4735
+ const lockedCached = await this.freshShardManifestCacheEntry(shardKey, manifestPath);
4736
+ if (lockedCached) return lockedCached;
4737
+ const entry = await readOrBuildShardManifestEntry({
4738
+ shardKey,
4739
+ manifestPath,
4740
+ concurrency: _DefaultSessionStore.LIST_SCAN_CONCURRENCY,
4741
+ collectSessionFilesInShard: (key) => this.collectSessionFilesInShard(key),
4742
+ readSummaryManifest: (id) => this.readSummaryManifest(id),
4743
+ summaryHeaderFor: (ref) => this.summaryHeaderFor(ref),
4744
+ summaryFor: (id) => this.summaryFor(id)
4745
+ });
4746
+ try {
4747
+ const stat11 = await fsp19.stat(manifestPath);
4748
+ this.shardManifestCache.set(shardKey, {
4749
+ entry,
4750
+ mtimeMs: stat11.mtimeMs,
4751
+ size: stat11.size,
4752
+ ino: stat11.ino
4753
+ });
4754
+ } catch {
4755
+ this.shardManifestCache.delete(shardKey);
4756
+ }
4757
+ return entry;
4758
+ });
4759
+ }
4760
+ async freshShardManifestCacheEntry(shardKey, manifestPath) {
4761
+ const cached = this.shardManifestCache.get(shardKey);
4762
+ if (!cached) return void 0;
4763
+ try {
4764
+ const stat11 = await fsp19.stat(manifestPath);
4765
+ if (stat11.mtimeMs === cached.mtimeMs && stat11.size === cached.size && stat11.ino === cached.ino) {
4766
+ return cached.entry;
4767
+ }
4768
+ } catch {
4769
+ }
4770
+ this.shardManifestCache.delete(shardKey);
4771
+ return void 0;
4772
+ }
4773
+ async collectSessionFilesInShard(shardKey) {
4774
+ const dir = shardKey ? path11.join(this.dir, shardKey) : this.dir;
4775
+ const entries = await this.collectSessionFiles(dir, shardKey);
4776
+ return shardKey ? entries.filter((entry) => entry.id.startsWith(`${shardKey}/`)) : entries.filter((entry) => !entry.id.includes("/"));
4777
+ }
4778
+ async collectSessionFiles(dir, prefix = "", depth = 0) {
4779
+ return collectSessionFiles(dir, prefix, depth);
4780
+ }
4781
+ async collectSessionIds(dir, prefix = "", depth = 0) {
4782
+ return collectSessionIds(dir, prefix, depth);
4783
+ }
4784
+ async summaryFor(id) {
4785
+ const manifest = this.sessionPath(id, ".summary.json");
4786
+ const t0 = Date.now();
4787
+ let outcome = "success";
4788
+ let errorMsg;
4789
+ const fromManifest = await this.readSummaryManifest(id, t0);
4790
+ if (fromManifest) return fromManifest;
4791
+ try {
4792
+ const full = this.sessionPath(id, ".jsonl");
4793
+ const stat11 = await fsp19.stat(full);
4794
+ const summary = await this.summarize(id, stat11.mtime.toISOString());
4795
+ await atomicWrite(manifest, JSON.stringify(summary), { mode: 384 }).catch((err) => {
4796
+ const msg = toErrorMessage(err);
4797
+ emitSessionStoreError(this.events, id, manifest, "summary_fallback", msg, true);
4798
+ this.logWarn("Session manifest write failed", {
4799
+ event: "session_store.manifest_write_failed",
4800
+ sessionId: id,
4801
+ message: msg
4802
+ });
4803
+ });
4804
+ outcome = "failure";
4805
+ errorMsg = "summary fallback \u2014 manifest rebuilt";
4806
+ emitSessionStoreRead(
4807
+ this.events,
4808
+ id,
4809
+ manifest,
4810
+ "summary",
4811
+ outcome,
4812
+ Date.now() - t0,
4813
+ errorMsg
4814
+ );
4815
+ return summary;
4816
+ } catch (err) {
4817
+ outcome = "failure";
4818
+ errorMsg = toErrorMessage(err);
4819
+ emitSessionStoreRead(
4820
+ this.events,
4821
+ id,
4822
+ manifest,
4823
+ "summary",
4824
+ outcome,
4825
+ Date.now() - t0,
4826
+ errorMsg
4827
+ );
4828
+ return {
4829
+ id,
4830
+ title: "(damaged)",
4831
+ startedAt: (/* @__PURE__ */ new Date()).toISOString(),
4832
+ model: "unknown",
4833
+ provider: "unknown",
4834
+ tokenTotal: 0
4835
+ };
4836
+ }
4837
+ }
4838
+ async readSummaryManifest(id, startTime = Date.now()) {
4839
+ const manifest = this.sessionPath(id, ".summary.json");
4840
+ try {
4841
+ const raw = await fsp19.readFile(manifest, "utf8");
4842
+ emitSessionStoreRead(this.events, id, manifest, "summary", "success", Date.now() - startTime);
4843
+ return JSON.parse(raw);
4844
+ } catch {
4845
+ return null;
4846
+ }
4847
+ }
4848
+ async summaryHeaderFor(ref) {
4849
+ return readSessionSummaryHeader(ref, this.secretScrubber);
4850
+ }
4851
+ async deleteSession(id) {
4852
+ const jsonlPath = this.sessionPath(id, ".jsonl");
4853
+ await deleteSessionArtifacts({ rootDir: this.dir, id, jsonlPath });
4854
+ await this.writeTombstone(id);
4855
+ }
4856
+ async isEmpty(id) {
4857
+ const canonicalId = await this.resolveId(id);
4858
+ return isStrictlyEmptySessionFile(this.sessionPath(canonicalId, ".jsonl"));
4859
+ }
4860
+ async delete(id) {
4861
+ if (this.catalogClient) {
4862
+ const canonical = await this.resolveId(id);
4863
+ const lease = await this.catalogClient.call("acquire_maintenance", {
4864
+ sessionId: canonical,
4865
+ operation: "delete",
4866
+ holderId: this.maintenanceHolderId
4867
+ });
4868
+ try {
4869
+ await this.catalogClient.call("delete", { sessionId: canonical, lease });
4870
+ } catch (error) {
4871
+ await this.catalogClient.call("release_maintenance", { lease }).catch(() => void 0);
4872
+ throw error;
4873
+ }
4874
+ this.clearLoadCache(canonical);
4875
+ return;
4876
+ }
4877
+ await assertSessionCanBeDeleted(id, this.isSessionInUse);
4878
+ await this.deleteSession(id);
4879
+ }
4880
+ async rename(id, name) {
4881
+ if (this.catalogClient) {
4882
+ const canonical = await this.resolveId(id);
4883
+ const summary = await this.catalogClient.call("rename", {
4884
+ sessionId: canonical,
4885
+ name: sessionContentText(this.secretScrubber.scrub(name))
4886
+ });
4887
+ this.clearLoadCache(canonical);
4888
+ return summary;
4889
+ }
4890
+ const manifest = this.sessionPath(id, ".summary.json");
4891
+ const jsonlPath = this.sessionPath(id, ".jsonl");
4892
+ const updated = await executeRenameSession({
4893
+ id,
4894
+ name,
4895
+ manifest,
4896
+ jsonlPath,
4897
+ events: this.events,
4898
+ secretScrubber: this.secretScrubber,
4899
+ readSummaryManifest: (sid) => this.readSummaryManifest(sid),
4900
+ summaryFor: (sid) => this.summaryFor(sid),
4901
+ appendToIndexStrict: (sum) => this.appendToIndexStrict(sum)
4902
+ });
4903
+ this.clearLoadCache(id);
4904
+ return updated;
4905
+ }
4906
+ async prune(maxAgeDays = 30) {
4907
+ if (this.catalogClient) {
4908
+ return this.catalogClient.call("prune", {
4909
+ maxAgeDays,
4910
+ holderId: this.maintenanceHolderId
4911
+ });
4912
+ }
4913
+ const deleted = await pruneSessionFiles(this.dir, maxAgeDays, (id) => this.deleteSession(id));
4914
+ if (deleted > 0) {
4915
+ await this.compactIndex().catch(() => void 0);
4916
+ }
4917
+ return deleted;
4918
+ }
4919
+ async clearHistory(id) {
4920
+ const canonical = this.catalogClient ? await this.resolveId(id) : id;
4921
+ await executeClearSessionHistory({
4922
+ id,
4923
+ canonical,
4924
+ catalogClient: this.catalogClient,
4925
+ maintenanceHolderId: this.maintenanceHolderId,
4926
+ ensureShardDir: (sid) => this.ensureShardDir(sid),
4927
+ sessionPath: (sid, ext) => this.sessionPath(sid, ext)
4928
+ });
4929
+ this.clearLoadCache(canonical);
4930
+ if (id !== canonical) this.clearLoadCache(id);
4931
+ }
4932
+ async summarize(id, mtime) {
4933
+ return summarizeSessionFile({
4934
+ id,
4935
+ file: this.sessionPath(id, ".jsonl"),
4936
+ mtime,
4937
+ secretScrubber: this.secretScrubber
4938
+ });
4939
+ }
4940
+ };
4941
+
4942
+ export {
4943
+ loadDirectorState,
4944
+ DirectorStateCheckpoint,
4945
+ sessionContentPreview,
4946
+ userInputTitle,
4947
+ SessionCheckpointCas,
4948
+ sanitizeModel,
4949
+ generateSessionId,
4950
+ resolveSessionId,
4951
+ sessionIdResolutionError,
4952
+ scrubPersistedSessionEvent,
4953
+ scrubPersistedSessionData,
4954
+ scrubPersistedSessionSummary,
4955
+ extractInterruptedTools,
4956
+ SessionRecovery,
4957
+ DefaultSessionStore
4958
+ };
4959
+ //# sourceMappingURL=chunk-UIWONWVG.js.map