@wrongstack/core 0.273.0 → 0.274.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (64) hide show
  1. package/dist/{agent-bridge-BZ2enORi.d.ts → agent-bridge-DFo21wmY.d.ts} +1 -1
  2. package/dist/{agent-subagent-runner-ehb4xGvd.d.ts → agent-subagent-runner-BwmkIDEd.d.ts} +7 -7
  3. package/dist/{brain-BxN2k2HP.d.ts → brain-gfZX3the.d.ts} +11 -5
  4. package/dist/{provider-model-resolve-DFd3IPpw.d.ts → codex-catalog-CooZ6mOl.d.ts} +47 -4
  5. package/dist/{compactor-72ug-ZRB.d.ts → compactor-riTOds0f.d.ts} +1 -1
  6. package/dist/{config-C8IYxlO8.d.ts → config-BxcrDzri.d.ts} +60 -4
  7. package/dist/{context-Dw55zZ_Q.d.ts → context-DERiLofu.d.ts} +60 -1
  8. package/dist/coordination/index.d.ts +27 -16
  9. package/dist/coordination/index.js +1641 -1398
  10. package/dist/coordination/index.js.map +1 -1
  11. package/dist/defaults/index.d.ts +26 -26
  12. package/dist/defaults/index.js +2154 -1466
  13. package/dist/defaults/index.js.map +1 -1
  14. package/dist/execution/index.d.ts +15 -15
  15. package/dist/execution/index.js +77 -9
  16. package/dist/execution/index.js.map +1 -1
  17. package/dist/execution/prompt-enhancer.d.ts +1 -1
  18. package/dist/extension/index.d.ts +6 -6
  19. package/dist/{global-mailbox-C9dsc9Y_.d.ts → global-mailbox-Cr8TW4t_.d.ts} +1 -1
  20. package/dist/{goal-preamble-NhflDjYb.d.ts → goal-preamble-CEhROp1e.d.ts} +9 -9
  21. package/dist/{goal-store-Cx363x7Z.d.ts → goal-store-xNSVxmpV.d.ts} +1 -1
  22. package/dist/hq/index.d.ts +5 -5
  23. package/dist/{index-B7fHDt0B.d.ts → index-00KPKAlm.d.ts} +5 -5
  24. package/dist/{index-BbVprU-9.d.ts → index-pDBSBE1r.d.ts} +2 -2
  25. package/dist/index.d.ts +72 -45
  26. package/dist/index.js +2840 -1769
  27. package/dist/index.js.map +1 -1
  28. package/dist/infrastructure/index.d.ts +6 -6
  29. package/dist/kernel/index.d.ts +11 -11
  30. package/dist/kernel/index.js.map +1 -1
  31. package/dist/{mcp-servers-B6fSRNC1.d.ts → mcp-servers-BIwRiOxe.d.ts} +3 -3
  32. package/dist/models/index.d.ts +5 -5
  33. package/dist/models/index.js +40 -2
  34. package/dist/models/index.js.map +1 -1
  35. package/dist/{models-registry-4C6Wr91w.d.ts → models-registry-8OorW51H.d.ts} +1 -1
  36. package/dist/{multi-agent-coordinator-q1skFeNP.d.ts → multi-agent-coordinator-CNx48Zoz.d.ts} +1 -1
  37. package/dist/{null-fleet-bus-C9rrgQwc.d.ts → null-fleet-bus-B4ZUJYL6.d.ts} +6 -6
  38. package/dist/observability/index.d.ts +2 -2
  39. package/dist/{parallel-eternal-engine-CtXly2Sf.d.ts → parallel-eternal-engine-rsIclDqO.d.ts} +9 -9
  40. package/dist/{path-resolver-Bim6G5Jz.d.ts → path-resolver-BqU-fwzD.d.ts} +3 -3
  41. package/dist/{permission-CC7XFYWG.d.ts → permission-Dgs3v-Xq.d.ts} +1 -1
  42. package/dist/{permission-policy-cYR4RJmw.d.ts → permission-policy-Dtht2k0e.d.ts} +2 -2
  43. package/dist/{pipeline-CNVKuQDQ.d.ts → pipeline-B-dpCFYS.d.ts} +2 -2
  44. package/dist/{plan-templates-C4wXMmiM.d.ts → plan-templates-B7MxyY2o.d.ts} +58 -5
  45. package/dist/{provider-runner-BpM0mdBE.d.ts → provider-runner-DrmpBE5l.d.ts} +3 -3
  46. package/dist/{retry-policy-BV7nzeAd.d.ts → retry-policy-hYxsm10a.d.ts} +1 -1
  47. package/dist/sdd/index.d.ts +12 -10
  48. package/dist/sdd/index.js +7 -0
  49. package/dist/sdd/index.js.map +1 -1
  50. package/dist/{secret-vault-eMBKfheR.d.ts → secret-vault-DnqIFhPF.d.ts} +1 -1
  51. package/dist/security/index.d.ts +5 -5
  52. package/dist/{selector-C4ORTOid.d.ts → selector-D21RjDIg.d.ts} +1 -1
  53. package/dist/{session-event-bridge-CeNpUL9w.d.ts → session-event-bridge-Dt54CTvq.d.ts} +1 -1
  54. package/dist/{session-reader-BepLSnGL.d.ts → session-reader-CceH13Kq.d.ts} +5 -4
  55. package/dist/storage/index.d.ts +46 -12
  56. package/dist/storage/index.js +2281 -1476
  57. package/dist/storage/index.js.map +1 -1
  58. package/dist/tools/index.d.ts +2 -2
  59. package/dist/types/index.d.ts +19 -19
  60. package/dist/types/index.js +162 -42
  61. package/dist/types/index.js.map +1 -1
  62. package/dist/utils/index.d.ts +2 -2
  63. package/dist/{worktree-manager-BDuXTaWL.d.ts → worktree-manager-DUfBbKzk.d.ts} +1 -1
  64. package/package.json +1 -1
@@ -1,6 +1,6 @@
1
1
  import { randomUUID, createHash, randomBytes } from 'crypto';
2
- import * as fsp6 from 'fs/promises';
3
- import * as path5 from 'path';
2
+ import * as fsp7 from 'fs/promises';
3
+ import * as path6 from 'path';
4
4
  import { isAbsolute, resolve } from 'path';
5
5
  import * as os from 'os';
6
6
  import { hostname } from 'os';
@@ -154,17 +154,17 @@ function formatHumanPrompt(request) {
154
154
  return lines.join("\n");
155
155
  }
156
156
  async function atomicWrite(targetPath, content, opts = {}) {
157
- const dir = path5.dirname(targetPath);
158
- await fsp6.mkdir(dir, { recursive: true });
159
- const tmp = path5.join(dir, `.${path5.basename(targetPath)}.${randomBytes(6).toString("hex")}.tmp`);
157
+ const dir = path6.dirname(targetPath);
158
+ await fsp7.mkdir(dir, { recursive: true });
159
+ const tmp = path6.join(dir, `.${path6.basename(targetPath)}.${randomBytes(6).toString("hex")}.tmp`);
160
160
  try {
161
161
  if (typeof content === "string") {
162
- await fsp6.writeFile(tmp, content, { flag: "wx", encoding: opts.encoding ?? "utf8" });
162
+ await fsp7.writeFile(tmp, content, { flag: "wx", encoding: opts.encoding ?? "utf8" });
163
163
  } else {
164
- await fsp6.writeFile(tmp, content, { flag: "wx" });
164
+ await fsp7.writeFile(tmp, content, { flag: "wx" });
165
165
  }
166
166
  try {
167
- const fh = await fsp6.open(tmp, "r+");
167
+ const fh = await fsp7.open(tmp, "r+");
168
168
  try {
169
169
  await fh.sync();
170
170
  } finally {
@@ -174,50 +174,50 @@ async function atomicWrite(targetPath, content, opts = {}) {
174
174
  }
175
175
  let mode;
176
176
  try {
177
- const stat7 = await fsp6.stat(targetPath);
177
+ const stat7 = await fsp7.stat(targetPath);
178
178
  mode = stat7.mode & 511;
179
179
  } catch {
180
180
  mode = opts.mode;
181
181
  }
182
182
  if (mode !== void 0) {
183
- await fsp6.chmod(tmp, mode);
183
+ await fsp7.chmod(tmp, mode);
184
184
  }
185
185
  await renameWithRetry(tmp, targetPath);
186
186
  } catch (err) {
187
187
  try {
188
- await fsp6.unlink(tmp);
188
+ await fsp7.unlink(tmp);
189
189
  } catch {
190
190
  }
191
191
  throw err;
192
192
  }
193
193
  }
194
194
  async function ensureDir(dir) {
195
- await fsp6.mkdir(dir, { recursive: true });
195
+ await fsp7.mkdir(dir, { recursive: true });
196
196
  }
197
197
  async function withFileLock(targetPath, fn, opts = {}) {
198
- const dir = path5.dirname(targetPath);
199
- await fsp6.mkdir(dir, { recursive: true });
200
- const lockPath = path5.join(dir, `.${path5.basename(targetPath)}.lock`);
198
+ const dir = path6.dirname(targetPath);
199
+ await fsp7.mkdir(dir, { recursive: true });
200
+ const lockPath = path6.join(dir, `.${path6.basename(targetPath)}.lock`);
201
201
  const timeoutMs = opts.timeoutMs ?? 5e3;
202
202
  const staleMs = opts.staleMs ?? 3e4;
203
203
  const started = Date.now();
204
204
  let handle;
205
205
  for (; ; ) {
206
206
  try {
207
- handle = await fsp6.open(lockPath, "wx");
207
+ handle = await fsp7.open(lockPath, "wx");
208
208
  await handle.writeFile(`${process.pid}:${Date.now()}`);
209
209
  break;
210
210
  } catch (err) {
211
211
  const code = err.code;
212
212
  if (code === "ENOENT") {
213
- await fsp6.mkdir(dir, { recursive: true });
213
+ await fsp7.mkdir(dir, { recursive: true });
214
214
  continue;
215
215
  }
216
216
  if (code !== "EEXIST") throw err;
217
217
  try {
218
- const stat7 = await fsp6.stat(lockPath);
218
+ const stat7 = await fsp7.stat(lockPath);
219
219
  if (Date.now() - stat7.mtimeMs > staleMs) {
220
- await fsp6.unlink(lockPath);
220
+ await fsp7.unlink(lockPath);
221
221
  continue;
222
222
  }
223
223
  } catch {
@@ -237,7 +237,7 @@ async function withFileLock(targetPath, fn, opts = {}) {
237
237
  } catch {
238
238
  }
239
239
  try {
240
- await fsp6.unlink(lockPath);
240
+ await fsp7.unlink(lockPath);
241
241
  } catch {
242
242
  }
243
243
  }
@@ -245,14 +245,14 @@ async function withFileLock(targetPath, fn, opts = {}) {
245
245
  var TRANSIENT_RENAME_CODES = /* @__PURE__ */ new Set(["EPERM", "EBUSY", "EACCES", "ENOTEMPTY"]);
246
246
  async function renameWithRetry(from, to) {
247
247
  if (process.platform !== "win32") {
248
- await fsp6.rename(from, to);
248
+ await fsp7.rename(from, to);
249
249
  return;
250
250
  }
251
251
  const delays = [10, 25, 60, 120, 250];
252
252
  let lastErr;
253
253
  for (let i = 0; i <= delays.length; i++) {
254
254
  try {
255
- await fsp6.rename(from, to);
255
+ await fsp7.rename(from, to);
256
256
  return;
257
257
  } catch (err) {
258
258
  lastErr = err;
@@ -275,7 +275,7 @@ function toErrorMessage(err) {
275
275
  async function acquireDirectorStateLock(lockPath, processId = process.pid) {
276
276
  let existing;
277
277
  try {
278
- existing = await fsp6.readFile(lockPath, "utf8");
278
+ existing = await fsp7.readFile(lockPath, "utf8");
279
279
  } catch {
280
280
  }
281
281
  if (existing) {
@@ -299,7 +299,7 @@ async function acquireDirectorStateLock(lockPath, processId = process.pid) {
299
299
  }
300
300
  async function releaseDirectorStateLock(lockPath) {
301
301
  try {
302
- await fsp6.unlink(lockPath);
302
+ await fsp7.unlink(lockPath);
303
303
  } catch {
304
304
  }
305
305
  }
@@ -525,7 +525,7 @@ async function expandGlob(pattern) {
525
525
  async function walk(dir, pat) {
526
526
  let entries;
527
527
  try {
528
- entries = await fsp6.readdir(dir);
528
+ entries = await fsp7.readdir(dir);
529
529
  } catch {
530
530
  return;
531
531
  }
@@ -547,7 +547,7 @@ async function expandGlob(pattern) {
547
547
  for (const e of entries) {
548
548
  const full = `${dir}${SEP}${e}`;
549
549
  try {
550
- const stat7 = await fsp6.stat(full);
550
+ const stat7 = await fsp7.stat(full);
551
551
  if (stat7.isDirectory()) await walk(full, rest);
552
552
  } catch {
553
553
  }
@@ -565,7 +565,7 @@ async function expandGlob(pattern) {
565
565
  if (entries.includes(seg)) {
566
566
  const full = `${dir}${SEP}${seg}`;
567
567
  try {
568
- const stat7 = await fsp6.stat(full);
568
+ const stat7 = await fsp7.stat(full);
569
569
  if (stat7.isDirectory()) await walk(full, rest);
570
570
  } catch {
571
571
  }
@@ -673,8 +673,8 @@ function truncate(s, max) {
673
673
  return s.length <= max ? s : `${s.slice(0, max - 1)}\u2026`;
674
674
  }
675
675
  function projectSlug(absRoot) {
676
- const base = slugify(path5.basename(absRoot));
677
- const hash = createHash("sha256").update(path5.resolve(absRoot)).digest("hex").slice(0, 6);
676
+ const base = slugify(path6.basename(absRoot));
677
+ const hash = createHash("sha256").update(path6.resolve(absRoot)).digest("hex").slice(0, 6);
678
678
  return `${base}-${hash}`;
679
679
  }
680
680
  function slugify(name) {
@@ -682,8 +682,8 @@ function slugify(name) {
682
682
  }
683
683
  function wstackGlobalRoot() {
684
684
  const fromEnv = process.env["WRONGSTACK_HOME"];
685
- if (fromEnv && fromEnv.trim().length > 0) return path5.resolve(fromEnv);
686
- return path5.join(os.homedir(), ".wrongstack");
685
+ if (fromEnv && fromEnv.trim().length > 0) return path6.resolve(fromEnv);
686
+ return path6.join(os.homedir(), ".wrongstack");
687
687
  }
688
688
 
689
689
  // src/types/errors.ts
@@ -999,8 +999,8 @@ var CollabSession = class extends EventEmitter {
999
999
  for (const filePath of allFiles) {
1000
1000
  try {
1001
1001
  const [content, stat7] = await Promise.all([
1002
- fsp6.readFile(filePath, "utf8"),
1003
- fsp6.stat(filePath)
1002
+ fsp7.readFile(filePath, "utf8"),
1003
+ fsp7.stat(filePath)
1004
1004
  ]);
1005
1005
  const ext = filePath.split(".").pop() ?? "";
1006
1006
  const language = ext === "ts" || ext === "tsx" ? "typescript" : ext === "js" || ext === "jsx" ? "javascript" : ext === "md" ? "markdown" : ext === "json" ? "json" : void 0;
@@ -1422,7 +1422,7 @@ Emit each evaluation immediately. Do not wait until you have read all reports.`;
1422
1422
  for (const file of this.snapshot.files) {
1423
1423
  if (file.snapshotMtimeMs === void 0 && file.snapshotSizeBytes === void 0) continue;
1424
1424
  try {
1425
- const stat7 = await fsp6.stat(file.path);
1425
+ const stat7 = await fsp7.stat(file.path);
1426
1426
  const mtimeChanged = file.snapshotMtimeMs !== void 0 && stat7.mtimeMs > file.snapshotMtimeMs + 1;
1427
1427
  const sizeChanged = file.snapshotSizeBytes !== void 0 && stat7.size !== file.snapshotSizeBytes;
1428
1428
  if (mtimeChanged || sizeChanged) {
@@ -7235,7 +7235,7 @@ var Director = class _Director {
7235
7235
  this.fleetManager = opts.fleetManager;
7236
7236
  this.logger = opts.logger;
7237
7237
  if (this.sharedScratchpadPath) {
7238
- void fsp6.mkdir(this.sharedScratchpadPath, { recursive: true }).catch((err) => this.logShutdownError("shared_scratchpad_mkdir", err));
7238
+ void fsp7.mkdir(this.sharedScratchpadPath, { recursive: true }).catch((err) => this.logShutdownError("shared_scratchpad_mkdir", err));
7239
7239
  }
7240
7240
  this.transport = new InMemoryBridgeTransport();
7241
7241
  this.bridge = new InMemoryAgentBridge(
@@ -7835,7 +7835,7 @@ var Director = class _Director {
7835
7835
  })),
7836
7836
  usage: this.usage.snapshot()
7837
7837
  };
7838
- await fsp6.mkdir(path5.dirname(this.manifestPath), { recursive: true });
7838
+ await fsp7.mkdir(path6.dirname(this.manifestPath), { recursive: true });
7839
7839
  await atomicWrite(this.manifestPath, JSON.stringify(manifest, null, 2), { mode: 384 });
7840
7840
  return this.manifestPath;
7841
7841
  }
@@ -8066,10 +8066,10 @@ var Director = class _Director {
8066
8066
  */
8067
8067
  async readSession(subagentId, tail) {
8068
8068
  if (!this.sessionsRoot) return null;
8069
- const filePath = path5.join(this.sessionsRoot, this.directorRunId, `${subagentId}.jsonl`);
8069
+ const filePath = path6.join(this.sessionsRoot, this.directorRunId, `${subagentId}.jsonl`);
8070
8070
  let raw;
8071
8071
  try {
8072
- raw = await fsp6.readFile(filePath, "utf8");
8072
+ raw = await fsp7.readFile(filePath, "utf8");
8073
8073
  } catch {
8074
8074
  return null;
8075
8075
  }
@@ -8596,13 +8596,13 @@ async function readSubagentPartial(opts, subagentId) {
8596
8596
  if (!opts.sessionsRoot) return void 0;
8597
8597
  const candidates = [];
8598
8598
  if (opts.directorRunId) {
8599
- candidates.push(path5.join(opts.sessionsRoot, opts.directorRunId, `${subagentId}.jsonl`));
8599
+ candidates.push(path6.join(opts.sessionsRoot, opts.directorRunId, `${subagentId}.jsonl`));
8600
8600
  } else {
8601
8601
  try {
8602
- const entries = await fsp6.readdir(opts.sessionsRoot, { withFileTypes: true });
8602
+ const entries = await fsp7.readdir(opts.sessionsRoot, { withFileTypes: true });
8603
8603
  for (const entry of entries) {
8604
8604
  if (entry.isDirectory()) {
8605
- candidates.push(path5.join(opts.sessionsRoot, entry.name, `${subagentId}.jsonl`));
8605
+ candidates.push(path6.join(opts.sessionsRoot, entry.name, `${subagentId}.jsonl`));
8606
8606
  }
8607
8607
  }
8608
8608
  } catch {
@@ -8612,7 +8612,7 @@ async function readSubagentPartial(opts, subagentId) {
8612
8612
  for (const file of candidates) {
8613
8613
  let raw;
8614
8614
  try {
8615
- raw = await fsp6.readFile(file, "utf8");
8615
+ raw = await fsp7.readFile(file, "utf8");
8616
8616
  } catch {
8617
8617
  continue;
8618
8618
  }
@@ -8858,1456 +8858,1674 @@ function makeAgentSubagentRunner(opts) {
8858
8858
  function defaultFormatTaskInput(task) {
8859
8859
  return task.description ?? "";
8860
8860
  }
8861
- function sanitizeModel(model) {
8862
- return model.replace(/[^a-zA-Z0-9_-]/g, "-").replace(/-+/g, "-").replace(/^-|-$/g, "").slice(0, 40);
8863
- }
8864
- function generateSessionId(startedAt, model) {
8865
- const date = startedAt.slice(0, 10);
8866
- const time = startedAt.slice(11, 19).replace(/:/g, "-");
8867
- const suffix = randomBytes(2).toString("hex");
8868
- const modelPart = model ? `_${sanitizeModel(model)}` : "";
8869
- return `${date}/${time}Z${modelPart}_${suffix}`;
8861
+
8862
+ // src/storage/session-helpers.ts
8863
+ function userInputTitle(content) {
8864
+ const text = typeof content === "string" ? content : content.filter((b) => b.type === "text").map((b) => b.text).join(" ");
8865
+ return (text || "(non-text input)").slice(0, 60);
8870
8866
  }
8871
8867
 
8872
- // src/storage/session-store.ts
8873
- var DefaultSessionStore = class _DefaultSessionStore {
8874
- dir;
8868
+ // src/storage/file-session-writer.ts
8869
+ var FileSessionWriter = class _FileSessionWriter {
8870
+ constructor(id, handle, startedAt, meta, events, opts = {}, traceId) {
8871
+ this.id = id;
8872
+ this.handle = handle;
8873
+ this.startedAt = startedAt;
8874
+ this.meta = meta;
8875
+ this.events = events;
8876
+ this.resumed = opts.resumed ?? false;
8877
+ this.manifestFile = opts.dir ? path6.join(opts.dir, `${path6.basename(id)}.summary.json`) : "";
8878
+ this.filePath = opts.filePath ?? "";
8879
+ this.secretScrubber = opts.secretScrubber;
8880
+ this.onCloseCb = opts.onClose;
8881
+ this.summary = {
8882
+ id,
8883
+ title: "(empty session)",
8884
+ startedAt,
8885
+ model: meta.model ?? "unknown",
8886
+ provider: meta.provider ?? "unknown",
8887
+ tokenTotal: 0
8888
+ };
8889
+ this.traceId = traceId;
8890
+ }
8891
+ id;
8892
+ handle;
8893
+ startedAt;
8894
+ meta;
8875
8895
  events;
8876
- secretScrubber;
8896
+ closed = false;
8897
+ closePromise = null;
8898
+ manifestFile;
8899
+ summary;
8900
+ tokenIn = 0;
8901
+ tokenOut = 0;
8902
+ filePath;
8903
+ get transcriptPath() {
8904
+ return this.filePath || void 0;
8905
+ }
8877
8906
  /**
8878
- * In-memory cache for load() results, keyed by session ID. The cache is
8879
- * invalidated when the file's mtimeMs or size changes (indicating the
8880
- * file was written to). This eliminates redundant full-file reads and
8881
- * JSON parses when the same session is loaded multiple times within the
8882
- * store's lifetime (e.g., webui session detail views, list() fallbacks).
8883
- *
8884
- * Max size is capped to prevent unbounded memory growth in long-running
8885
- * processes. When the limit is reached, the oldest entry is evicted.
8907
+ * Lazy session_start/session_resumed init, shared by all appenders.
8908
+ * A single promise (not a boolean) so a second append racing the first
8909
+ * can't push its event into the buffer BEFORE the first append's event —
8910
+ * every appender awaits the same init and resumes in FIFO call order.
8886
8911
  */
8887
- _loadCache = /* @__PURE__ */ new Map();
8888
- _indexCache = null;
8889
- static LOAD_CACHE_MAX_ENTRIES = 50;
8890
- static LIST_SCAN_CONCURRENCY = 32;
8891
- constructor(opts) {
8892
- this.dir = opts.dir;
8893
- this.events = opts.events;
8894
- this.secretScrubber = opts.secretScrubber;
8912
+ initPromise = null;
8913
+ ensureInit() {
8914
+ if (!this.initPromise) this.initPromise = this.writeSessionStartLazy();
8915
+ return this.initPromise;
8916
+ }
8917
+ resumed;
8918
+ appendFailCount = 0;
8919
+ lastAppendWarnAt = 0;
8920
+ secretScrubber;
8921
+ onCloseCb;
8922
+ /** Implements SessionWriter.traceId — propagated from ContextInit.traceId. */
8923
+ traceId;
8924
+ // ── Write buffer — batches events to reduce per-event disk I/O ──────────────
8925
+ //
8926
+ // Every append() pushes the scrubbed event into an in-memory buffer instead
8927
+ // of calling handle.appendFile() synchronously. The buffer flushes to disk
8928
+ // when it reaches FLUSH_SIZE events OR after FLUSH_INTERVAL_MS of inactivity.
8929
+ // This cuts the number of disk writes by ~95% without changing the on-disk
8930
+ // format — the JSONL is still one JSON object per line.
8931
+ writeBuffer = [];
8932
+ flushTimer = null;
8933
+ static FLUSH_INTERVAL_MS = 500;
8934
+ static FLUSH_SIZE = 50;
8935
+ // ── Write serialization ─────────────────────────────────────────────────────
8936
+ //
8937
+ // All disk writes are funneled through a FIFO promise chain. Without it,
8938
+ // a timer-driven flush racing an explicit flush()/close() issues two
8939
+ // concurrent appendFile() calls on the shared O_APPEND handle — the kernel
8940
+ // may complete them out of order (chronology breaks) or, for large
8941
+ // batches, interleave partial writes (torn JSONL lines). The chain keeps
8942
+ // exactly one write in flight; failures don't break the chain.
8943
+ writeChain = Promise.resolve();
8944
+ /** Enqueue a write on the FIFO chain. Resolves/rejects with that write. */
8945
+ enqueueWrite(data) {
8946
+ const write = this.writeChain.then(() => this.handle.appendFile(data, "utf8"));
8947
+ this.writeChain = write.then(
8948
+ () => void 0,
8949
+ () => void 0
8950
+ );
8951
+ return write;
8895
8952
  }
8953
+ // ── Enriched summary tracking ───────────────────────────────────────────────
8954
+ iterationCount = 0;
8955
+ toolCallCount = 0;
8956
+ toolErrorCount = 0;
8957
+ toolBreakdown = {};
8958
+ fileChangeCount = 0;
8959
+ compactionCount = 0;
8960
+ outcome = void 0;
8896
8961
  /**
8897
- * Clear the load() cache. Useful for testing or when the caller knows
8898
- * the file has changed externally (e.g., another process wrote to it).
8962
+ * Scrub secrets out of conversation-turn events before they are observed
8963
+ * for the summary, written to the JSONL log, or surfaced on resume. Only
8964
+ * `user_input` / `llm_response` carry free-form user/model text; other event
8965
+ * types either have no secret-bearing content or are already scrubbed
8966
+ * upstream (tool results). Returns the event unchanged when no scrubber is
8967
+ * configured.
8899
8968
  */
8900
- clearLoadCache(sessionId) {
8901
- if (sessionId !== void 0) {
8902
- this._loadCache.delete(sessionId);
8903
- } else {
8904
- this._loadCache.clear();
8969
+ scrubEvent(event) {
8970
+ const s = this.secretScrubber;
8971
+ if (!s) return event;
8972
+ if (event.type === "user_input") {
8973
+ return {
8974
+ ...event,
8975
+ content: typeof event.content === "string" ? s.scrub(event.content) : s.scrubObject(event.content)
8976
+ };
8977
+ }
8978
+ if (event.type === "llm_response") {
8979
+ return { ...event, content: s.scrubObject(event.content) };
8905
8980
  }
8981
+ return event;
8906
8982
  }
8907
- // ── Storage event helpers ───────────────────────────────────────────────────
8908
- emitRead(sessionId, filePath, operation, outcome, durationMs, error) {
8909
- this.events?.emit("storage.read", {
8910
- sessionId,
8911
- store: "session",
8912
- filePath,
8913
- operation,
8914
- outcome,
8915
- durationMs,
8916
- ...error !== void 0 ? { error } : {}
8917
- });
8983
+ pendingFileSnapshots = [];
8984
+ /** Tracks open tool_use IDs during the current run to serialize on close for resume. */
8985
+ openToolUses = /* @__PURE__ */ new Set();
8986
+ recordFileChange(input) {
8987
+ this.pendingFileSnapshots.push(input);
8918
8988
  }
8919
- emitWrite(sessionId, filePath, operation, outcome, durationMs, eventCount, error) {
8920
- this.events?.emit("storage.write", {
8921
- sessionId,
8922
- store: "session",
8923
- filePath,
8924
- operation,
8925
- outcome,
8926
- durationMs,
8927
- ...eventCount !== void 0 ? { eventCount } : {},
8928
- ...error !== void 0 ? { error } : {}
8929
- });
8989
+ get pendingToolUses() {
8990
+ return Array.from(this.openToolUses);
8930
8991
  }
8931
- emitError(sessionId, filePath, operation, error, recoverable) {
8932
- this.events?.emit("storage.error", {
8933
- sessionId,
8934
- store: "session",
8935
- filePath,
8936
- operation,
8937
- error,
8938
- recoverable
8939
- });
8992
+ async writeSessionStartLazy() {
8993
+ const record = `${JSON.stringify({
8994
+ type: this.resumed ? "session_resumed" : "session_start",
8995
+ ts: this.startedAt,
8996
+ id: this.id,
8997
+ model: this.meta.model ?? "unknown",
8998
+ provider: this.meta.provider ?? "unknown"
8999
+ })}
9000
+ `;
9001
+ try {
9002
+ await this.enqueueWrite(record);
9003
+ } catch {
9004
+ }
8940
9005
  }
8941
- /** Absolute path to the session index file. */
8942
- get indexFile() {
8943
- return path5.join(this.dir, "_index.jsonl");
9006
+ async append(event) {
9007
+ if (this.closed) return;
9008
+ await this.ensureInit();
9009
+ const scrubbed = this.scrubEvent(event);
9010
+ this.observeForSummary(scrubbed);
9011
+ this.writeBuffer.push(scrubbed);
9012
+ if (this.writeBuffer.length >= _FileSessionWriter.FLUSH_SIZE) {
9013
+ if (this.flushTimer) {
9014
+ clearTimeout(this.flushTimer);
9015
+ this.flushTimer = null;
9016
+ }
9017
+ await this.flushBuffer();
9018
+ } else {
9019
+ this.scheduleFlush();
9020
+ }
8944
9021
  }
8945
- /** Join session ID to its absolute path within the store directory. */
8946
- sessionPath(id, ext) {
8947
- return path5.join(this.dir, `${id}${ext}`);
9022
+ async appendBatch(events) {
9023
+ if (this.closed || events.length === 0) return;
9024
+ await this.ensureInit();
9025
+ for (const event of events) {
9026
+ const scrubbed = this.scrubEvent(event);
9027
+ this.observeForSummary(scrubbed);
9028
+ this.writeBuffer.push(scrubbed);
9029
+ }
9030
+ if (this.writeBuffer.length >= _FileSessionWriter.FLUSH_SIZE) {
9031
+ if (this.flushTimer) {
9032
+ clearTimeout(this.flushTimer);
9033
+ this.flushTimer = null;
9034
+ }
9035
+ await this.flushBuffer();
9036
+ } else {
9037
+ this.scheduleFlush();
9038
+ }
8948
9039
  }
8949
9040
  /**
8950
- * Ensure the directory implied by the session ID exists. When the ID
8951
- * contains a date prefix like `2026-06-06/...`, this creates the date
8952
- * subdirectory so sessions group naturally by day.
9041
+ * Flush buffered events to disk immediately. Critical events
9042
+ * (user_input, llm_response) call this so they survive SIGKILL/crash
9043
+ * instead of sitting in the in-memory buffer for up to 500ms.
9044
+ *
9045
+ * Idempotent — cancels any pending timer and writes whatever has
9046
+ * accumulated in the buffer. Safe to call even when the buffer
9047
+ * is empty (no-op).
8953
9048
  */
8954
- async ensureShardDir(id) {
8955
- const dirPath = path5.dirname(path5.join(this.dir, id));
8956
- await ensureDir(dirPath);
8957
- return dirPath;
8958
- }
8959
- async create(meta) {
8960
- const startedAt = (/* @__PURE__ */ new Date()).toISOString();
8961
- const id = meta.id && meta.id.length > 0 ? meta.id : generateSessionId(startedAt, meta.model ?? meta.provider);
8962
- const shardDir = await this.ensureShardDir(id);
8963
- const file = path5.join(shardDir, `${path5.basename(id)}.jsonl`);
8964
- const t0 = Date.now();
8965
- let handle;
8966
- try {
8967
- handle = await fsp6.open(file, "a", 384);
8968
- } catch (err) {
8969
- this.emitError(id, file, "create", toErrorMessage(err), false);
8970
- throw new Error(
8971
- `Failed to open session file: ${toErrorMessage(err)}`,
8972
- { cause: err }
8973
- );
8974
- }
8975
- try {
8976
- const writer = new FileSessionWriter(id, handle, startedAt, meta, this.events, {
8977
- dir: shardDir,
8978
- filePath: file,
8979
- secretScrubber: this.secretScrubber,
8980
- onClose: (s) => this.appendToIndex(s)
8981
- });
8982
- this.emitWrite(id, file, "create", "success", Date.now() - t0);
8983
- return writer;
8984
- } catch (err) {
8985
- await handle.close().catch((e) => console.warn(JSON.stringify({
8986
- level: "warn",
8987
- event: "session_store.handle_close_failed",
8988
- message: e instanceof Error ? e.message : String(e),
8989
- timestamp: (/* @__PURE__ */ new Date()).toISOString()
8990
- })));
8991
- this.emitError(id, file, "create", toErrorMessage(err), true);
8992
- throw err;
9049
+ async flush() {
9050
+ if (this.flushTimer) {
9051
+ clearTimeout(this.flushTimer);
9052
+ this.flushTimer = null;
8993
9053
  }
9054
+ await this.flushBuffer();
8994
9055
  }
8995
- async resume(id) {
8996
- const file = this.sessionPath(id, ".jsonl");
8997
- const t0 = Date.now();
8998
- const data = await this.load(id);
8999
- let handle;
9000
- try {
9001
- handle = await fsp6.open(file, "a", 384);
9002
- } catch (err) {
9003
- this.emitError(id, file, "resume", toErrorMessage(err), false);
9004
- throw new Error(
9005
- `Failed to open session "${id}" for append: ${toErrorMessage(err)}`,
9006
- { cause: err }
9007
- );
9008
- }
9009
- try {
9010
- const writer = new FileSessionWriter(
9011
- id,
9012
- handle,
9013
- (/* @__PURE__ */ new Date()).toISOString(),
9014
- {
9015
- id,
9016
- model: data.metadata.model,
9017
- provider: data.metadata.provider
9018
- },
9019
- this.events,
9020
- {
9021
- resumed: true,
9022
- // Shard directory (sessions/<date>/) — must match create() so the
9023
- // .summary.json sidecar lands next to the JSONL instead of the
9024
- // sessions root (where summaryFor() would never find it).
9025
- dir: path5.dirname(file),
9026
- filePath: file,
9027
- secretScrubber: this.secretScrubber,
9028
- onClose: (s) => this.appendToIndex(s)
9029
- }
9030
- );
9031
- this.emitWrite(id, file, "resume", "success", Date.now() - t0);
9032
- return { writer, data };
9033
- } catch (err) {
9034
- await handle.close().catch((e) => console.warn(JSON.stringify({
9035
- level: "warn",
9036
- event: "session_store.handle_close_failed",
9037
- message: e instanceof Error ? e.message : String(e),
9038
- timestamp: (/* @__PURE__ */ new Date()).toISOString()
9039
- })));
9040
- this.emitError(id, file, "resume", toErrorMessage(err), true);
9041
- throw err;
9042
- }
9056
+ /** Schedule a deferred flush. No-op if a timer is already pending. */
9057
+ scheduleFlush() {
9058
+ if (this.flushTimer) return;
9059
+ this.flushTimer = setTimeout(() => {
9060
+ this.flushTimer = null;
9061
+ this.flushBuffer().catch(() => {
9062
+ });
9063
+ }, _FileSessionWriter.FLUSH_INTERVAL_MS);
9043
9064
  }
9044
- async load(id) {
9045
- const file = this.sessionPath(id, ".jsonl");
9065
+ /**
9066
+ * Flush all buffered events to disk as a single appendFile call.
9067
+ * Errors use the same throttled-warning pattern the old per-event
9068
+ * append path used — one warning every 5s with a suppressed count.
9069
+ * On failure the buffer is cleared (events are best-effort, same as
9070
+ * the old per-event path where a failed write was silently dropped).
9071
+ */
9072
+ async flushBuffer() {
9073
+ if (this.writeBuffer.length === 0) return;
9074
+ const eventCount = this.writeBuffer.length;
9075
+ const batch = this.writeBuffer.map((e) => JSON.stringify(e)).join("\n") + "\n";
9076
+ this.writeBuffer = [];
9046
9077
  const t0 = Date.now();
9047
9078
  let outcome = "success";
9048
9079
  let errorMsg;
9049
- let cacheHit = false;
9050
9080
  try {
9051
- const s = await fsp6.stat(file);
9052
- const stat7 = { mtimeMs: s.mtimeMs, size: s.size };
9053
- const cached = this._loadCache.get(id);
9054
- if (cached && cached.mtimeMs === stat7.mtimeMs && cached.size === stat7.size) {
9055
- cacheHit = true;
9056
- this._loadCache.delete(id);
9057
- this._loadCache.set(id, cached);
9058
- return cached.data;
9059
- }
9060
- const raw = await fsp6.readFile(file, "utf8");
9061
- const lines = raw.split("\n").filter((l) => l.trim());
9062
- const events = [];
9063
- let sessionStartEvent;
9064
- let sessionEndEvent;
9065
- let sessionModel;
9066
- let sessionProvider;
9067
- let sessionPendingToolUses;
9068
- const messages = [];
9069
- const openToolUses = /* @__PURE__ */ new Set();
9070
- let usage = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 };
9071
- for (const line of lines) {
9072
- try {
9073
- const parsed = JSON.parse(line);
9074
- if (parsed !== null && typeof parsed === "object" && typeof parsed.type === "string" && typeof parsed.ts === "string") {
9075
- const ev = parsed;
9076
- events.push(ev);
9077
- if (ev.type === "session_start" && !sessionStartEvent) {
9078
- sessionStartEvent = ev;
9079
- sessionModel = ev.model;
9080
- sessionProvider = ev.provider;
9081
- }
9082
- if (ev.type === "session_end") {
9083
- sessionEndEvent = ev;
9084
- sessionPendingToolUses = ev.pendingToolUses;
9085
- }
9086
- if (ev.type === "user_input") {
9087
- openToolUses.clear();
9088
- messages.push({ role: "user", content: ev.content, ts: ev.ts });
9089
- } else if (ev.type === "llm_response") {
9090
- messages.push({ role: "assistant", content: ev.content, ts: ev.ts });
9091
- for (const b of ev.content) {
9092
- if (b.type === "tool_use") openToolUses.add(b.id);
9093
- }
9094
- usage = {
9095
- input: usage.input + (ev.usage.input ?? 0),
9096
- output: usage.output + (ev.usage.output ?? 0),
9097
- cacheRead: (usage.cacheRead ?? 0) + (ev.usage.cacheRead ?? 0),
9098
- cacheWrite: (usage.cacheWrite ?? 0) + (ev.usage.cacheWrite ?? 0)
9099
- };
9100
- } else if (ev.type === "tool_result") {
9101
- if (!openToolUses.has(ev.id)) {
9102
- this.events?.emit("session.damaged", {
9103
- sessionId: id,
9104
- detail: `Orphan tool_result "${ev.id}" has no matching tool_use`
9105
- });
9106
- continue;
9107
- }
9108
- openToolUses.delete(ev.id);
9109
- const resultBlock = {
9110
- type: "tool_result",
9111
- tool_use_id: ev.id,
9112
- content: typeof ev.content === "string" ? ev.content : JSON.stringify(ev.content),
9113
- is_error: ev.isError
9114
- };
9115
- const last = messages[messages.length - 1];
9116
- const lastIsToolResultUser = last?.role === "user" && Array.isArray(last.content) && last.content.every((b) => b.type === "tool_result");
9117
- if (lastIsToolResultUser && Array.isArray(last.content)) {
9118
- last.content.push(resultBlock);
9119
- } else {
9120
- messages.push({ role: "user", content: [resultBlock], ts: ev.ts });
9121
- }
9122
- }
9123
- }
9124
- } catch {
9125
- }
9126
- }
9127
- if (openToolUses.size > 0) {
9128
- this.events?.emit("session.damaged", {
9129
- sessionId: id,
9130
- detail: `${openToolUses.size} tool_use blocks without matching results - replay repaired`
9131
- });
9132
- }
9133
- const repaired = repairToolUseAdjacency(messages);
9134
- if (repaired.report.changed) {
9135
- this.events?.emit("session.damaged", {
9136
- sessionId: id,
9137
- detail: `Repaired replay adjacency: removed ${repaired.report.removedToolUses.length} tool_use, ${repaired.report.removedToolResults.length} tool_result, ${repaired.report.removedMessages} empty messages`
9138
- });
9139
- }
9140
- const meta = {
9141
- id,
9142
- startedAt: sessionStartEvent?.ts ?? (/* @__PURE__ */ new Date(0)).toISOString(),
9143
- endedAt: sessionEndEvent?.ts,
9144
- model: sessionModel,
9145
- provider: sessionProvider,
9146
- pendingToolUses: sessionPendingToolUses
9147
- };
9148
- const toolCallEnds = extractToolCallEnds(events);
9149
- const data = { metadata: meta, events, messages: repaired.messages, usage, toolCallEnds };
9150
- if (this._loadCache.size >= _DefaultSessionStore.LOAD_CACHE_MAX_ENTRIES) {
9151
- const oldest = this._loadCache.keys().next().value;
9152
- if (oldest !== void 0) {
9153
- this._loadCache.delete(oldest);
9154
- }
9155
- }
9156
- this._loadCache.set(id, { mtimeMs: stat7.mtimeMs, size: stat7.size, data });
9157
- return data;
9081
+ await this.enqueueWrite(batch);
9158
9082
  } catch (err) {
9159
9083
  outcome = "failure";
9160
9084
  errorMsg = toErrorMessage(err);
9161
- throw err;
9162
- } finally {
9163
- this.emitRead(id, file, "load", outcome, Date.now() - t0, errorMsg);
9164
- if (cacheHit) {
9165
- this.events?.emit("storage.cache_hit", {
9166
- sessionId: id,
9167
- store: "session",
9168
- filePath: file,
9169
- operation: "load",
9170
- durationMs: Date.now() - t0
9171
- });
9085
+ this.appendFailCount += eventCount;
9086
+ const now = Date.now();
9087
+ if (now - this.lastAppendWarnAt > 5e3) {
9088
+ const suppressed = this.appendFailCount - 1;
9089
+ const tail = suppressed > 0 ? ` (+${suppressed} suppressed)` : "";
9090
+ console.warn(
9091
+ "[session] flush failed:",
9092
+ toErrorMessage(err),
9093
+ tail
9094
+ );
9095
+ this.lastAppendWarnAt = now;
9096
+ this.appendFailCount = 0;
9172
9097
  }
9098
+ } finally {
9099
+ this.events?.emit("storage.write", {
9100
+ sessionId: this.id,
9101
+ store: "session",
9102
+ filePath: this.filePath,
9103
+ operation: "flush",
9104
+ outcome,
9105
+ durationMs: Date.now() - t0,
9106
+ ...errorMsg !== void 0 ? { error: errorMsg } : {},
9107
+ ...eventCount !== void 0 ? { eventCount } : {},
9108
+ ...this.traceId !== void 0 ? { traceId: this.traceId } : {}
9109
+ });
9173
9110
  }
9174
9111
  }
9175
- async list(limit = 20) {
9176
- try {
9177
- await ensureDir(this.dir);
9178
- const indexed = await this.readIndex();
9179
- if (indexed.length > 0) {
9180
- indexed.sort((a, b) => {
9181
- if (a.startedAt < b.startedAt) return 1;
9182
- if (a.startedAt > b.startedAt) return -1;
9183
- return a.id.localeCompare(b.id);
9184
- });
9185
- return indexed.slice(0, limit);
9112
+ observeForSummary(event) {
9113
+ if (event.type === "llm_response") {
9114
+ for (const block of event.content) {
9115
+ if (block.type === "tool_use") this.openToolUses.add(block.id);
9186
9116
  }
9187
- return await this.listFromDirectoryScan(limit);
9188
- } catch {
9189
- return [];
9190
9117
  }
9191
- }
9192
- // ── Session index (_index.jsonl) ─────────────────────────────────────────
9193
- //
9194
- // One JSON line per closed session, appended atomically on close().
9195
- // When a session is deleted, a tombstone {action:"delete",id:"..."} is
9196
- // appended. On read, tombstones filter out matching session entries.
9197
- // This keeps listing O(lines-in-index) instead of O(files-on-disk).
9198
- //
9199
- // The index auto-compacts every N appends to prevent unbounded growth
9200
- // from tombstones and duplicate entries (resume cycles).
9201
- indexAppendCount = 0;
9202
- static COMPACT_EVERY = 30;
9203
- /** Append a session summary to the index. */
9204
- async appendToIndex(summary) {
9205
- try {
9206
- await ensureDir(this.dir);
9207
- const line = JSON.stringify(summary) + "\n";
9208
- await fsp6.appendFile(this.indexFile, line, "utf8");
9209
- this._indexCache = null;
9210
- this.indexAppendCount++;
9211
- if (this.indexAppendCount >= _DefaultSessionStore.COMPACT_EVERY) {
9212
- await this.compactIndex();
9213
- this.indexAppendCount = 0;
9118
+ if (event.type === "tool_use") {
9119
+ this.openToolUses.add(event.id);
9120
+ } else if (event.type === "tool_call_start") {
9121
+ this.toolCallCount++;
9122
+ this.toolBreakdown[event.name] = (this.toolBreakdown[event.name] ?? 0) + 1;
9123
+ } else if (event.type === "tool_result") {
9124
+ this.openToolUses.delete(event.id);
9125
+ if (event.isError) {
9126
+ this.toolErrorCount++;
9127
+ this.outcome = "error";
9214
9128
  }
9215
- } catch {
9129
+ } else if (event.type === "file_snapshot") {
9130
+ this.fileChangeCount += event.files.length;
9131
+ } else if (event.type === "compaction") {
9132
+ this.compactionCount++;
9216
9133
  }
9217
- }
9218
- /** Append a tombstone entry for a deleted session. */
9219
- async writeTombstone(id) {
9220
- try {
9221
- await ensureDir(this.dir);
9222
- const line = JSON.stringify({ action: "delete", id }) + "\n";
9223
- await fsp6.appendFile(this.indexFile, line, "utf8");
9224
- this._indexCache = null;
9225
- this.indexAppendCount++;
9226
- } catch {
9134
+ if (event.type === "error" || event.type === "provider_error") {
9135
+ this.outcome = "error";
9136
+ }
9137
+ if (event.type === "user_input" && this.summary.title === "(empty session)") {
9138
+ this.summary = { ...this.summary, title: userInputTitle(event.content) };
9139
+ } else if (event.type === "llm_response") {
9140
+ this.tokenIn += event.usage.input;
9141
+ this.tokenOut += event.usage.output;
9142
+ this.summary = { ...this.summary, tokenTotal: this.tokenIn + this.tokenOut };
9143
+ } else if (event.type === "session_end") {
9144
+ const total = event.usage.input + event.usage.output;
9145
+ if (total > 0) this.summary = { ...this.summary, tokenTotal: total };
9146
+ } else if (event.type === "in_flight_start") {
9147
+ this.iterationCount++;
9227
9148
  }
9228
9149
  }
9229
- /**
9230
- * Compact the index: read all entries, drop tombstones, deduplicate
9231
- * (keep latest per session), and rewrite. Atomic via temp+rename.
9232
- */
9233
- async compactIndex() {
9234
- const t0 = Date.now();
9235
- let outcome = "success";
9236
- let errorMsg;
9150
+ async close() {
9151
+ if (this.closePromise) return this.closePromise;
9152
+ this.closePromise = this.doClose();
9153
+ return this.closePromise;
9154
+ }
9155
+ async doClose() {
9156
+ this.closed = true;
9157
+ if (this.flushTimer) {
9158
+ clearTimeout(this.flushTimer);
9159
+ this.flushTimer = null;
9160
+ }
9161
+ await this.flushBuffer();
9162
+ await this.writeChain;
9163
+ this.summary = {
9164
+ ...this.summary,
9165
+ endedAt: (/* @__PURE__ */ new Date()).toISOString(),
9166
+ iterationCount: this.iterationCount,
9167
+ toolCallCount: this.toolCallCount,
9168
+ toolErrorCount: this.toolErrorCount,
9169
+ fileChangeCount: this.fileChangeCount,
9170
+ compactionCount: this.compactionCount > 0 ? this.compactionCount : void 0,
9171
+ toolBreakdown: { ...this.toolBreakdown },
9172
+ outcome: this.outcome ?? "completed"
9173
+ };
9174
+ if (this.manifestFile) {
9175
+ const t0 = Date.now();
9176
+ let outcome = "success";
9177
+ let errorMsg;
9178
+ try {
9179
+ await atomicWrite(this.manifestFile, JSON.stringify(this.summary), { mode: 384 });
9180
+ } catch (err) {
9181
+ outcome = "failure";
9182
+ errorMsg = toErrorMessage(err);
9183
+ } finally {
9184
+ this.events?.emit("storage.write", {
9185
+ sessionId: this.id,
9186
+ store: "session",
9187
+ filePath: this.manifestFile,
9188
+ operation: "close",
9189
+ outcome,
9190
+ durationMs: Date.now() - t0,
9191
+ ...errorMsg !== void 0 ? { error: errorMsg } : {},
9192
+ ...this.traceId !== void 0 ? { traceId: this.traceId } : {}
9193
+ });
9194
+ }
9195
+ }
9196
+ const idxT0 = Date.now();
9197
+ let idxOutcome = "success";
9198
+ let idxError;
9237
9199
  try {
9238
- const entries = await this.readIndex();
9239
- if (entries.length === 0) return;
9240
- const tmp = `${this.indexFile}.compact.tmp`;
9241
- const lines = entries.map((s) => JSON.stringify(s)).join("\n") + "\n";
9242
- await fsp6.writeFile(tmp, lines, "utf8");
9243
- await fsp6.rename(tmp, this.indexFile);
9244
- this._indexCache = null;
9200
+ await this.onCloseCb?.(this.summary);
9245
9201
  } catch (err) {
9246
- outcome = "failure";
9247
- errorMsg = toErrorMessage(err);
9202
+ idxOutcome = "failure";
9203
+ idxError = toErrorMessage(err);
9248
9204
  } finally {
9249
- this.emitWrite("~compact~", this.indexFile, "compact", outcome, Date.now() - t0, void 0, errorMsg);
9205
+ this.events?.emit("storage.write", {
9206
+ sessionId: this.summary.id,
9207
+ store: "session",
9208
+ filePath: this.filePath,
9209
+ operation: "index_append",
9210
+ outcome: idxOutcome,
9211
+ durationMs: Date.now() - idxT0,
9212
+ ...idxError !== void 0 ? { error: idxError } : {},
9213
+ ...this.traceId !== void 0 ? { traceId: this.traceId } : {}
9214
+ });
9250
9215
  }
9251
- }
9252
- /**
9253
- * Read the index file and return deduplicated session summaries.
9254
- * Entries with a matching tombstone are filtered out.
9255
- * Returns empty array when the index doesn't exist or is corrupt.
9256
- */
9257
- async readIndex() {
9258
- let stat7;
9259
9216
  try {
9260
- const s = await fsp6.stat(this.indexFile);
9261
- stat7 = { mtimeMs: s.mtimeMs, size: s.size };
9217
+ await this.handle.close();
9262
9218
  } catch {
9263
- this._indexCache = null;
9264
- return [];
9265
9219
  }
9266
- if (this._indexCache !== null && this._indexCache.mtimeMs === stat7.mtimeMs && this._indexCache.size === stat7.size) {
9267
- return [...this._indexCache.summaries];
9220
+ }
9221
+ async writeCheckpoint(promptIndex, promptPreview) {
9222
+ const fileCount = this.pendingFileSnapshots.length;
9223
+ if (fileCount > 0) {
9224
+ await this.writeFileSnapshot(promptIndex, [...this.pendingFileSnapshots]);
9225
+ this.pendingFileSnapshots = [];
9268
9226
  }
9269
- let raw;
9270
- try {
9271
- raw = await fsp6.readFile(this.indexFile, "utf8");
9272
- } catch {
9273
- this._indexCache = null;
9274
- return [];
9227
+ await this.append({
9228
+ type: "checkpoint",
9229
+ ts: (/* @__PURE__ */ new Date()).toISOString(),
9230
+ promptIndex,
9231
+ promptPreview
9232
+ });
9233
+ this.events?.emit("checkpoint.written", {
9234
+ promptIndex,
9235
+ promptPreview,
9236
+ ts: (/* @__PURE__ */ new Date()).toISOString(),
9237
+ fileCount
9238
+ });
9239
+ }
9240
+ async writeFileSnapshot(promptIndex, files) {
9241
+ await this.append({
9242
+ type: "file_snapshot",
9243
+ ts: (/* @__PURE__ */ new Date()).toISOString(),
9244
+ promptIndex,
9245
+ files
9246
+ });
9247
+ }
9248
+ /**
9249
+ * Truncate the session file to the checkpoint with the given promptIndex,
9250
+ * removing all events that follow it. Uses a single-pass byte-offset scan
9251
+ * so post-checkpoint content is never read or parsed — O(1) memory instead
9252
+ * of O(N) JSON.parse calls over the full file.
9253
+ */
9254
+ async truncateToCheckpoint(targetPromptIndex) {
9255
+ if (!this.filePath) return 0;
9256
+ if (this.flushTimer) {
9257
+ clearTimeout(this.flushTimer);
9258
+ this.flushTimer = null;
9275
9259
  }
9276
- const deleted = /* @__PURE__ */ new Set();
9277
- const seen = /* @__PURE__ */ new Map();
9278
- for (const line of raw.split("\n")) {
9279
- if (!line.trim()) continue;
9280
- try {
9281
- const entry = JSON.parse(line);
9282
- if (entry.action === "delete" && entry.id) {
9283
- deleted.add(entry.id);
9284
- seen.delete(entry.id);
9285
- continue;
9260
+ await this.flushBuffer();
9261
+ await this.writeChain;
9262
+ const CHUNK_SIZE = 65536;
9263
+ let fd;
9264
+ let fileOffset = 0;
9265
+ let lineStartOffset = 0;
9266
+ let checkpointByteOffset = -1;
9267
+ let removedCount = 0;
9268
+ let targetCheckpointSeen = false;
9269
+ try {
9270
+ fd = await fsp7.open(this.filePath, "r", 384);
9271
+ while (true) {
9272
+ const buf = Buffer.alloc(CHUNK_SIZE);
9273
+ const { bytesRead } = await fd.read(buf, 0, CHUNK_SIZE, fileOffset);
9274
+ if (bytesRead === 0) break;
9275
+ let chunkPos = 0;
9276
+ while (chunkPos < bytesRead) {
9277
+ const idx = buf.indexOf("\n", chunkPos);
9278
+ if (idx === -1) {
9279
+ lineStartOffset = fileOffset + chunkPos;
9280
+ break;
9281
+ }
9282
+ if (checkpointByteOffset !== -1) {
9283
+ removedCount++;
9284
+ } else {
9285
+ const lineBytes = buf.subarray(chunkPos, idx);
9286
+ const line = new TextDecoder("utf-8", { fatal: false }).decode(lineBytes);
9287
+ if (line.trim()) {
9288
+ try {
9289
+ const event = JSON.parse(line);
9290
+ if (event.type === "checkpoint") {
9291
+ if (event.promptIndex === targetPromptIndex) {
9292
+ checkpointByteOffset = lineStartOffset;
9293
+ targetCheckpointSeen = true;
9294
+ } else if (event.promptIndex !== void 0 && event.promptIndex > targetPromptIndex) {
9295
+ checkpointByteOffset = lineStartOffset;
9296
+ }
9297
+ } else if (targetCheckpointSeen && event.promptIndex !== void 0 && event.promptIndex > targetPromptIndex) {
9298
+ removedCount++;
9299
+ } else if (targetCheckpointSeen && event.promptIndex === void 0) {
9300
+ removedCount++;
9301
+ } else if (!targetCheckpointSeen && event.promptIndex === void 0) {
9302
+ removedCount++;
9303
+ } else if (!targetCheckpointSeen && event.promptIndex !== void 0 && event.promptIndex > targetPromptIndex) {
9304
+ removedCount++;
9305
+ }
9306
+ } catch {
9307
+ }
9308
+ }
9309
+ }
9310
+ chunkPos = idx + 1;
9311
+ lineStartOffset = fileOffset + chunkPos;
9286
9312
  }
9287
- if (entry.id && !deleted.has(entry.id)) {
9288
- seen.set(entry.id, entry);
9313
+ fileOffset += bytesRead;
9314
+ if (chunkPos >= bytesRead) {
9315
+ lineStartOffset = fileOffset;
9289
9316
  }
9290
- } catch {
9291
9317
  }
9318
+ } finally {
9319
+ await fd?.close();
9292
9320
  }
9293
- const summaries = Array.from(seen.values());
9294
- this._indexCache = { ...stat7, summaries };
9295
- return [...summaries];
9296
- }
9297
- /**
9298
- * Rebuild the index from disk by scanning all sessions and writing a
9299
- * fresh _index.jsonl. Useful after manual cleanup or index corruption.
9300
- */
9301
- async rebuildIndex() {
9302
- const ids = await this.collectSessionIds(this.dir);
9303
- const summaries = await Promise.all(ids.map((id) => this.summaryFor(id).catch(() => null)));
9304
- const valid = summaries.filter((s) => s !== null);
9305
- const tmp = `${this.indexFile}.tmp`;
9306
- const lines = valid.map((s) => JSON.stringify(s)).join("\n") + "\n";
9307
- await fsp6.writeFile(tmp, lines, "utf8");
9308
- await fsp6.rename(tmp, this.indexFile);
9309
- this._indexCache = null;
9310
- return valid.length;
9311
- }
9312
- async listFromDirectoryScan(limit) {
9313
- const refs = await this.collectSessionFiles(this.dir);
9314
- const candidates = await mapWithConcurrency(
9315
- refs,
9316
- _DefaultSessionStore.LIST_SCAN_CONCURRENCY,
9317
- async (ref) => {
9318
- const manifest = await this.readSummaryManifest(ref.id);
9319
- if (manifest) return { summary: manifest, needsBackfill: false };
9320
- const summary = await this.summaryHeaderFor(ref);
9321
- return summary ? { summary, needsBackfill: true } : null;
9322
- }
9323
- );
9324
- const out = candidates.filter((s) => s !== null);
9325
- out.sort((a, b) => compareSessionSummaries(a.summary, b.summary));
9326
- const selected = out.slice(0, limit);
9327
- const summaries = await mapWithConcurrency(
9328
- selected,
9329
- Math.min(_DefaultSessionStore.LIST_SCAN_CONCURRENCY, Math.max(1, limit)),
9330
- async (candidate) => {
9331
- if (!candidate.needsBackfill) return candidate.summary;
9332
- return await this.summaryFor(candidate.summary.id).catch(() => candidate.summary);
9333
- }
9334
- );
9335
- return summaries.filter((s) => s !== null);
9336
- }
9337
- async collectSessionFiles(dir, prefix = "", depth = 0) {
9338
- let entries;
9321
+ if (checkpointByteOffset === -1) return 0;
9322
+ await this.writeChain;
9323
+ await this.handle.close();
9324
+ const tmpPath = `${this.filePath}.rewind.tmp`;
9325
+ const src = await fsp7.open(this.filePath, "r", 384);
9339
9326
  try {
9340
- entries = await fsp6.readdir(dir, { withFileTypes: true });
9341
- } catch {
9342
- return [];
9343
- }
9344
- const dirEntries = [];
9345
- const files = [];
9346
- for (const entry of entries) {
9347
- if (entry.name.startsWith(".") && entry.name !== ".wrongstack") continue;
9348
- if (entry.name === "shared" || entry.name === "subagents" || entry.name === "attachments")
9349
- continue;
9350
- if (entry.isDirectory()) {
9351
- dirEntries.push(entry);
9352
- } else if (entry.isFile() && entry.name.endsWith(".jsonl")) {
9353
- if (entry.name === "_index.jsonl") continue;
9354
- const base = entry.name.replace(/\.jsonl$/, "");
9355
- const id = prefix ? `${prefix}/${base}` : base;
9356
- files.push({ id, filePath: path5.join(dir, entry.name) });
9327
+ const statResult = await src.stat();
9328
+ const totalSize = statResult.size;
9329
+ const prefixBytes = checkpointByteOffset;
9330
+ let newlineAfterCheckpoint = prefixBytes;
9331
+ if (prefixBytes < totalSize) {
9332
+ const probeBuf = Buffer.alloc(Math.min(CHUNK_SIZE, totalSize - prefixBytes));
9333
+ const { bytesRead: probeRead } = await src.read(probeBuf, 0, probeBuf.length, prefixBytes);
9334
+ if (probeRead > 0) {
9335
+ const nl = probeBuf.indexOf("\n");
9336
+ newlineAfterCheckpoint = nl !== -1 ? prefixBytes + nl + 1 : totalSize;
9337
+ }
9338
+ } else {
9339
+ newlineAfterCheckpoint = totalSize;
9357
9340
  }
9358
- }
9359
- const childFileArrays = await Promise.all(
9360
- dirEntries.map((entry) => {
9361
- const childPrefix = depth === 0 ? entry.name : `${prefix}/${entry.name}`;
9362
- return this.collectSessionFiles(path5.join(dir, entry.name), childPrefix, depth + 1);
9363
- })
9364
- );
9365
- return [...childFileArrays.flat(), ...files];
9366
- }
9367
- /** Recursively collect session IDs from date-shard subdirectories.
9368
- * IDs include the date-prefix path (e.g. "2026-06-06/17-46-57Z_…").
9369
- * Skips `.jsonl`/`.summary.json` root files, dot-files, and
9370
- * sub-directories that belong to fleet/subagent sessions. */
9371
- async collectSessionIds(dir, prefix = "", depth = 0) {
9372
- let entries;
9373
- try {
9374
- entries = await fsp6.readdir(dir, { withFileTypes: true });
9375
- } catch {
9376
- return [];
9377
- }
9378
- const dirEntries = [];
9379
- const fileIds = [];
9380
- for (const entry of entries) {
9381
- if (entry.name.startsWith(".") && entry.name !== ".wrongstack") continue;
9382
- if (entry.name === "shared" || entry.name === "subagents" || entry.name === "attachments")
9383
- continue;
9384
- if (entry.isDirectory()) {
9385
- dirEntries.push(entry);
9386
- } else if (entry.isFile() && entry.name.endsWith(".jsonl")) {
9387
- if (entry.name === "_index.jsonl") continue;
9388
- const base = entry.name.replace(/\.jsonl$/, "");
9389
- fileIds.push(prefix ? `${prefix}/${base}` : base);
9341
+ const writeFd = await fsp7.open(tmpPath, "w", 384);
9342
+ try {
9343
+ let readOffset = 0;
9344
+ while (readOffset < newlineAfterCheckpoint) {
9345
+ const toCopy = Math.min(CHUNK_SIZE, newlineAfterCheckpoint - readOffset);
9346
+ const copyBuf = Buffer.alloc(toCopy);
9347
+ const { bytesRead: r } = await src.read(copyBuf, 0, toCopy, readOffset);
9348
+ if (r === 0) break;
9349
+ await writeFd.write(copyBuf, 0, r);
9350
+ readOffset += r;
9351
+ }
9352
+ let tailOffset = newlineAfterCheckpoint;
9353
+ let leftover = "";
9354
+ while (tailOffset < totalSize) {
9355
+ const toRead = Math.min(CHUNK_SIZE, totalSize - tailOffset);
9356
+ const tailBuf = Buffer.alloc(toRead);
9357
+ const { bytesRead: tr } = await src.read(tailBuf, 0, toRead, tailOffset);
9358
+ if (tr === 0) break;
9359
+ const chunk = leftover + tailBuf.subarray(0, tr).toString("utf8");
9360
+ const lastNl = chunk.lastIndexOf("\n");
9361
+ if (lastNl === -1) {
9362
+ leftover = chunk;
9363
+ } else {
9364
+ for (const line of chunk.slice(0, lastNl + 1).split("\n")) {
9365
+ if (!line.trim()) continue;
9366
+ try {
9367
+ JSON.parse(line);
9368
+ } catch {
9369
+ await writeFd.write(`${line}
9370
+ `, void 0, "utf8");
9371
+ }
9372
+ }
9373
+ leftover = chunk.slice(lastNl + 1);
9374
+ }
9375
+ tailOffset += tr;
9376
+ }
9377
+ if (leftover.trim()) {
9378
+ try {
9379
+ JSON.parse(leftover);
9380
+ } catch {
9381
+ await writeFd.write(`${leftover}
9382
+ `, void 0, "utf8");
9383
+ }
9384
+ }
9385
+ } finally {
9386
+ await writeFd.close();
9390
9387
  }
9391
- }
9392
- const childIdArrays = await Promise.all(
9393
- dirEntries.map((entry) => {
9394
- const childPrefix = depth === 0 ? entry.name : `${prefix}/${entry.name}`;
9395
- return this.collectSessionIds(path5.join(dir, entry.name), childPrefix, depth + 1);
9396
- })
9397
- );
9398
- return [...childIdArrays.flat(), ...fileIds];
9399
- }
9400
- async summaryFor(id) {
9401
- const manifest = this.sessionPath(id, ".summary.json");
9402
- const t0 = Date.now();
9403
- let outcome = "success";
9404
- let errorMsg;
9405
- const fromManifest = await this.readSummaryManifest(id, t0);
9406
- if (fromManifest) return fromManifest;
9407
- try {
9408
- const full = this.sessionPath(id, ".jsonl");
9409
- const stat7 = await fsp6.stat(full);
9410
- const summary = await this.summarize(id, stat7.mtime.toISOString());
9411
- await atomicWrite(manifest, JSON.stringify(summary), { mode: 384 }).catch((err) => {
9412
- const msg = toErrorMessage(err);
9413
- this.emitError(id, manifest, "summary_fallback", msg, true);
9414
- console.warn(JSON.stringify({
9415
- level: "warn",
9416
- event: "session_store.manifest_write_failed",
9417
- sessionId: id,
9418
- message: msg,
9419
- timestamp: (/* @__PURE__ */ new Date()).toISOString()
9420
- }));
9421
- });
9422
- outcome = "failure";
9423
- errorMsg = "summary fallback \xE2\u20AC\u201D manifest rebuilt";
9424
- this.emitRead(id, manifest, "summary", outcome, Date.now() - t0, errorMsg);
9425
- return summary;
9388
+ await src.close();
9389
+ await fsp7.rename(tmpPath, this.filePath);
9390
+ this.handle = await fsp7.open(this.filePath, "a", 384);
9426
9391
  } catch (err) {
9427
- outcome = "failure";
9428
- errorMsg = toErrorMessage(err);
9429
- this.emitRead(id, manifest, "summary", outcome, Date.now() - t0, errorMsg);
9430
- return {
9431
- id,
9432
- title: "(damaged)",
9433
- startedAt: (/* @__PURE__ */ new Date()).toISOString(),
9434
- model: "unknown",
9435
- provider: "unknown",
9436
- tokenTotal: 0
9437
- };
9392
+ await fsp7.unlink(tmpPath).catch(() => void 0);
9393
+ this.handle = await fsp7.open(this.filePath, "a", 384).catch(() => this.handle);
9394
+ throw err;
9438
9395
  }
9396
+ await this.append({
9397
+ type: "rewound",
9398
+ ts: (/* @__PURE__ */ new Date()).toISOString(),
9399
+ toPromptIndex: targetPromptIndex,
9400
+ revertedFiles: []
9401
+ });
9402
+ this.events?.emit("session.rewound", {
9403
+ toPromptIndex: targetPromptIndex,
9404
+ revertedFiles: [],
9405
+ removedEvents: removedCount
9406
+ });
9407
+ return removedCount;
9439
9408
  }
9440
- async readSummaryManifest(id, startTime = Date.now()) {
9441
- const manifest = this.sessionPath(id, ".summary.json");
9442
- try {
9443
- const raw = await fsp6.readFile(manifest, "utf8");
9444
- this.emitRead(id, manifest, "summary", "success", Date.now() - startTime);
9445
- return JSON.parse(raw);
9446
- } catch {
9447
- return null;
9409
+ async clearSession() {
9410
+ if (!this.filePath) return;
9411
+ if (this.flushTimer) {
9412
+ clearTimeout(this.flushTimer);
9413
+ this.flushTimer = null;
9448
9414
  }
9415
+ this.writeBuffer = [];
9416
+ await this.writeChain;
9417
+ const record = `${JSON.stringify({
9418
+ type: "session_start",
9419
+ ts: (/* @__PURE__ */ new Date()).toISOString(),
9420
+ id: this.id,
9421
+ model: this.meta.model ?? "unknown",
9422
+ provider: this.meta.provider ?? "unknown"
9423
+ })}
9424
+ `;
9425
+ await fsp7.writeFile(this.filePath, record, "utf8");
9449
9426
  }
9450
- async summaryHeaderFor(ref) {
9451
- let mtime = (/* @__PURE__ */ new Date(0)).toISOString();
9452
- try {
9453
- const stat7 = await fsp6.stat(ref.filePath);
9454
- if (!stat7.isFile()) {
9455
- return {
9456
- id: ref.id,
9457
- title: "(damaged)",
9458
- startedAt: stat7.mtime.toISOString(),
9459
- model: "unknown",
9460
- provider: "unknown",
9461
- tokenTotal: 0
9462
- };
9463
- }
9464
- mtime = stat7.mtime.toISOString();
9465
- } catch {
9466
- return null;
9467
- }
9468
- try {
9469
- for await (const event of this.iterSessionEvents(ref.filePath)) {
9470
- if (event.type === "session_start") {
9471
- return {
9472
- id: ref.id,
9473
- title: "(empty session)",
9474
- startedAt: event.ts,
9475
- model: event.model ?? "unknown",
9476
- provider: event.provider ?? "unknown",
9477
- tokenTotal: 0
9478
- };
9479
- }
9480
- }
9481
- return {
9482
- id: ref.id,
9483
- title: "(empty session)",
9484
- startedAt: (/* @__PURE__ */ new Date(0)).toISOString(),
9485
- model: "unknown",
9486
- provider: "unknown",
9487
- tokenTotal: 0
9488
- };
9489
- } catch {
9490
- return {
9491
- id: ref.id,
9492
- title: "(damaged)",
9493
- startedAt: mtime,
9494
- model: "unknown",
9495
- provider: "unknown",
9496
- tokenTotal: 0
9497
- };
9427
+ /**
9428
+ * Write an in-flight marker. The agent loop should call
9429
+ * this at the start of each long-running operation; a matching
9430
+ * `clearInFlightMarker` follows on clean exit. A stale marker
9431
+ * (no end) is what `SessionRecovery.detectStale` looks for.
9432
+ */
9433
+ async writeInFlightMarker(context) {
9434
+ if (!context || context.length > 500) {
9435
+ throw new Error("In-flight context must be 1..500 chars");
9498
9436
  }
9437
+ await this.append({
9438
+ type: "in_flight_start",
9439
+ ts: (/* @__PURE__ */ new Date()).toISOString(),
9440
+ context
9441
+ });
9442
+ this.events?.emit("in_flight.started", { context, ts: (/* @__PURE__ */ new Date()).toISOString() });
9499
9443
  }
9500
9444
  /**
9501
- * Delete a session and all associated files: JSONL, summary, plan/todos
9502
- * sidecars, and the session directory (fleet.json, shared/, subagents/).
9445
+ * Close the in-flight marker. Idempotent in spirit
9446
+ * (you can call it after a successful iteration even if you
9447
+ * didn't open one this round) — but the session log records
9448
+ * every call so postmortem tooling can see "the agent finished
9449
+ * cleanly X times, then died without finishing Y".
9450
+ */
9451
+ async clearInFlightMarker(reason) {
9452
+ await this.append({
9453
+ type: "in_flight_end",
9454
+ ts: (/* @__PURE__ */ new Date()).toISOString(),
9455
+ reason
9456
+ });
9457
+ this.events?.emit("in_flight.ended", { reason, ts: (/* @__PURE__ */ new Date()).toISOString() });
9458
+ }
9459
+ };
9460
+ function sanitizeModel(model) {
9461
+ return model.replace(/[^a-zA-Z0-9_-]/g, "-").replace(/-+/g, "-").replace(/^-|-$/g, "").slice(0, 40);
9462
+ }
9463
+ function generateSessionId(startedAt, model) {
9464
+ const date = startedAt.slice(0, 10);
9465
+ const time = startedAt.slice(11, 19).replace(/:/g, "-");
9466
+ const suffix = randomBytes(2).toString("hex");
9467
+ const modelPart = model ? `_${sanitizeModel(model)}` : "";
9468
+ return `${date}/${time}Z${modelPart}_${suffix}`;
9469
+ }
9470
+
9471
+ // src/storage/session-store.ts
9472
+ var DefaultSessionStore = class _DefaultSessionStore {
9473
+ dir;
9474
+ events;
9475
+ secretScrubber;
9476
+ /**
9477
+ * In-memory cache for load() results, keyed by session ID. The cache is
9478
+ * invalidated when the file's mtimeMs or size changes (indicating the
9479
+ * file was written to). This eliminates redundant full-file reads and
9480
+ * JSON parses when the same session is loaded multiple times within the
9481
+ * store's lifetime (e.g., webui session detail views, list() fallbacks).
9503
9482
  *
9504
- * Individual file deletions are best-effort (logged as structured warnings),
9505
- * but a tombstone is always written so readIndex() filters this session out.
9506
- * If the session directory itself can't be removed, the error is surfaced
9507
- * to the caller so prune() can report it.
9483
+ * Max size is capped to prevent unbounded memory growth in long-running
9484
+ * processes. When the limit is reached, the oldest entry is evicted.
9508
9485
  */
9509
- async deleteSession(id) {
9510
- const jsonlPath = this.sessionPath(id, ".jsonl");
9511
- const summaryPath = this.sessionPath(id, ".summary.json");
9512
- const shardDir = path5.dirname(path5.join(this.dir, id));
9513
- const base = path5.basename(id);
9514
- const sessDir = path5.join(shardDir, base);
9515
- const deletions = [
9516
- fsp6.unlink(jsonlPath),
9517
- fsp6.unlink(summaryPath),
9518
- fsp6.unlink(path5.join(shardDir, `${base}.plan.json`)),
9519
- fsp6.unlink(path5.join(shardDir, `${base}.todos.json`))
9520
- ];
9521
- const results = await Promise.allSettled(deletions);
9522
- for (const r of results) {
9523
- if (r.status === "rejected") {
9524
- const msg = r.reason instanceof Error ? r.reason.message : String(r.reason);
9525
- if (r.reason?.code !== "ENOENT") {
9526
- console.warn(JSON.stringify({
9527
- level: "warn",
9528
- event: "session_store.delete_failed",
9529
- sessionId: id,
9530
- message: msg,
9531
- timestamp: (/* @__PURE__ */ new Date()).toISOString()
9532
- }));
9533
- }
9534
- }
9486
+ _loadCache = /* @__PURE__ */ new Map();
9487
+ _indexCache = null;
9488
+ shardManifestCache = /* @__PURE__ */ new Map();
9489
+ static LOAD_CACHE_MAX_ENTRIES = 50;
9490
+ static LIST_SCAN_CONCURRENCY = 32;
9491
+ constructor(opts) {
9492
+ this.dir = opts.dir;
9493
+ this.events = opts.events;
9494
+ this.secretScrubber = opts.secretScrubber;
9495
+ }
9496
+ /**
9497
+ * Clear the load() cache. Useful for testing or when the caller knows
9498
+ * the file has changed externally (e.g., another process wrote to it).
9499
+ */
9500
+ clearLoadCache(sessionId) {
9501
+ if (sessionId !== void 0) {
9502
+ this._loadCache.delete(sessionId);
9503
+ } else {
9504
+ this._loadCache.clear();
9535
9505
  }
9536
- await fsp6.rm(sessDir, { recursive: true, force: true }).catch((err) => {
9537
- console.warn(JSON.stringify({
9538
- level: "warn",
9539
- event: "session_store.rmdir_failed",
9540
- sessionId: id,
9541
- message: toErrorMessage(err),
9542
- timestamp: (/* @__PURE__ */ new Date()).toISOString()
9543
- }));
9506
+ }
9507
+ // ── Storage event helpers ───────────────────────────────────────────────────
9508
+ emitRead(sessionId, filePath, operation, outcome, durationMs, error) {
9509
+ this.events?.emit("storage.read", {
9510
+ sessionId,
9511
+ store: "session",
9512
+ filePath,
9513
+ operation,
9514
+ outcome,
9515
+ durationMs,
9516
+ ...error !== void 0 ? { error } : {}
9544
9517
  });
9545
- await this.writeTombstone(id);
9546
9518
  }
9547
- async delete(id) {
9548
- await this.deleteSession(id);
9519
+ emitWrite(sessionId, filePath, operation, outcome, durationMs, eventCount, error) {
9520
+ this.events?.emit("storage.write", {
9521
+ sessionId,
9522
+ store: "session",
9523
+ filePath,
9524
+ operation,
9525
+ outcome,
9526
+ durationMs,
9527
+ ...eventCount !== void 0 ? { eventCount } : {},
9528
+ ...error !== void 0 ? { error } : {}
9529
+ });
9549
9530
  }
9550
- async prune(maxAgeDays = 30) {
9551
- const cutoff = Date.now() - maxAgeDays * 864e5;
9552
- let deleted = 0;
9553
- let activeSessionId = null;
9531
+ emitError(sessionId, filePath, operation, error, recoverable) {
9532
+ this.events?.emit("storage.error", {
9533
+ sessionId,
9534
+ store: "session",
9535
+ filePath,
9536
+ operation,
9537
+ error,
9538
+ recoverable
9539
+ });
9540
+ }
9541
+ /** Absolute path to the session index file. */
9542
+ get indexFile() {
9543
+ return path6.join(this.dir, "_index.jsonl");
9544
+ }
9545
+ /** Join session ID to its absolute path within the store directory. */
9546
+ sessionPath(id, ext) {
9547
+ return path6.join(this.dir, `${id}${ext}`);
9548
+ }
9549
+ shardManifestPath(shardKey) {
9550
+ return shardKey ? path6.join(this.dir, shardKey, "_manifest.json") : path6.join(this.dir, "_manifest.json");
9551
+ }
9552
+ shardKeyForSessionId(id) {
9553
+ const dirName = path6.dirname(id);
9554
+ return dirName === "." ? "" : dirName;
9555
+ }
9556
+ invalidateShardManifestBySessionId(id) {
9557
+ this.shardManifestCache.delete(this.shardKeyForSessionId(id));
9558
+ }
9559
+ /**
9560
+ * Ensure the directory implied by the session ID exists. When the ID
9561
+ * contains a date prefix like `2026-06-06/...`, this creates the date
9562
+ * subdirectory so sessions group naturally by day.
9563
+ */
9564
+ async ensureShardDir(id) {
9565
+ const dirPath = path6.dirname(path6.join(this.dir, id));
9566
+ await ensureDir(dirPath);
9567
+ return dirPath;
9568
+ }
9569
+ async create(meta) {
9570
+ const startedAt = (/* @__PURE__ */ new Date()).toISOString();
9571
+ const id = meta.id && meta.id.length > 0 ? meta.id : generateSessionId(startedAt, meta.model ?? meta.provider);
9572
+ const shardDir = await this.ensureShardDir(id);
9573
+ const file = path6.join(shardDir, `${path6.basename(id)}.jsonl`);
9574
+ const t0 = Date.now();
9575
+ let handle;
9554
9576
  try {
9555
- const raw = await fsp6.readFile(path5.join(this.dir, "active.json"), "utf8");
9556
- const active = JSON.parse(raw);
9557
- activeSessionId = active.sessionId ?? null;
9558
- } catch {
9577
+ handle = await fsp7.open(file, "a", 384);
9578
+ } catch (err) {
9579
+ this.emitError(id, file, "create", toErrorMessage(err), false);
9580
+ throw new Error(
9581
+ `Failed to open session file: ${toErrorMessage(err)}`,
9582
+ { cause: err }
9583
+ );
9559
9584
  }
9560
- const isPrunableJsonl = (name) => name.endsWith(".jsonl") && name !== "_index.jsonl" && name !== "_mailbox.jsonl" && !name.endsWith(".replay.jsonl") && !name.endsWith(".audit.jsonl");
9561
- const pruneFile = async (dir, name, prefix) => {
9562
- const jsonlPath = path5.join(dir, name);
9563
- try {
9564
- const stat7 = await fsp6.stat(jsonlPath);
9565
- if (stat7.mtimeMs >= cutoff) return;
9566
- } catch {
9567
- return;
9568
- }
9569
- const base = name.replace(/\.jsonl$/, "");
9570
- const id = prefix ? `${prefix}/${base}` : base;
9571
- if (activeSessionId && id === activeSessionId) return;
9572
- await this.deleteSession(id);
9573
- deleted++;
9574
- };
9575
- const entries = await fsp6.readdir(this.dir, { withFileTypes: true }).catch(() => []);
9576
- for (const entry of entries) {
9577
- if (entry.isFile()) {
9578
- if (isPrunableJsonl(entry.name)) await pruneFile(this.dir, entry.name, "");
9579
- continue;
9580
- }
9581
- if (!entry.isDirectory()) continue;
9582
- const dateDir = path5.join(this.dir, entry.name);
9583
- const files = await fsp6.readdir(dateDir, { withFileTypes: true }).catch(() => []);
9584
- for (const file of files) {
9585
- if (!file.isFile() || !isPrunableJsonl(file.name)) continue;
9586
- await pruneFile(dateDir, file.name, entry.name);
9587
- }
9585
+ try {
9586
+ const writer = new FileSessionWriter(id, handle, startedAt, meta, this.events, {
9587
+ dir: shardDir,
9588
+ filePath: file,
9589
+ secretScrubber: this.secretScrubber,
9590
+ onClose: (s) => this.appendToIndex(s)
9591
+ });
9592
+ this.emitWrite(id, file, "create", "success", Date.now() - t0);
9593
+ return writer;
9594
+ } catch (err) {
9595
+ await handle.close().catch((e) => console.warn(JSON.stringify({
9596
+ level: "warn",
9597
+ event: "session_store.handle_close_failed",
9598
+ message: e instanceof Error ? e.message : String(e),
9599
+ timestamp: (/* @__PURE__ */ new Date()).toISOString()
9600
+ })));
9601
+ this.emitError(id, file, "create", toErrorMessage(err), true);
9602
+ throw err;
9588
9603
  }
9589
- if (deleted > 0) {
9590
- await this.compactIndex().catch(() => void 0);
9604
+ }
9605
+ async resume(id) {
9606
+ const file = this.sessionPath(id, ".jsonl");
9607
+ const t0 = Date.now();
9608
+ const data = await this.load(id);
9609
+ let handle;
9610
+ try {
9611
+ handle = await fsp7.open(file, "a", 384);
9612
+ } catch (err) {
9613
+ this.emitError(id, file, "resume", toErrorMessage(err), false);
9614
+ throw new Error(
9615
+ `Failed to open session "${id}" for append: ${toErrorMessage(err)}`,
9616
+ { cause: err }
9617
+ );
9591
9618
  }
9592
- for (const entry of entries) {
9593
- if (!entry.isDirectory()) continue;
9594
- const dateDir = path5.join(this.dir, entry.name);
9595
- try {
9596
- const remaining = await fsp6.readdir(dateDir);
9597
- if (remaining.length === 0) {
9598
- await fsp6.rmdir(dateDir).catch(() => void 0);
9619
+ try {
9620
+ const writer = new FileSessionWriter(
9621
+ id,
9622
+ handle,
9623
+ (/* @__PURE__ */ new Date()).toISOString(),
9624
+ {
9625
+ id,
9626
+ model: data.metadata.model,
9627
+ provider: data.metadata.provider
9628
+ },
9629
+ this.events,
9630
+ {
9631
+ resumed: true,
9632
+ // Shard directory (sessions/<date>/) — must match create() so the
9633
+ // .summary.json sidecar lands next to the JSONL instead of the
9634
+ // sessions root (where summaryFor() would never find it).
9635
+ dir: path6.dirname(file),
9636
+ filePath: file,
9637
+ secretScrubber: this.secretScrubber,
9638
+ onClose: (s) => this.appendToIndex(s)
9599
9639
  }
9600
- } catch {
9601
- }
9640
+ );
9641
+ this.emitWrite(id, file, "resume", "success", Date.now() - t0);
9642
+ return { writer, data };
9643
+ } catch (err) {
9644
+ await handle.close().catch((e) => console.warn(JSON.stringify({
9645
+ level: "warn",
9646
+ event: "session_store.handle_close_failed",
9647
+ message: e instanceof Error ? e.message : String(e),
9648
+ timestamp: (/* @__PURE__ */ new Date()).toISOString()
9649
+ })));
9650
+ this.emitError(id, file, "resume", toErrorMessage(err), true);
9651
+ throw err;
9602
9652
  }
9603
- return deleted;
9604
9653
  }
9605
- async clearHistory(id) {
9606
- await this.ensureShardDir(id);
9654
+ async load(id) {
9607
9655
  const file = this.sessionPath(id, ".jsonl");
9608
- const meta = this.sessionPath(id, ".summary.json");
9609
- const record = `${JSON.stringify({
9610
- type: "session_start",
9611
- ts: (/* @__PURE__ */ new Date()).toISOString(),
9612
- id,
9613
- model: "unknown",
9614
- provider: "unknown"
9615
- })}
9616
- `;
9617
- await fsp6.writeFile(file, record, "utf8");
9618
- await fsp6.unlink(meta).catch(() => void 0);
9619
- }
9620
- async summarize(id, mtime) {
9656
+ const t0 = Date.now();
9657
+ let outcome = "success";
9658
+ let errorMsg;
9659
+ let cacheHit = false;
9621
9660
  try {
9622
- const file = this.sessionPath(id, ".jsonl");
9623
- let title = "(empty session)";
9624
- let startedAt = (/* @__PURE__ */ new Date(0)).toISOString();
9625
- let endedAt;
9626
- let model = "unknown";
9627
- let provider = "unknown";
9628
- let tokenIn = 0;
9629
- let tokenOut = 0;
9630
- let iterationCount = 0;
9631
- let toolCallCount = 0;
9632
- let toolErrorCount = 0;
9633
- let fileChangeCount = 0;
9634
- const toolBreakdown = {};
9635
- let outcome;
9636
- let lastEventType;
9637
- let hasError = false;
9638
- let sawStart = false;
9639
- for await (const e of this.iterSessionEvents(file)) {
9640
- lastEventType = e.type;
9641
- if (e.type === "session_start") {
9642
- if (!sawStart) {
9643
- sawStart = true;
9644
- startedAt = e.ts;
9645
- model = e.model ?? "unknown";
9646
- provider = e.provider ?? "unknown";
9661
+ const s = await fsp7.stat(file);
9662
+ const stat7 = { mtimeMs: s.mtimeMs, size: s.size };
9663
+ const cached = this._loadCache.get(id);
9664
+ if (cached && cached.mtimeMs === stat7.mtimeMs && cached.size === stat7.size) {
9665
+ cacheHit = true;
9666
+ this._loadCache.delete(id);
9667
+ this._loadCache.set(id, cached);
9668
+ return cached.data;
9669
+ }
9670
+ const raw = await fsp7.readFile(file, "utf8");
9671
+ const lines = raw.split("\n").filter((l) => l.trim());
9672
+ const events = [];
9673
+ let sessionStartEvent;
9674
+ let sessionEndEvent;
9675
+ let sessionModel;
9676
+ let sessionProvider;
9677
+ let sessionPendingToolUses;
9678
+ const messages = [];
9679
+ const openToolUses = /* @__PURE__ */ new Set();
9680
+ let usage = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 };
9681
+ for (const line of lines) {
9682
+ try {
9683
+ const parsed = JSON.parse(line);
9684
+ if (parsed !== null && typeof parsed === "object" && typeof parsed.type === "string" && typeof parsed.ts === "string") {
9685
+ const ev = parsed;
9686
+ events.push(ev);
9687
+ if (ev.type === "session_start" && !sessionStartEvent) {
9688
+ sessionStartEvent = ev;
9689
+ sessionModel = ev.model;
9690
+ sessionProvider = ev.provider;
9691
+ }
9692
+ if (ev.type === "session_end") {
9693
+ sessionEndEvent = ev;
9694
+ sessionPendingToolUses = ev.pendingToolUses;
9695
+ }
9696
+ if (ev.type === "user_input") {
9697
+ openToolUses.clear();
9698
+ messages.push({ role: "user", content: ev.content, ts: ev.ts });
9699
+ } else if (ev.type === "llm_response") {
9700
+ messages.push({ role: "assistant", content: ev.content, ts: ev.ts });
9701
+ for (const b of ev.content) {
9702
+ if (b.type === "tool_use") openToolUses.add(b.id);
9703
+ }
9704
+ usage = {
9705
+ input: usage.input + (ev.usage.input ?? 0),
9706
+ output: usage.output + (ev.usage.output ?? 0),
9707
+ cacheRead: (usage.cacheRead ?? 0) + (ev.usage.cacheRead ?? 0),
9708
+ cacheWrite: (usage.cacheWrite ?? 0) + (ev.usage.cacheWrite ?? 0)
9709
+ };
9710
+ } else if (ev.type === "tool_result") {
9711
+ if (!openToolUses.has(ev.id)) {
9712
+ this.events?.emit("session.damaged", {
9713
+ sessionId: id,
9714
+ detail: `Orphan tool_result "${ev.id}" has no matching tool_use`
9715
+ });
9716
+ continue;
9717
+ }
9718
+ openToolUses.delete(ev.id);
9719
+ const resultBlock = {
9720
+ type: "tool_result",
9721
+ tool_use_id: ev.id,
9722
+ content: typeof ev.content === "string" ? ev.content : JSON.stringify(ev.content),
9723
+ is_error: ev.isError
9724
+ };
9725
+ const last = messages[messages.length - 1];
9726
+ const lastIsToolResultUser = last?.role === "user" && Array.isArray(last.content) && last.content.every((b) => b.type === "tool_result");
9727
+ if (lastIsToolResultUser && Array.isArray(last.content)) {
9728
+ last.content.push(resultBlock);
9729
+ } else {
9730
+ messages.push({ role: "user", content: [resultBlock], ts: ev.ts });
9731
+ }
9732
+ }
9647
9733
  }
9648
- } else if (e.type === "session_end") {
9649
- endedAt = e.ts;
9650
- } else if (e.type === "user_input") {
9651
- if (title === "(empty session)") title = userInputTitle(e.content);
9652
- } else if (e.type === "llm_response") {
9653
- tokenIn += e.usage.input ?? 0;
9654
- tokenOut += e.usage.output ?? 0;
9655
- } else if (e.type === "in_flight_start") iterationCount++;
9656
- else if (e.type === "tool_call_start") {
9657
- toolCallCount++;
9658
- toolBreakdown[e.name] = (toolBreakdown[e.name] ?? 0) + 1;
9659
- } else if (e.type === "tool_result" && e.isError) toolErrorCount++;
9660
- else if (e.type === "file_snapshot") fileChangeCount += e.files.length;
9661
- else if (e.type === "error" || e.type === "provider_error") hasError = true;
9734
+ } catch {
9735
+ }
9662
9736
  }
9663
- if (lastEventType === "session_end") {
9664
- outcome = "completed";
9665
- } else if (lastEventType === "in_flight_start") {
9666
- outcome = "aborted";
9667
- } else if (hasError) {
9668
- outcome = "error";
9737
+ if (openToolUses.size > 0) {
9738
+ this.events?.emit("session.damaged", {
9739
+ sessionId: id,
9740
+ detail: `${openToolUses.size} tool_use blocks without matching results - replay repaired`
9741
+ });
9669
9742
  }
9670
- return {
9671
- id,
9672
- title,
9673
- startedAt,
9674
- endedAt,
9675
- model,
9676
- provider,
9677
- tokenTotal: tokenIn + tokenOut,
9678
- iterationCount: iterationCount > 0 ? iterationCount : void 0,
9679
- toolCallCount: toolCallCount > 0 ? toolCallCount : void 0,
9680
- toolErrorCount: toolErrorCount > 0 ? toolErrorCount : void 0,
9681
- fileChangeCount: fileChangeCount > 0 ? fileChangeCount : void 0,
9682
- toolBreakdown: Object.keys(toolBreakdown).length > 0 ? toolBreakdown : {},
9683
- outcome
9684
- };
9685
- } catch {
9686
- return {
9743
+ const repaired = repairToolUseAdjacency(messages);
9744
+ if (repaired.report.changed) {
9745
+ this.events?.emit("session.damaged", {
9746
+ sessionId: id,
9747
+ detail: `Repaired replay adjacency: removed ${repaired.report.removedToolUses.length} tool_use, ${repaired.report.removedToolResults.length} tool_result, ${repaired.report.removedMessages} empty messages`
9748
+ });
9749
+ }
9750
+ const meta = {
9687
9751
  id,
9688
- title: "(damaged)",
9689
- startedAt: mtime,
9690
- model: "unknown",
9691
- provider: "unknown",
9692
- tokenTotal: 0
9752
+ startedAt: sessionStartEvent?.ts ?? (/* @__PURE__ */ new Date(0)).toISOString(),
9753
+ endedAt: sessionEndEvent?.ts,
9754
+ model: sessionModel,
9755
+ provider: sessionProvider,
9756
+ pendingToolUses: sessionPendingToolUses
9693
9757
  };
9758
+ const toolCallEnds = extractToolCallEnds(events);
9759
+ const data = { metadata: meta, events, messages: repaired.messages, usage, toolCallEnds };
9760
+ if (this._loadCache.size >= _DefaultSessionStore.LOAD_CACHE_MAX_ENTRIES) {
9761
+ const oldest = this._loadCache.keys().next().value;
9762
+ if (oldest !== void 0) {
9763
+ this._loadCache.delete(oldest);
9764
+ }
9765
+ }
9766
+ this._loadCache.set(id, { mtimeMs: stat7.mtimeMs, size: stat7.size, data });
9767
+ return data;
9768
+ } catch (err) {
9769
+ outcome = "failure";
9770
+ errorMsg = toErrorMessage(err);
9771
+ throw err;
9772
+ } finally {
9773
+ this.emitRead(id, file, "load", outcome, Date.now() - t0, errorMsg);
9774
+ if (cacheHit) {
9775
+ this.events?.emit("storage.cache_hit", {
9776
+ sessionId: id,
9777
+ store: "session",
9778
+ filePath: file,
9779
+ operation: "load",
9780
+ durationMs: Date.now() - t0
9781
+ });
9782
+ }
9694
9783
  }
9695
9784
  }
9696
- async *iterSessionEvents(file) {
9697
- const stream = createReadStream(file, { encoding: "utf8" });
9698
- const lines = createInterface({ input: stream, crlfDelay: Infinity });
9785
+ /**
9786
+ * Streaming search over a session's JSONL. Walks the file once, parses
9787
+ * each event lazily, and yields only the events that match `predicate`.
9788
+ * Stops as soon as `opts.limit` matches are collected.
9789
+ *
9790
+ * Why this exists: `load()` parses the entire file into memory and
9791
+ * rebuilds `messages`/`toolCallEnds` for every caller. `search()` only
9792
+ * needs to know which events contain matching text — a per-line
9793
+ * predicate is enough. The full parse work (and the `_loadCache` poll)
9794
+ * is wasted in that case.
9795
+ *
9796
+ * Memory: O(hits) regardless of file size. Disk: one linear scan,
9797
+ * terminated at `limit` if the caller asked for one.
9798
+ *
9799
+ * Errors: missing file yields []. Corrupt lines are skipped (same
9800
+ * policy as `load()`). Aborting via `signal` rejects with `AbortError`.
9801
+ */
9802
+ async searchEvents(id, predicate, opts) {
9803
+ const file = this.sessionPath(id, ".jsonl");
9804
+ const limit = opts?.limit;
9805
+ const signal = opts?.signal;
9806
+ const out = [];
9807
+ let stat7;
9808
+ try {
9809
+ stat7 = await fsp7.stat(file);
9810
+ } catch (err) {
9811
+ if (err.code === "ENOENT") return [];
9812
+ throw err;
9813
+ }
9814
+ if (stat7.size === 0) return [];
9815
+ let fh;
9699
9816
  try {
9700
- for await (const line of lines) {
9701
- if (!line.trim()) continue;
9817
+ fh = await fsp7.open(file, "r");
9818
+ const CHUNK = 64 * 1024;
9819
+ const buf = Buffer.alloc(CHUNK);
9820
+ let leftover = "";
9821
+ let eventIndex = 0;
9822
+ for (let position = 0; ; position += buf.byteLength) {
9823
+ if (signal?.aborted) {
9824
+ const reason = signal.reason ?? new DOMException("Aborted", "AbortError");
9825
+ throw reason;
9826
+ }
9827
+ const { bytesRead } = await fh.read(buf, 0, CHUNK, position);
9828
+ if (bytesRead === 0) break;
9829
+ const text = leftover + buf.subarray(0, bytesRead).toString("utf8");
9830
+ const parts = text.split("\n");
9831
+ leftover = parts.pop() ?? "";
9832
+ for (const line of parts) {
9833
+ if (!line) continue;
9834
+ let ev;
9835
+ try {
9836
+ const parsed = JSON.parse(line);
9837
+ if (parsed === null || typeof parsed !== "object" || typeof parsed.type !== "string" || typeof parsed.ts !== "string") {
9838
+ continue;
9839
+ }
9840
+ ev = parsed;
9841
+ } catch {
9842
+ continue;
9843
+ }
9844
+ if (predicate(ev, eventIndex, ev.ts)) {
9845
+ out.push({ event: ev, eventIndex, ts: ev.ts });
9846
+ if (limit !== void 0 && out.length >= limit) {
9847
+ return out;
9848
+ }
9849
+ }
9850
+ eventIndex++;
9851
+ }
9852
+ }
9853
+ if (leftover.trim()) {
9702
9854
  try {
9703
- const parsed = JSON.parse(line);
9855
+ const parsed = JSON.parse(leftover);
9704
9856
  if (parsed !== null && typeof parsed === "object" && typeof parsed.type === "string" && typeof parsed.ts === "string") {
9705
- yield parsed;
9857
+ const ev = parsed;
9858
+ if (predicate(ev, eventIndex, ev.ts)) {
9859
+ out.push({ event: ev, eventIndex, ts: ev.ts });
9860
+ }
9706
9861
  }
9707
9862
  } catch {
9708
9863
  }
9709
9864
  }
9865
+ return out;
9710
9866
  } finally {
9711
- lines.close();
9712
- stream.destroy();
9867
+ if (fh) await fh.close().catch(() => void 0);
9713
9868
  }
9714
9869
  }
9715
- };
9716
- function extractToolCallEnds(events) {
9717
- const result = [];
9718
- for (const e of events) {
9719
- if (e.type === "tool_call_end") {
9720
- result.push({
9721
- name: e.name,
9722
- id: e.id,
9723
- durationMs: e.durationMs,
9724
- ok: e.ok ?? false,
9725
- outputBytes: e.outputBytes,
9726
- outputTokens: e.outputTokens,
9727
- outputLines: e.outputLines
9728
- });
9870
+ async list(limit = 20) {
9871
+ try {
9872
+ await ensureDir(this.dir);
9873
+ const indexed = await this.readIndex();
9874
+ if (indexed.length > 0) {
9875
+ indexed.sort((a, b) => {
9876
+ if (a.startedAt < b.startedAt) return 1;
9877
+ if (a.startedAt > b.startedAt) return -1;
9878
+ return a.id.localeCompare(b.id);
9879
+ });
9880
+ return indexed.slice(0, limit);
9881
+ }
9882
+ return await this.listFromDirectoryScan(limit);
9883
+ } catch {
9884
+ return [];
9729
9885
  }
9730
9886
  }
9731
- return result;
9732
- }
9733
- var FileSessionWriter = class _FileSessionWriter {
9734
- constructor(id, handle, startedAt, meta, events, opts = {}, traceId) {
9735
- this.id = id;
9736
- this.handle = handle;
9737
- this.startedAt = startedAt;
9738
- this.meta = meta;
9739
- this.events = events;
9740
- this.resumed = opts.resumed ?? false;
9741
- this.manifestFile = opts.dir ? path5.join(opts.dir, `${path5.basename(id)}.summary.json`) : "";
9742
- this.filePath = opts.filePath ?? "";
9743
- this.secretScrubber = opts.secretScrubber;
9744
- this.onCloseCb = opts.onClose;
9745
- this.summary = {
9746
- id,
9747
- title: "(empty session)",
9748
- startedAt,
9749
- model: meta.model ?? "unknown",
9750
- provider: meta.provider ?? "unknown",
9751
- tokenTotal: 0
9752
- };
9753
- this.traceId = traceId;
9754
- }
9755
- id;
9756
- handle;
9757
- startedAt;
9758
- meta;
9759
- events;
9760
- closed = false;
9761
- closePromise = null;
9762
- manifestFile;
9763
- summary;
9764
- tokenIn = 0;
9765
- tokenOut = 0;
9766
- filePath;
9767
- get transcriptPath() {
9768
- return this.filePath || void 0;
9769
- }
9770
- /**
9771
- * Lazy session_start/session_resumed init, shared by all appenders.
9772
- * A single promise (not a boolean) so a second append racing the first
9773
- * can't push its event into the buffer BEFORE the first append's event —
9774
- * every appender awaits the same init and resumes in FIFO call order.
9775
- */
9776
- initPromise = null;
9777
- ensureInit() {
9778
- if (!this.initPromise) this.initPromise = this.writeSessionStartLazy();
9779
- return this.initPromise;
9780
- }
9781
- resumed;
9782
- appendFailCount = 0;
9783
- lastAppendWarnAt = 0;
9784
- secretScrubber;
9785
- onCloseCb;
9786
- /** Implements SessionWriter.traceId — propagated from ContextInit.traceId. */
9787
- traceId;
9788
- // ── Write buffer — batches events to reduce per-event disk I/O ─────────
9789
- //
9790
- // Every append() pushes the scrubbed event into an in-memory buffer instead
9791
- // of calling handle.appendFile() synchronously. The buffer flushes to disk
9792
- // when it reaches FLUSH_SIZE events OR after FLUSH_INTERVAL_MS of inactivity.
9793
- // This cuts the number of disk writes by ~95% without changing the on-disk
9794
- // format — the JSONL is still one JSON object per line.
9795
- writeBuffer = [];
9796
- flushTimer = null;
9797
- static FLUSH_INTERVAL_MS = 500;
9798
- static FLUSH_SIZE = 50;
9799
- // ── Write serialization ─────────────────────────────────────────────────
9800
- //
9801
- // All disk writes are funneled through a FIFO promise chain. Without it,
9802
- // a timer-driven flush racing an explicit flush()/close() issues two
9803
- // concurrent appendFile() calls on the shared O_APPEND handle — the kernel
9804
- // may complete them out of order (chronology breaks) or, for large
9805
- // batches, interleave partial writes (torn JSONL lines). The chain keeps
9806
- // exactly one write in flight; failures don't break the chain.
9807
- writeChain = Promise.resolve();
9808
- /** Enqueue a write on the FIFO chain. Resolves/rejects with that write. */
9809
- enqueueWrite(data) {
9810
- const write = this.writeChain.then(() => this.handle.appendFile(data, "utf8"));
9811
- this.writeChain = write.then(
9812
- () => void 0,
9813
- () => void 0
9814
- );
9815
- return write;
9816
- }
9817
- // ── Enriched summary tracking ──────────────────────────────────────────
9818
- iterationCount = 0;
9819
- toolCallCount = 0;
9820
- toolErrorCount = 0;
9821
- toolBreakdown = {};
9822
- fileChangeCount = 0;
9823
- compactionCount = 0;
9824
- outcome = void 0;
9825
9887
  /**
9826
- * Scrub secrets out of conversation-turn events before they are observed
9827
- * for the summary, written to the JSONL log, or surfaced on resume. Only
9828
- * `user_input` / `llm_response` carry free-form user/model text; other event
9829
- * types either have no secret-bearing content or are already scrubbed
9830
- * upstream (tool results). Returns the event unchanged when no scrubber is
9831
- * configured.
9888
+ * List sessions matching filter criteria, using the cached index.
9889
+ * Filters are applied BEFORE sorting and slicing, so the caller gets
9890
+ * exactly `limit` matching sessions not a slice of a larger fetch.
9891
+ *
9892
+ * This avoids the DefaultSessionReader pattern of fetching 1000 sessions
9893
+ * then linear-filtering: the index is already in memory (readIndex
9894
+ * caches it), and the filter runs over the cached array without any
9895
+ * additional disk I/O.
9832
9896
  */
9833
- scrubEvent(event) {
9834
- const s = this.secretScrubber;
9835
- if (!s) return event;
9836
- if (event.type === "user_input") {
9837
- return {
9838
- ...event,
9839
- content: typeof event.content === "string" ? s.scrub(event.content) : s.scrubObject(event.content)
9840
- };
9841
- }
9842
- if (event.type === "llm_response") {
9843
- return { ...event, content: s.scrubObject(event.content) };
9844
- }
9845
- return event;
9846
- }
9847
- pendingFileSnapshots = [];
9848
- /** Tracks open tool_use IDs during the current run to serialize on close for resume. */
9849
- openToolUses = /* @__PURE__ */ new Set();
9850
- recordFileChange(input) {
9851
- this.pendingFileSnapshots.push(input);
9852
- }
9853
- get pendingToolUses() {
9854
- return Array.from(this.openToolUses);
9855
- }
9856
- async writeSessionStartLazy() {
9857
- const record = `${JSON.stringify({
9858
- type: this.resumed ? "session_resumed" : "session_start",
9859
- ts: this.startedAt,
9860
- id: this.id,
9861
- model: this.meta.model ?? "unknown",
9862
- provider: this.meta.provider ?? "unknown"
9863
- })}
9864
- `;
9897
+ async listFiltered(criteria) {
9898
+ const limit = criteria.limit ?? 100;
9865
9899
  try {
9866
- await this.enqueueWrite(record);
9900
+ await ensureDir(this.dir);
9901
+ const indexed = await this.readIndex();
9902
+ if (indexed.length === 0) {
9903
+ const raw = await this.list(Math.max(limit, 100));
9904
+ return raw.filter((s) => matchesSessionFilter(s, criteria)).slice(0, limit);
9905
+ }
9906
+ const filtered = indexed.filter((s) => matchesSessionFilter(s, criteria));
9907
+ filtered.sort((a, b) => {
9908
+ if (a.startedAt < b.startedAt) return 1;
9909
+ if (a.startedAt > b.startedAt) return -1;
9910
+ return a.id.localeCompare(b.id);
9911
+ });
9912
+ return filtered.slice(0, limit);
9867
9913
  } catch {
9914
+ return [];
9868
9915
  }
9869
9916
  }
9870
- async append(event) {
9871
- if (this.closed) return;
9872
- await this.ensureInit();
9873
- const scrubbed = this.scrubEvent(event);
9874
- this.observeForSummary(scrubbed);
9875
- this.writeBuffer.push(scrubbed);
9876
- if (this.writeBuffer.length >= _FileSessionWriter.FLUSH_SIZE) {
9877
- if (this.flushTimer) {
9878
- clearTimeout(this.flushTimer);
9879
- this.flushTimer = null;
9880
- }
9881
- await this.flushBuffer();
9882
- } else {
9883
- this.scheduleFlush();
9884
- }
9885
- }
9886
- async appendBatch(events) {
9887
- if (this.closed || events.length === 0) return;
9888
- await this.ensureInit();
9889
- for (const event of events) {
9890
- const scrubbed = this.scrubEvent(event);
9891
- this.observeForSummary(scrubbed);
9892
- this.writeBuffer.push(scrubbed);
9893
- }
9894
- if (this.writeBuffer.length >= _FileSessionWriter.FLUSH_SIZE) {
9895
- if (this.flushTimer) {
9896
- clearTimeout(this.flushTimer);
9897
- this.flushTimer = null;
9917
+ // ── Session index (_index.jsonl) ─────────────────────────────────────────
9918
+ //
9919
+ // One JSON line per closed session, appended atomically on close().
9920
+ // When a session is deleted, a tombstone {action:"delete",id:"..."} is
9921
+ // appended. On read, tombstones filter out matching session entries.
9922
+ // This keeps listing O(lines-in-index) instead of O(files-on-disk).
9923
+ //
9924
+ // The index auto-compacts every N appends to prevent unbounded growth
9925
+ // from tombstones and duplicate entries (resume cycles).
9926
+ indexAppendCount = 0;
9927
+ static COMPACT_EVERY = 30;
9928
+ /** Append a session summary to the index. */
9929
+ async appendToIndex(summary) {
9930
+ try {
9931
+ await ensureDir(this.dir);
9932
+ const line = JSON.stringify(summary) + "\n";
9933
+ await fsp7.appendFile(this.indexFile, line, "utf8");
9934
+ this._indexCache = null;
9935
+ this.invalidateShardManifestBySessionId(summary.id);
9936
+ this.indexAppendCount++;
9937
+ if (this.indexAppendCount >= _DefaultSessionStore.COMPACT_EVERY) {
9938
+ await this.compactIndex();
9939
+ this.indexAppendCount = 0;
9898
9940
  }
9899
- await this.flushBuffer();
9900
- } else {
9901
- this.scheduleFlush();
9902
- }
9903
- }
9904
- /**
9905
- * Flush buffered events to disk immediately. Critical events
9906
- * (user_input, llm_response) call this so they survive SIGKILL/crash
9907
- * instead of sitting in the in-memory buffer for up to 500ms.
9908
- *
9909
- * Idempotent — cancels any pending timer and writes whatever has
9910
- * accumulated in the buffer. Safe to call even when the buffer
9911
- * is empty (no-op).
9912
- */
9913
- async flush() {
9914
- if (this.flushTimer) {
9915
- clearTimeout(this.flushTimer);
9916
- this.flushTimer = null;
9941
+ } catch {
9917
9942
  }
9918
- await this.flushBuffer();
9919
9943
  }
9920
- /** Schedule a deferred flush. No-op if a timer is already pending. */
9921
- scheduleFlush() {
9922
- if (this.flushTimer) return;
9923
- this.flushTimer = setTimeout(() => {
9924
- this.flushTimer = null;
9925
- this.flushBuffer().catch(() => {
9926
- });
9927
- }, _FileSessionWriter.FLUSH_INTERVAL_MS);
9944
+ /** Append a tombstone entry for a deleted session. */
9945
+ async writeTombstone(id) {
9946
+ try {
9947
+ await ensureDir(this.dir);
9948
+ const line = JSON.stringify({ action: "delete", id }) + "\n";
9949
+ await fsp7.appendFile(this.indexFile, line, "utf8");
9950
+ this._indexCache = null;
9951
+ this.invalidateShardManifestBySessionId(id);
9952
+ this.indexAppendCount++;
9953
+ } catch {
9954
+ }
9928
9955
  }
9929
9956
  /**
9930
- * Flush all buffered events to disk as a single appendFile call.
9931
- * Errors use the same throttled-warning pattern the old per-event
9932
- * append path used — one warning every 5s with a suppressed count.
9933
- * On failure the buffer is cleared (events are best-effort, same as
9934
- * the old per-event path where a failed write was silently dropped).
9957
+ * Compact the index: read all entries, drop tombstones, deduplicate
9958
+ * (keep latest per session), and rewrite. Atomic via temp+rename.
9935
9959
  */
9936
- async flushBuffer() {
9937
- if (this.writeBuffer.length === 0) return;
9938
- const eventCount = this.writeBuffer.length;
9939
- const batch = this.writeBuffer.map((e) => JSON.stringify(e)).join("\n") + "\n";
9940
- this.writeBuffer = [];
9960
+ async compactIndex() {
9941
9961
  const t0 = Date.now();
9942
9962
  let outcome = "success";
9943
9963
  let errorMsg;
9944
9964
  try {
9945
- await this.enqueueWrite(batch);
9965
+ const entries = await this.readIndex();
9966
+ if (entries.length === 0) return;
9967
+ const tmp = `${this.indexFile}.compact.tmp`;
9968
+ const lines = entries.map((s) => JSON.stringify(s)).join("\n") + "\n";
9969
+ await fsp7.writeFile(tmp, lines, "utf8");
9970
+ await fsp7.rename(tmp, this.indexFile);
9971
+ this._indexCache = null;
9946
9972
  } catch (err) {
9947
9973
  outcome = "failure";
9948
9974
  errorMsg = toErrorMessage(err);
9949
- this.appendFailCount += eventCount;
9950
- const now = Date.now();
9951
- if (now - this.lastAppendWarnAt > 5e3) {
9952
- const suppressed = this.appendFailCount - 1;
9953
- const tail = suppressed > 0 ? ` (+${suppressed} suppressed)` : "";
9954
- console.warn(
9955
- "[session] flush failed:",
9956
- toErrorMessage(err),
9957
- tail
9958
- );
9959
- this.lastAppendWarnAt = now;
9960
- this.appendFailCount = 0;
9961
- }
9962
9975
  } finally {
9963
- this.events?.emit("storage.write", {
9964
- sessionId: this.id,
9965
- store: "session",
9966
- filePath: this.filePath,
9967
- operation: "flush",
9968
- outcome,
9969
- durationMs: Date.now() - t0,
9970
- ...errorMsg !== void 0 ? { error: errorMsg } : {},
9971
- ...eventCount !== void 0 ? { eventCount } : {},
9972
- ...this.traceId !== void 0 ? { traceId: this.traceId } : {}
9973
- });
9976
+ this.emitWrite("~compact~", this.indexFile, "compact", outcome, Date.now() - t0, void 0, errorMsg);
9974
9977
  }
9975
9978
  }
9976
- observeForSummary(event) {
9977
- if (event.type === "llm_response") {
9978
- for (const block of event.content) {
9979
- if (block.type === "tool_use") this.openToolUses.add(block.id);
9979
+ /**
9980
+ * Read the index file and return deduplicated session summaries.
9981
+ * Entries with a matching tombstone are filtered out.
9982
+ * Returns empty array when the index doesn't exist or is corrupt.
9983
+ */
9984
+ async readIndex() {
9985
+ let stat7;
9986
+ try {
9987
+ const s = await fsp7.stat(this.indexFile);
9988
+ stat7 = { mtimeMs: s.mtimeMs, size: s.size };
9989
+ } catch {
9990
+ this._indexCache = null;
9991
+ return [];
9992
+ }
9993
+ if (this._indexCache !== null && this._indexCache.mtimeMs === stat7.mtimeMs && this._indexCache.size === stat7.size) {
9994
+ return [...this._indexCache.summaries];
9995
+ }
9996
+ let raw;
9997
+ try {
9998
+ raw = await fsp7.readFile(this.indexFile, "utf8");
9999
+ } catch {
10000
+ this._indexCache = null;
10001
+ return [];
10002
+ }
10003
+ const deleted = /* @__PURE__ */ new Set();
10004
+ const seen = /* @__PURE__ */ new Map();
10005
+ for (const line of raw.split("\n")) {
10006
+ if (!line.trim()) continue;
10007
+ try {
10008
+ const entry = JSON.parse(line);
10009
+ if (entry.action === "delete" && entry.id) {
10010
+ deleted.add(entry.id);
10011
+ seen.delete(entry.id);
10012
+ continue;
10013
+ }
10014
+ if (entry.id && !deleted.has(entry.id)) {
10015
+ seen.set(entry.id, entry);
10016
+ }
10017
+ } catch {
9980
10018
  }
9981
10019
  }
9982
- if (event.type === "tool_use") {
9983
- this.openToolUses.add(event.id);
9984
- } else if (event.type === "tool_call_start") {
9985
- this.toolCallCount++;
9986
- this.toolBreakdown[event.name] = (this.toolBreakdown[event.name] ?? 0) + 1;
9987
- } else if (event.type === "tool_result") {
9988
- this.openToolUses.delete(event.id);
9989
- if (event.isError) {
9990
- this.toolErrorCount++;
9991
- this.outcome = "error";
10020
+ const summaries = Array.from(seen.values());
10021
+ this._indexCache = { ...stat7, summaries };
10022
+ return [...summaries];
10023
+ }
10024
+ /**
10025
+ * Rebuild the index from disk by scanning all sessions and writing a
10026
+ * fresh _index.jsonl. Useful after manual cleanup or index corruption.
10027
+ */
10028
+ async rebuildIndex() {
10029
+ const ids = await this.collectSessionIds(this.dir);
10030
+ const summaries = await Promise.all(ids.map((id) => this.summaryFor(id).catch(() => null)));
10031
+ const valid = summaries.filter((s) => s !== null);
10032
+ const tmp = `${this.indexFile}.tmp`;
10033
+ const lines = valid.map((s) => JSON.stringify(s)).join("\n") + "\n";
10034
+ await fsp7.writeFile(tmp, lines, "utf8");
10035
+ await fsp7.rename(tmp, this.indexFile);
10036
+ this._indexCache = null;
10037
+ return valid.length;
10038
+ }
10039
+ async listFromDirectoryScan(limit) {
10040
+ const shardKeys = await this.collectShardKeys();
10041
+ const shardEntries = await mapWithConcurrency(
10042
+ shardKeys,
10043
+ _DefaultSessionStore.LIST_SCAN_CONCURRENCY,
10044
+ async (shardKey) => await this.readOrBuildShardManifest(shardKey)
10045
+ );
10046
+ const out = [];
10047
+ for (const entry of shardEntries) {
10048
+ for (const summary of entry.summaries) {
10049
+ out.push({ summary, needsBackfill: false });
9992
10050
  }
9993
- } else if (event.type === "file_snapshot") {
9994
- this.fileChangeCount += event.files.length;
9995
- } else if (event.type === "compaction") {
9996
- this.compactionCount++;
9997
10051
  }
9998
- if (event.type === "error" || event.type === "provider_error") {
9999
- this.outcome = "error";
10052
+ out.sort((a, b) => compareSessionSummaries(a.summary, b.summary));
10053
+ const selected = out.slice(0, limit);
10054
+ const summaries = await mapWithConcurrency(
10055
+ selected,
10056
+ Math.min(_DefaultSessionStore.LIST_SCAN_CONCURRENCY, Math.max(1, limit)),
10057
+ async (candidate) => candidate.summary
10058
+ );
10059
+ return summaries.filter((s) => s !== null);
10060
+ }
10061
+ async collectShardKeys() {
10062
+ let entries;
10063
+ try {
10064
+ entries = await fsp7.readdir(this.dir, { withFileTypes: true });
10065
+ } catch {
10066
+ return [""];
10000
10067
  }
10001
- if (event.type === "user_input" && this.summary.title === "(empty session)") {
10002
- this.summary = { ...this.summary, title: userInputTitle(event.content) };
10003
- } else if (event.type === "llm_response") {
10004
- this.tokenIn += event.usage.input;
10005
- this.tokenOut += event.usage.output;
10006
- this.summary = { ...this.summary, tokenTotal: this.tokenIn + this.tokenOut };
10007
- } else if (event.type === "session_end") {
10008
- const total = event.usage.input + event.usage.output;
10009
- if (total > 0) this.summary = { ...this.summary, tokenTotal: total };
10010
- } else if (event.type === "in_flight_start") {
10011
- this.iterationCount++;
10068
+ const shardKeys = [""];
10069
+ for (const entry of entries) {
10070
+ if (entry.name.startsWith(".") && entry.name !== ".wrongstack") continue;
10071
+ if (entry.name === "shared" || entry.name === "subagents" || entry.name === "attachments") continue;
10072
+ if (entry.isDirectory()) shardKeys.push(entry.name);
10012
10073
  }
10074
+ return shardKeys;
10013
10075
  }
10014
- async close() {
10015
- if (this.closePromise) return this.closePromise;
10016
- this.closePromise = this.doClose();
10017
- return this.closePromise;
10076
+ async readOrBuildShardManifest(shardKey) {
10077
+ const cached = this.shardManifestCache.get(shardKey);
10078
+ if (cached) return cached;
10079
+ const manifestPath = this.shardManifestPath(shardKey);
10080
+ try {
10081
+ const raw = await fsp7.readFile(manifestPath, "utf8");
10082
+ const parsed = JSON.parse(raw);
10083
+ const entry2 = {
10084
+ summaries: Array.isArray(parsed.summaries) ? parsed.summaries : [],
10085
+ ids: Array.isArray(parsed.ids) ? parsed.ids : []
10086
+ };
10087
+ this.shardManifestCache.set(shardKey, entry2);
10088
+ return entry2;
10089
+ } catch {
10090
+ }
10091
+ const refs = await this.collectSessionFilesInShard(shardKey);
10092
+ const candidates = await mapWithConcurrency(
10093
+ refs,
10094
+ _DefaultSessionStore.LIST_SCAN_CONCURRENCY,
10095
+ async (ref) => {
10096
+ const manifest = await this.readSummaryManifest(ref.id);
10097
+ if (manifest) return { summary: manifest, needsBackfill: false };
10098
+ const summary = await this.summaryHeaderFor(ref);
10099
+ if (!summary) return null;
10100
+ const hydrated = await this.summaryFor(summary.id).catch(() => summary);
10101
+ return { summary: hydrated, needsBackfill: false };
10102
+ }
10103
+ );
10104
+ const summaries = candidates.filter((candidate) => candidate !== null).map((candidate) => candidate.summary);
10105
+ summaries.sort(compareSessionSummaries);
10106
+ const entry = { summaries, ids: summaries.map((summary) => summary.id) };
10107
+ this.shardManifestCache.set(shardKey, entry);
10108
+ await atomicWrite(manifestPath, JSON.stringify(entry), { mode: 384 }).catch(() => void 0);
10109
+ return entry;
10110
+ }
10111
+ async collectSessionFilesInShard(shardKey) {
10112
+ const dir = shardKey ? path6.join(this.dir, shardKey) : this.dir;
10113
+ const entries = await this.collectSessionFiles(dir, shardKey);
10114
+ return shardKey ? entries.filter((entry) => entry.id.startsWith(`${shardKey}/`)) : entries.filter((entry) => !entry.id.includes("/"));
10018
10115
  }
10019
- async doClose() {
10020
- this.closed = true;
10021
- if (this.flushTimer) {
10022
- clearTimeout(this.flushTimer);
10023
- this.flushTimer = null;
10116
+ async collectSessionFiles(dir, prefix = "", depth = 0) {
10117
+ let entries;
10118
+ try {
10119
+ entries = await fsp7.readdir(dir, { withFileTypes: true });
10120
+ } catch {
10121
+ return [];
10024
10122
  }
10025
- await this.flushBuffer();
10026
- await this.writeChain;
10027
- this.summary = {
10028
- ...this.summary,
10029
- endedAt: (/* @__PURE__ */ new Date()).toISOString(),
10030
- iterationCount: this.iterationCount,
10031
- toolCallCount: this.toolCallCount,
10032
- toolErrorCount: this.toolErrorCount,
10033
- fileChangeCount: this.fileChangeCount,
10034
- compactionCount: this.compactionCount > 0 ? this.compactionCount : void 0,
10035
- toolBreakdown: { ...this.toolBreakdown },
10036
- outcome: this.outcome ?? "completed"
10037
- };
10038
- if (this.manifestFile) {
10039
- const t0 = Date.now();
10040
- let outcome = "success";
10041
- let errorMsg;
10042
- try {
10043
- await atomicWrite(this.manifestFile, JSON.stringify(this.summary), { mode: 384 });
10044
- } catch (err) {
10045
- outcome = "failure";
10046
- errorMsg = toErrorMessage(err);
10047
- } finally {
10048
- this.events?.emit("storage.write", {
10049
- sessionId: this.id,
10050
- store: "session",
10051
- filePath: this.manifestFile,
10052
- operation: "close",
10053
- outcome,
10054
- durationMs: Date.now() - t0,
10055
- ...errorMsg !== void 0 ? { error: errorMsg } : {},
10056
- ...this.traceId !== void 0 ? { traceId: this.traceId } : {}
10057
- });
10123
+ const dirEntries = [];
10124
+ const files = [];
10125
+ for (const entry of entries) {
10126
+ if (entry.name.startsWith(".") && entry.name !== ".wrongstack") continue;
10127
+ if (entry.name === "shared" || entry.name === "subagents" || entry.name === "attachments")
10128
+ continue;
10129
+ if (entry.isDirectory()) {
10130
+ dirEntries.push(entry);
10131
+ } else if (entry.isFile() && entry.name.endsWith(".jsonl")) {
10132
+ if (entry.name === "_index.jsonl") continue;
10133
+ const base = entry.name.replace(/\.jsonl$/, "");
10134
+ const id = prefix ? `${prefix}/${base}` : base;
10135
+ files.push({ id, filePath: path6.join(dir, entry.name) });
10136
+ }
10137
+ }
10138
+ const childFileArrays = await Promise.all(
10139
+ dirEntries.map((entry) => {
10140
+ const childPrefix = depth === 0 ? entry.name : `${prefix}/${entry.name}`;
10141
+ return this.collectSessionFiles(path6.join(dir, entry.name), childPrefix, depth + 1);
10142
+ })
10143
+ );
10144
+ return [...childFileArrays.flat(), ...files];
10145
+ }
10146
+ /** Recursively collect session IDs from date-shard subdirectories.
10147
+ * IDs include the date-prefix path (e.g. "2026-06-06/17-46-57Z_…").
10148
+ * Skips `.jsonl`/`.summary.json` root files, dot-files, and
10149
+ * sub-directories that belong to fleet/subagent sessions. */
10150
+ async collectSessionIds(dir, prefix = "", depth = 0) {
10151
+ let entries;
10152
+ try {
10153
+ entries = await fsp7.readdir(dir, { withFileTypes: true });
10154
+ } catch {
10155
+ return [];
10156
+ }
10157
+ const dirEntries = [];
10158
+ const fileIds = [];
10159
+ for (const entry of entries) {
10160
+ if (entry.name.startsWith(".") && entry.name !== ".wrongstack") continue;
10161
+ if (entry.name === "shared" || entry.name === "subagents" || entry.name === "attachments")
10162
+ continue;
10163
+ if (entry.isDirectory()) {
10164
+ dirEntries.push(entry);
10165
+ } else if (entry.isFile() && entry.name.endsWith(".jsonl")) {
10166
+ if (entry.name === "_index.jsonl") continue;
10167
+ const base = entry.name.replace(/\.jsonl$/, "");
10168
+ fileIds.push(prefix ? `${prefix}/${base}` : base);
10058
10169
  }
10059
10170
  }
10060
- const idxT0 = Date.now();
10061
- let idxOutcome = "success";
10062
- let idxError;
10171
+ const childIdArrays = await Promise.all(
10172
+ dirEntries.map((entry) => {
10173
+ const childPrefix = depth === 0 ? entry.name : `${prefix}/${entry.name}`;
10174
+ return this.collectSessionIds(path6.join(dir, entry.name), childPrefix, depth + 1);
10175
+ })
10176
+ );
10177
+ return [...childIdArrays.flat(), ...fileIds];
10178
+ }
10179
+ async summaryFor(id) {
10180
+ const manifest = this.sessionPath(id, ".summary.json");
10181
+ const t0 = Date.now();
10182
+ let outcome = "success";
10183
+ let errorMsg;
10184
+ const fromManifest = await this.readSummaryManifest(id, t0);
10185
+ if (fromManifest) return fromManifest;
10063
10186
  try {
10064
- await this.onCloseCb?.(this.summary);
10065
- } catch (err) {
10066
- idxOutcome = "failure";
10067
- idxError = toErrorMessage(err);
10068
- } finally {
10069
- this.events?.emit("storage.write", {
10070
- sessionId: this.summary.id,
10071
- store: "session",
10072
- filePath: this.filePath,
10073
- operation: "index_append",
10074
- outcome: idxOutcome,
10075
- durationMs: Date.now() - idxT0,
10076
- ...idxError !== void 0 ? { error: idxError } : {},
10077
- ...this.traceId !== void 0 ? { traceId: this.traceId } : {}
10187
+ const full = this.sessionPath(id, ".jsonl");
10188
+ const stat7 = await fsp7.stat(full);
10189
+ const summary = await this.summarize(id, stat7.mtime.toISOString());
10190
+ await atomicWrite(manifest, JSON.stringify(summary), { mode: 384 }).catch((err) => {
10191
+ const msg = toErrorMessage(err);
10192
+ this.emitError(id, manifest, "summary_fallback", msg, true);
10193
+ console.warn(JSON.stringify({
10194
+ level: "warn",
10195
+ event: "session_store.manifest_write_failed",
10196
+ sessionId: id,
10197
+ message: msg,
10198
+ timestamp: (/* @__PURE__ */ new Date()).toISOString()
10199
+ }));
10078
10200
  });
10201
+ outcome = "failure";
10202
+ errorMsg = "summary fallback \xE2\u20AC\u201D manifest rebuilt";
10203
+ this.emitRead(id, manifest, "summary", outcome, Date.now() - t0, errorMsg);
10204
+ return summary;
10205
+ } catch (err) {
10206
+ outcome = "failure";
10207
+ errorMsg = toErrorMessage(err);
10208
+ this.emitRead(id, manifest, "summary", outcome, Date.now() - t0, errorMsg);
10209
+ return {
10210
+ id,
10211
+ title: "(damaged)",
10212
+ startedAt: (/* @__PURE__ */ new Date()).toISOString(),
10213
+ model: "unknown",
10214
+ provider: "unknown",
10215
+ tokenTotal: 0
10216
+ };
10079
10217
  }
10218
+ }
10219
+ async readSummaryManifest(id, startTime = Date.now()) {
10220
+ const manifest = this.sessionPath(id, ".summary.json");
10080
10221
  try {
10081
- await this.handle.close();
10222
+ const raw = await fsp7.readFile(manifest, "utf8");
10223
+ this.emitRead(id, manifest, "summary", "success", Date.now() - startTime);
10224
+ return JSON.parse(raw);
10082
10225
  } catch {
10226
+ return null;
10083
10227
  }
10084
10228
  }
10085
- async writeCheckpoint(promptIndex, promptPreview) {
10086
- const fileCount = this.pendingFileSnapshots.length;
10087
- if (fileCount > 0) {
10088
- await this.writeFileSnapshot(promptIndex, [...this.pendingFileSnapshots]);
10089
- this.pendingFileSnapshots = [];
10229
+ async summaryHeaderFor(ref) {
10230
+ let mtime = (/* @__PURE__ */ new Date(0)).toISOString();
10231
+ try {
10232
+ const stat7 = await fsp7.stat(ref.filePath);
10233
+ if (!stat7.isFile()) {
10234
+ return {
10235
+ id: ref.id,
10236
+ title: "(damaged)",
10237
+ startedAt: stat7.mtime.toISOString(),
10238
+ model: "unknown",
10239
+ provider: "unknown",
10240
+ tokenTotal: 0
10241
+ };
10242
+ }
10243
+ mtime = stat7.mtime.toISOString();
10244
+ } catch {
10245
+ return null;
10246
+ }
10247
+ try {
10248
+ for await (const event of this.iterSessionEvents(ref.filePath)) {
10249
+ if (event.type === "session_start") {
10250
+ return {
10251
+ id: ref.id,
10252
+ title: "(empty session)",
10253
+ startedAt: event.ts,
10254
+ model: event.model ?? "unknown",
10255
+ provider: event.provider ?? "unknown",
10256
+ tokenTotal: 0
10257
+ };
10258
+ }
10259
+ }
10260
+ return {
10261
+ id: ref.id,
10262
+ title: "(empty session)",
10263
+ startedAt: (/* @__PURE__ */ new Date(0)).toISOString(),
10264
+ model: "unknown",
10265
+ provider: "unknown",
10266
+ tokenTotal: 0
10267
+ };
10268
+ } catch {
10269
+ return {
10270
+ id: ref.id,
10271
+ title: "(damaged)",
10272
+ startedAt: mtime,
10273
+ model: "unknown",
10274
+ provider: "unknown",
10275
+ tokenTotal: 0
10276
+ };
10090
10277
  }
10091
- await this.append({
10092
- type: "checkpoint",
10093
- ts: (/* @__PURE__ */ new Date()).toISOString(),
10094
- promptIndex,
10095
- promptPreview
10096
- });
10097
- this.events?.emit("checkpoint.written", {
10098
- promptIndex,
10099
- promptPreview,
10100
- ts: (/* @__PURE__ */ new Date()).toISOString(),
10101
- fileCount
10102
- });
10103
- }
10104
- async writeFileSnapshot(promptIndex, files) {
10105
- await this.append({
10106
- type: "file_snapshot",
10107
- ts: (/* @__PURE__ */ new Date()).toISOString(),
10108
- promptIndex,
10109
- files
10110
- });
10111
10278
  }
10112
10279
  /**
10113
- * Truncate the session file to the checkpoint with the given promptIndex,
10114
- * removing all events that follow it. Uses a single-pass byte-offset scan
10115
- * so post-checkpoint content is never read or parsed — O(1) memory instead
10116
- * of O(N) JSON.parse calls over the full file.
10280
+ * Delete a session and all associated files: JSONL, summary, plan/todos
10281
+ * sidecars, and the session directory (fleet.json, shared/, subagents/).
10282
+ *
10283
+ * Individual file deletions are best-effort (logged as structured warnings),
10284
+ * but a tombstone is always written so readIndex() filters this session out.
10285
+ * If the session directory itself can't be removed, the error is surfaced
10286
+ * to the caller so prune() can report it.
10117
10287
  */
10118
- async truncateToCheckpoint(targetPromptIndex) {
10119
- if (!this.filePath) return 0;
10120
- if (this.flushTimer) {
10121
- clearTimeout(this.flushTimer);
10122
- this.flushTimer = null;
10288
+ async deleteSession(id) {
10289
+ const jsonlPath = this.sessionPath(id, ".jsonl");
10290
+ const summaryPath = this.sessionPath(id, ".summary.json");
10291
+ const shardDir = path6.dirname(path6.join(this.dir, id));
10292
+ const base = path6.basename(id);
10293
+ const sessDir = path6.join(shardDir, base);
10294
+ const deletions = [
10295
+ fsp7.unlink(jsonlPath),
10296
+ fsp7.unlink(summaryPath),
10297
+ fsp7.unlink(path6.join(shardDir, `${base}.plan.json`)),
10298
+ fsp7.unlink(path6.join(shardDir, `${base}.todos.json`))
10299
+ ];
10300
+ const results = await Promise.allSettled(deletions);
10301
+ for (const r of results) {
10302
+ if (r.status === "rejected") {
10303
+ const msg = r.reason instanceof Error ? r.reason.message : String(r.reason);
10304
+ if (r.reason?.code !== "ENOENT") {
10305
+ console.warn(JSON.stringify({
10306
+ level: "warn",
10307
+ event: "session_store.delete_failed",
10308
+ sessionId: id,
10309
+ message: msg,
10310
+ timestamp: (/* @__PURE__ */ new Date()).toISOString()
10311
+ }));
10312
+ }
10313
+ }
10123
10314
  }
10124
- await this.flushBuffer();
10125
- await this.writeChain;
10126
- const CHUNK_SIZE = 65536;
10127
- let fd;
10128
- let fileOffset = 0;
10129
- let lineStartOffset = 0;
10130
- let checkpointByteOffset = -1;
10131
- let removedCount = 0;
10132
- let targetCheckpointSeen = false;
10315
+ await fsp7.rm(sessDir, { recursive: true, force: true }).catch((err) => {
10316
+ console.warn(JSON.stringify({
10317
+ level: "warn",
10318
+ event: "session_store.rmdir_failed",
10319
+ sessionId: id,
10320
+ message: toErrorMessage(err),
10321
+ timestamp: (/* @__PURE__ */ new Date()).toISOString()
10322
+ }));
10323
+ });
10324
+ await this.writeTombstone(id);
10325
+ }
10326
+ async delete(id) {
10327
+ await this.deleteSession(id);
10328
+ }
10329
+ async prune(maxAgeDays = 30) {
10330
+ const cutoff = Date.now() - maxAgeDays * 864e5;
10331
+ let deleted = 0;
10332
+ let activeSessionId = null;
10133
10333
  try {
10134
- fd = await fsp6.open(this.filePath, "r", 384);
10135
- while (true) {
10136
- const buf = Buffer.alloc(CHUNK_SIZE);
10137
- const { bytesRead } = await fd.read(buf, 0, CHUNK_SIZE, fileOffset);
10138
- if (bytesRead === 0) break;
10139
- let chunkPos = 0;
10140
- while (chunkPos < bytesRead) {
10141
- const idx = buf.indexOf("\n", chunkPos);
10142
- if (idx === -1) {
10143
- lineStartOffset = fileOffset + chunkPos;
10144
- break;
10145
- }
10146
- if (checkpointByteOffset !== -1) {
10147
- removedCount++;
10148
- } else {
10149
- const lineBytes = buf.subarray(chunkPos, idx);
10150
- const line = new TextDecoder("utf-8", { fatal: false }).decode(lineBytes);
10151
- if (line.trim()) {
10152
- try {
10153
- const event = JSON.parse(line);
10154
- if (event.type === "checkpoint") {
10155
- if (event.promptIndex === targetPromptIndex) {
10156
- checkpointByteOffset = lineStartOffset;
10157
- targetCheckpointSeen = true;
10158
- } else if (event.promptIndex !== void 0 && event.promptIndex > targetPromptIndex) {
10159
- checkpointByteOffset = lineStartOffset;
10160
- }
10161
- } else if (targetCheckpointSeen && event.promptIndex !== void 0 && event.promptIndex > targetPromptIndex) {
10162
- removedCount++;
10163
- } else if (targetCheckpointSeen && event.promptIndex === void 0) {
10164
- removedCount++;
10165
- } else if (!targetCheckpointSeen && event.promptIndex === void 0) {
10166
- removedCount++;
10167
- } else if (!targetCheckpointSeen && event.promptIndex !== void 0 && event.promptIndex > targetPromptIndex) {
10168
- removedCount++;
10169
- }
10170
- } catch {
10171
- }
10172
- }
10173
- }
10174
- chunkPos = idx + 1;
10175
- lineStartOffset = fileOffset + chunkPos;
10176
- }
10177
- fileOffset += bytesRead;
10178
- if (chunkPos >= bytesRead) {
10179
- lineStartOffset = fileOffset;
10180
- }
10334
+ const raw = await fsp7.readFile(path6.join(this.dir, "active.json"), "utf8");
10335
+ const active = JSON.parse(raw);
10336
+ activeSessionId = active.sessionId ?? null;
10337
+ } catch {
10338
+ }
10339
+ const isPrunableJsonl = (name) => name.endsWith(".jsonl") && name !== "_index.jsonl" && name !== "_mailbox.jsonl" && !name.endsWith(".replay.jsonl") && !name.endsWith(".audit.jsonl");
10340
+ const pruneFile = async (dir, name, prefix) => {
10341
+ const jsonlPath = path6.join(dir, name);
10342
+ try {
10343
+ const stat7 = await fsp7.stat(jsonlPath);
10344
+ if (stat7.mtimeMs >= cutoff) return;
10345
+ } catch {
10346
+ return;
10347
+ }
10348
+ const base = name.replace(/\.jsonl$/, "");
10349
+ const id = prefix ? `${prefix}/${base}` : base;
10350
+ if (activeSessionId && id === activeSessionId) return;
10351
+ await this.deleteSession(id);
10352
+ deleted++;
10353
+ };
10354
+ const entries = await fsp7.readdir(this.dir, { withFileTypes: true }).catch(() => []);
10355
+ for (const entry of entries) {
10356
+ if (entry.isFile()) {
10357
+ if (isPrunableJsonl(entry.name)) await pruneFile(this.dir, entry.name, "");
10358
+ continue;
10181
10359
  }
10182
- } finally {
10183
- await fd?.close();
10184
- }
10185
- if (checkpointByteOffset === -1) return 0;
10186
- await this.writeChain;
10187
- await this.handle.close();
10188
- const tmpPath = `${this.filePath}.rewind.tmp`;
10189
- const src = await fsp6.open(this.filePath, "r", 384);
10190
- try {
10191
- const statResult = await src.stat();
10192
- const totalSize = statResult.size;
10193
- const prefixBytes = checkpointByteOffset;
10194
- let newlineAfterCheckpoint = prefixBytes;
10195
- if (prefixBytes < totalSize) {
10196
- const probeBuf = Buffer.alloc(Math.min(CHUNK_SIZE, totalSize - prefixBytes));
10197
- const { bytesRead: probeRead } = await src.read(probeBuf, 0, probeBuf.length, prefixBytes);
10198
- if (probeRead > 0) {
10199
- const nl = probeBuf.indexOf("\n");
10200
- newlineAfterCheckpoint = nl !== -1 ? prefixBytes + nl + 1 : totalSize;
10201
- }
10202
- } else {
10203
- newlineAfterCheckpoint = totalSize;
10360
+ if (!entry.isDirectory()) continue;
10361
+ const dateDir = path6.join(this.dir, entry.name);
10362
+ const files = await fsp7.readdir(dateDir, { withFileTypes: true }).catch(() => []);
10363
+ for (const file of files) {
10364
+ if (!file.isFile() || !isPrunableJsonl(file.name)) continue;
10365
+ await pruneFile(dateDir, file.name, entry.name);
10204
10366
  }
10205
- const writeFd = await fsp6.open(tmpPath, "w", 384);
10367
+ }
10368
+ if (deleted > 0) {
10369
+ await this.compactIndex().catch(() => void 0);
10370
+ }
10371
+ for (const entry of entries) {
10372
+ if (!entry.isDirectory()) continue;
10373
+ const dateDir = path6.join(this.dir, entry.name);
10206
10374
  try {
10207
- let readOffset = 0;
10208
- while (readOffset < newlineAfterCheckpoint) {
10209
- const toCopy = Math.min(CHUNK_SIZE, newlineAfterCheckpoint - readOffset);
10210
- const copyBuf = Buffer.alloc(toCopy);
10211
- const { bytesRead: r } = await src.read(copyBuf, 0, toCopy, readOffset);
10212
- if (r === 0) break;
10213
- await writeFd.write(copyBuf, 0, r);
10214
- readOffset += r;
10215
- }
10216
- const raw = await fsp6.readFile(this.filePath);
10217
- const tail = raw.subarray(newlineAfterCheckpoint).toString("utf8");
10218
- for (const line of tail.split("\n")) {
10219
- if (!line.trim()) continue;
10220
- try {
10221
- JSON.parse(line);
10222
- } catch {
10223
- await writeFd.write(`${line}
10224
- `, void 0, "utf8");
10225
- }
10375
+ const remaining = await fsp7.readdir(dateDir);
10376
+ if (remaining.length === 0) {
10377
+ await fsp7.rmdir(dateDir).catch(() => void 0);
10226
10378
  }
10227
- } finally {
10228
- await writeFd.close();
10379
+ } catch {
10229
10380
  }
10230
- await src.close();
10231
- await fsp6.rename(tmpPath, this.filePath);
10232
- this.handle = await fsp6.open(this.filePath, "a", 384);
10233
- } catch (err) {
10234
- await fsp6.unlink(tmpPath).catch(() => void 0);
10235
- this.handle = await fsp6.open(this.filePath, "a", 384).catch(() => this.handle);
10236
- throw err;
10237
10381
  }
10238
- await this.append({
10239
- type: "rewound",
10240
- ts: (/* @__PURE__ */ new Date()).toISOString(),
10241
- toPromptIndex: targetPromptIndex,
10242
- revertedFiles: []
10243
- });
10244
- this.events?.emit("session.rewound", {
10245
- toPromptIndex: targetPromptIndex,
10246
- revertedFiles: [],
10247
- removedEvents: removedCount
10248
- });
10249
- return removedCount;
10382
+ return deleted;
10250
10383
  }
10251
- async clearSession() {
10252
- if (!this.filePath) return;
10253
- if (this.flushTimer) {
10254
- clearTimeout(this.flushTimer);
10255
- this.flushTimer = null;
10256
- }
10257
- this.writeBuffer = [];
10258
- await this.writeChain;
10384
+ async clearHistory(id) {
10385
+ await this.ensureShardDir(id);
10386
+ const file = this.sessionPath(id, ".jsonl");
10387
+ const meta = this.sessionPath(id, ".summary.json");
10259
10388
  const record = `${JSON.stringify({
10260
10389
  type: "session_start",
10261
10390
  ts: (/* @__PURE__ */ new Date()).toISOString(),
10262
- id: this.id,
10263
- model: this.meta.model ?? "unknown",
10264
- provider: this.meta.provider ?? "unknown"
10391
+ id,
10392
+ model: "unknown",
10393
+ provider: "unknown"
10265
10394
  })}
10266
10395
  `;
10267
- await fsp6.writeFile(this.filePath, record, "utf8");
10396
+ await fsp7.writeFile(file, record, "utf8");
10397
+ await fsp7.unlink(meta).catch(() => void 0);
10268
10398
  }
10269
- /**
10270
- * Idea #1 — write an in-flight marker. The agent loop should call
10271
- * this at the start of each long-running operation; a matching
10272
- * `clearInFlightMarker` follows on clean exit. A stale marker
10273
- * (no end) is what `SessionRecovery.detectStale` looks for.
10274
- */
10275
- async writeInFlightMarker(context) {
10276
- if (!context || context.length > 500) {
10277
- throw new Error("In-flight context must be 1..500 chars");
10399
+ async summarize(id, mtime) {
10400
+ try {
10401
+ const file = this.sessionPath(id, ".jsonl");
10402
+ let title = "(empty session)";
10403
+ let startedAt = (/* @__PURE__ */ new Date(0)).toISOString();
10404
+ let endedAt;
10405
+ let model = "unknown";
10406
+ let provider = "unknown";
10407
+ let tokenIn = 0;
10408
+ let tokenOut = 0;
10409
+ let iterationCount = 0;
10410
+ let toolCallCount = 0;
10411
+ let toolErrorCount = 0;
10412
+ let fileChangeCount = 0;
10413
+ const toolBreakdown = {};
10414
+ let outcome;
10415
+ let lastEventType;
10416
+ let hasError = false;
10417
+ let sawStart = false;
10418
+ for await (const e of this.iterSessionEvents(file)) {
10419
+ lastEventType = e.type;
10420
+ if (e.type === "session_start") {
10421
+ if (!sawStart) {
10422
+ sawStart = true;
10423
+ startedAt = e.ts;
10424
+ model = e.model ?? "unknown";
10425
+ provider = e.provider ?? "unknown";
10426
+ }
10427
+ } else if (e.type === "session_end") {
10428
+ endedAt = e.ts;
10429
+ } else if (e.type === "user_input") {
10430
+ if (title === "(empty session)") title = userInputTitle(e.content);
10431
+ } else if (e.type === "llm_response") {
10432
+ tokenIn += e.usage.input ?? 0;
10433
+ tokenOut += e.usage.output ?? 0;
10434
+ } else if (e.type === "in_flight_start") iterationCount++;
10435
+ else if (e.type === "tool_call_start") {
10436
+ toolCallCount++;
10437
+ toolBreakdown[e.name] = (toolBreakdown[e.name] ?? 0) + 1;
10438
+ } else if (e.type === "tool_result" && e.isError) toolErrorCount++;
10439
+ else if (e.type === "file_snapshot") fileChangeCount += e.files.length;
10440
+ else if (e.type === "error" || e.type === "provider_error") hasError = true;
10441
+ }
10442
+ if (lastEventType === "session_end") {
10443
+ outcome = "completed";
10444
+ } else if (lastEventType === "in_flight_start") {
10445
+ outcome = "aborted";
10446
+ } else if (hasError) {
10447
+ outcome = "error";
10448
+ }
10449
+ return {
10450
+ id,
10451
+ title,
10452
+ startedAt,
10453
+ endedAt,
10454
+ model,
10455
+ provider,
10456
+ tokenTotal: tokenIn + tokenOut,
10457
+ iterationCount: iterationCount > 0 ? iterationCount : void 0,
10458
+ toolCallCount: toolCallCount > 0 ? toolCallCount : void 0,
10459
+ toolErrorCount: toolErrorCount > 0 ? toolErrorCount : void 0,
10460
+ fileChangeCount: fileChangeCount > 0 ? fileChangeCount : void 0,
10461
+ toolBreakdown: Object.keys(toolBreakdown).length > 0 ? toolBreakdown : {},
10462
+ outcome
10463
+ };
10464
+ } catch {
10465
+ return {
10466
+ id,
10467
+ title: "(damaged)",
10468
+ startedAt: mtime,
10469
+ model: "unknown",
10470
+ provider: "unknown",
10471
+ tokenTotal: 0
10472
+ };
10278
10473
  }
10279
- await this.append({
10280
- type: "in_flight_start",
10281
- ts: (/* @__PURE__ */ new Date()).toISOString(),
10282
- context
10283
- });
10284
- this.events?.emit("in_flight.started", { context, ts: (/* @__PURE__ */ new Date()).toISOString() });
10285
10474
  }
10286
- /**
10287
- * Idea #1 — close the in-flight marker. Idempotent in spirit
10288
- * (you can call it after a successful iteration even if you
10289
- * didn't open one this round) — but the session log records
10290
- * every call so postmortem tooling can see "the agent finished
10291
- * cleanly X times, then died without finishing Y".
10292
- */
10293
- async clearInFlightMarker(reason) {
10294
- await this.append({
10295
- type: "in_flight_end",
10296
- ts: (/* @__PURE__ */ new Date()).toISOString(),
10297
- reason
10298
- });
10299
- this.events?.emit("in_flight.ended", { reason, ts: (/* @__PURE__ */ new Date()).toISOString() });
10475
+ async *iterSessionEvents(file) {
10476
+ const stream = createReadStream(file, { encoding: "utf8" });
10477
+ const lines = createInterface({ input: stream, crlfDelay: Infinity });
10478
+ try {
10479
+ for await (const line of lines) {
10480
+ if (!line.trim()) continue;
10481
+ try {
10482
+ const parsed = JSON.parse(line);
10483
+ if (parsed !== null && typeof parsed === "object" && typeof parsed.type === "string" && typeof parsed.ts === "string") {
10484
+ yield parsed;
10485
+ }
10486
+ } catch {
10487
+ }
10488
+ }
10489
+ } finally {
10490
+ lines.close();
10491
+ stream.destroy();
10492
+ }
10300
10493
  }
10301
10494
  };
10302
- function userInputTitle(content) {
10303
- const text = typeof content === "string" ? content : content.filter((b) => b.type === "text").map((b) => b.text).join(" ");
10304
- return (text || "(non-text input)").slice(0, 60);
10495
+ function extractToolCallEnds(events) {
10496
+ const result = [];
10497
+ for (const e of events) {
10498
+ if (e.type === "tool_call_end") {
10499
+ result.push({
10500
+ name: e.name,
10501
+ id: e.id,
10502
+ durationMs: e.durationMs,
10503
+ ok: e.ok ?? false,
10504
+ outputBytes: e.outputBytes,
10505
+ outputTokens: e.outputTokens,
10506
+ outputLines: e.outputLines
10507
+ });
10508
+ }
10509
+ }
10510
+ return result;
10305
10511
  }
10306
10512
  function compareSessionSummaries(a, b) {
10307
10513
  if (a.startedAt < b.startedAt) return 1;
10308
10514
  if (a.startedAt > b.startedAt) return -1;
10309
10515
  return a.id.localeCompare(b.id);
10310
10516
  }
10517
+ function matchesSessionFilter(s, criteria) {
10518
+ if (criteria.since && s.startedAt < criteria.since) return false;
10519
+ if (criteria.until && s.startedAt > criteria.until) return false;
10520
+ if (criteria.provider && s.provider !== criteria.provider) return false;
10521
+ if (criteria.model && s.model !== criteria.model) return false;
10522
+ if (criteria.minTokens !== void 0 && s.tokenTotal < criteria.minTokens) return false;
10523
+ if (criteria.titleContains) {
10524
+ const needle = criteria.titleContains.toLowerCase();
10525
+ if (!s.title.toLowerCase().includes(needle)) return false;
10526
+ }
10527
+ return true;
10528
+ }
10311
10529
  async function mapWithConcurrency(items, concurrency, fn) {
10312
10530
  if (items.length === 0) return [];
10313
10531
  const out = new Array(items.length);
@@ -10333,9 +10551,9 @@ function makeDirectorSessionFactory(opts) {
10333
10551
  let dir;
10334
10552
  if (opts.store) {
10335
10553
  store = opts.store;
10336
- dir = opts.sessionsRoot ? path5.join(opts.sessionsRoot, runId) : "(caller-managed)";
10554
+ dir = opts.sessionsRoot ? path6.join(opts.sessionsRoot, runId) : "(caller-managed)";
10337
10555
  } else if (opts.sessionsRoot) {
10338
- dir = path5.join(opts.sessionsRoot, runId);
10556
+ dir = path6.join(opts.sessionsRoot, runId);
10339
10557
  store = new DefaultSessionStore({ dir });
10340
10558
  } else {
10341
10559
  throw new Error("makeDirectorSessionFactory requires either `store` or `sessionsRoot`");
@@ -10647,7 +10865,7 @@ var FleetManager = class {
10647
10865
  })),
10648
10866
  usage: this.usage.snapshot()
10649
10867
  };
10650
- await fsp6.mkdir(path5.dirname(this.manifestPath), { recursive: true });
10868
+ await fsp7.mkdir(path6.dirname(this.manifestPath), { recursive: true });
10651
10869
  await atomicWrite(this.manifestPath, JSON.stringify(manifest, null, 2), { mode: 384 });
10652
10870
  return this.manifestPath;
10653
10871
  }
@@ -10802,12 +11020,18 @@ var DefaultMailbox = class {
10802
11020
  _byTo = /* @__PURE__ */ new Map();
10803
11021
  /** Secondary index: sender → Set of messages (points into _messageCache). */
10804
11022
  _byFrom = /* @__PURE__ */ new Map();
11023
+ /** Counts malformed JSONL lines skipped during parsing for observability. */
11024
+ _corruptionCount = 0;
10805
11025
  constructor(sessionDir) {
10806
- this.filePath = path5.join(sessionDir, MAILBOX_FILE);
11026
+ this.filePath = path6.join(sessionDir, MAILBOX_FILE);
10807
11027
  }
10808
11028
  get mailboxPath() {
10809
11029
  return this.filePath;
10810
11030
  }
11031
+ /** Returns the count of malformed JSONL lines encountered during reads. */
11032
+ get corruptionCount() {
11033
+ return this._corruptionCount;
11034
+ }
10811
11035
  // ── Send ──────────────────────────────────────────────────────────────
10812
11036
  async send(input) {
10813
11037
  const now = (/* @__PURE__ */ new Date()).toISOString();
@@ -10828,10 +11052,13 @@ var DefaultMailbox = class {
10828
11052
  taskContext: input.taskContext
10829
11053
  };
10830
11054
  const line = JSON.stringify(msg) + LINE_SEPARATOR;
10831
- await fsp6.mkdir(path5.dirname(this.filePath), { recursive: true });
11055
+ await fsp7.mkdir(path6.dirname(this.filePath), { recursive: true });
10832
11056
  await withFileLock(this.filePath, async () => {
10833
- await fsp6.appendFile(this.filePath, line, "utf8");
11057
+ await fsp7.appendFile(this.filePath, line, "utf8");
10834
11058
  this._pushToCache(msg);
11059
+ const { mtime, size } = await this._statUnderLockOrAbsent();
11060
+ this._messageCacheMtime = mtime;
11061
+ this._messageCacheSize = size;
10835
11062
  });
10836
11063
  return msg;
10837
11064
  }
@@ -10908,9 +11135,10 @@ var DefaultMailbox = class {
10908
11135
  }
10909
11136
  if (changed) {
10910
11137
  const serialized = all.map((m) => JSON.stringify(m)).join(LINE_SEPARATOR) + LINE_SEPARATOR;
10911
- await fsp6.writeFile(this.filePath, serialized, "utf8");
11138
+ await fsp7.writeFile(this.filePath, serialized, "utf8");
10912
11139
  }
10913
- this._setMessageCache(all);
11140
+ const { mtime, size } = await this._statUnderLockOrAbsent();
11141
+ this._setMessageCache(all, mtime, size);
10914
11142
  });
10915
11143
  return updated;
10916
11144
  }
@@ -10966,8 +11194,9 @@ var DefaultMailbox = class {
10966
11194
  }
10967
11195
  async clearAll() {
10968
11196
  await withFileLock(this.filePath, async () => {
10969
- await fsp6.writeFile(this.filePath, "", "utf8");
10970
- this._setMessageCache([]);
11197
+ await fsp7.writeFile(this.filePath, "", "utf8");
11198
+ const { mtime, size } = await this._statUnderLockOrAbsent();
11199
+ this._setMessageCache([], mtime, size);
10971
11200
  });
10972
11201
  }
10973
11202
  async purgeStale(opts) {
@@ -10998,9 +11227,10 @@ var DefaultMailbox = class {
10998
11227
  remaining = kept.length;
10999
11228
  if (kept.length < all.length) {
11000
11229
  const content = kept.map((m) => JSON.stringify(m)).join(LINE_SEPARATOR) + LINE_SEPARATOR;
11001
- await fsp6.writeFile(this.filePath, content, "utf8");
11230
+ await fsp7.writeFile(this.filePath, content, "utf8");
11002
11231
  }
11003
- this._setMessageCache(kept);
11232
+ const { mtime, size } = await this._statUnderLockOrAbsent();
11233
+ this._setMessageCache(kept, mtime, size);
11004
11234
  });
11005
11235
  return {
11006
11236
  completedPurged,
@@ -11020,7 +11250,7 @@ var DefaultMailbox = class {
11020
11250
  // ── Internal ──────────────────────────────────────────────────────────
11021
11251
  async _readAll() {
11022
11252
  try {
11023
- const raw = await fsp6.readFile(this.filePath, "utf8");
11253
+ const raw = await fsp7.readFile(this.filePath, "utf8");
11024
11254
  return this._parseLines(raw);
11025
11255
  } catch (err) {
11026
11256
  if (err.code === "ENOENT") return [];
@@ -11052,7 +11282,9 @@ var DefaultMailbox = class {
11052
11282
  const msg = parsed;
11053
11283
  this._messageCache.push(msg);
11054
11284
  this._indexMsg(msg);
11055
- } catch {
11285
+ } catch (err) {
11286
+ this._corruptionCount++;
11287
+ console.debug(`[mailbox] skipped malformed line during incremental read: ${err.message}`);
11056
11288
  }
11057
11289
  }
11058
11290
  return this._messageCache;
@@ -11074,19 +11306,38 @@ var DefaultMailbox = class {
11074
11306
  delete parsed["readAt"];
11075
11307
  }
11076
11308
  messages.push(parsed);
11077
- } catch {
11309
+ } catch (err) {
11310
+ this._corruptionCount++;
11311
+ console.debug(`[mailbox] skipped malformed line during full parse: ${err.message}`);
11078
11312
  }
11079
11313
  }
11080
11314
  return messages;
11081
11315
  }
11316
+ /**
11317
+ * Stat the mailbox file under the assumption that we are holding the
11318
+ * file lock, and that a write to the file has just completed. Returns
11319
+ * the (mtimeMs, size) pair, or (-1, -1) if the file does not exist
11320
+ * (e.g. ackMany/purgeStale on a session that has never sent a message).
11321
+ */
11322
+ async _statUnderLockOrAbsent() {
11323
+ try {
11324
+ const st = await fsp7.stat(this.filePath);
11325
+ return { mtime: st.mtimeMs, size: st.size };
11326
+ } catch (err) {
11327
+ if (err.code === "ENOENT") {
11328
+ return { mtime: -1, size: -1 };
11329
+ }
11330
+ throw err;
11331
+ }
11332
+ }
11082
11333
  async _readAllCached() {
11083
11334
  try {
11084
- const st = await fsp6.stat(this.filePath);
11335
+ const st = await fsp7.stat(this.filePath);
11085
11336
  if (this._messageCache !== null && this._messageCacheMtime === st.mtimeMs && this._messageCacheSize === st.size) {
11086
11337
  return this._messageCache;
11087
11338
  }
11088
11339
  if (this._messageCache !== null && this._messageCacheSize >= 0 && st.size > this._messageCacheSize) {
11089
- const fd = await fsp6.open(this.filePath, "r");
11340
+ const fd = await fsp7.open(this.filePath, "r");
11090
11341
  try {
11091
11342
  const updated = await this._readNewMessagesOnly(fd, this._messageCacheSize, st.size);
11092
11343
  this._messageCacheMtime = st.mtimeMs;
@@ -11118,16 +11369,8 @@ var DefaultMailbox = class {
11118
11369
  }
11119
11370
  this._messageCache = messages;
11120
11371
  this._buildIndexes(messages);
11121
- if (mtime !== void 0 && size !== void 0) {
11122
- this._messageCacheMtime = mtime;
11123
- this._messageCacheSize = size;
11124
- return;
11125
- }
11126
- void fsp6.stat(this.filePath).then((st) => {
11127
- this._messageCacheMtime = st.mtimeMs;
11128
- this._messageCacheSize = st.size;
11129
- }).catch(() => {
11130
- });
11372
+ this._messageCacheMtime = mtime;
11373
+ this._messageCacheSize = size;
11131
11374
  }
11132
11375
  _pushToCache(msg) {
11133
11376
  if (this._messageCache === null) return;
@@ -11307,7 +11550,7 @@ var REGISTRY_CACHE_TTL_MS = 2e3;
11307
11550
  var LINE_SEPARATOR2 = "\n";
11308
11551
  var MESSAGE_CACHE_MAX_ENTRIES2 = 1e4;
11309
11552
  function resolveProjectDir(projectRoot, globalRoot) {
11310
- return path5.join(globalRoot, "projects", projectSlug(projectRoot));
11553
+ return path6.join(globalRoot, "projects", projectSlug(projectRoot));
11311
11554
  }
11312
11555
  var GlobalMailbox = class {
11313
11556
  /** Path to the JSONL message file. */
@@ -11360,14 +11603,14 @@ var GlobalMailbox = class {
11360
11603
  * @param hqPublisher — optional HQ publisher, or getter, for cross-project telemetry
11361
11604
  */
11362
11605
  constructor(projectDir, events, hqPublisher) {
11363
- this.messagePath = path5.join(projectDir, MAILBOX_FILE2);
11364
- this.registryPath = path5.join(projectDir, "_mailbox.registry.json");
11365
- this.clientRegistryPath = path5.join(projectDir, CLIENT_REGISTRY_FILE);
11606
+ this.messagePath = path6.join(projectDir, MAILBOX_FILE2);
11607
+ this.registryPath = path6.join(projectDir, "_mailbox.registry.json");
11608
+ this.clientRegistryPath = path6.join(projectDir, CLIENT_REGISTRY_FILE);
11366
11609
  this._events = events;
11367
11610
  this._hqPublisher = hqPublisher;
11368
11611
  }
11369
11612
  get hqMailboxId() {
11370
- return `${path5.basename(path5.dirname(this.messagePath))}:mailbox`;
11613
+ return `${path6.basename(path6.dirname(this.messagePath))}:mailbox`;
11371
11614
  }
11372
11615
  get hqPublisher() {
11373
11616
  return typeof this._hqPublisher === "function" ? this._hqPublisher() : this._hqPublisher;
@@ -11404,9 +11647,9 @@ var GlobalMailbox = class {
11404
11647
  taskContext: input.taskContext
11405
11648
  };
11406
11649
  const line = JSON.stringify(msg) + LINE_SEPARATOR2;
11407
- await fsp6.mkdir(path5.dirname(this.messagePath), { recursive: true });
11650
+ await fsp7.mkdir(path6.dirname(this.messagePath), { recursive: true });
11408
11651
  await withFileLock(this.messagePath, async () => {
11409
- await fsp6.appendFile(this.messagePath, line, "utf8");
11652
+ await fsp7.appendFile(this.messagePath, line, "utf8");
11410
11653
  this._pushToCache(msg);
11411
11654
  });
11412
11655
  this.publishHqMailboxEvent({ mailboxId: this.hqMailboxId, action: "message.sent", message: msg });
@@ -11472,7 +11715,7 @@ var GlobalMailbox = class {
11472
11715
  }
11473
11716
  if (changed) {
11474
11717
  const serialized = all.map((m) => JSON.stringify(m)).join(LINE_SEPARATOR2) + LINE_SEPARATOR2;
11475
- await fsp6.writeFile(this.messagePath, serialized, "utf8");
11718
+ await fsp7.writeFile(this.messagePath, serialized, "utf8");
11476
11719
  }
11477
11720
  cacheSnapshot = all;
11478
11721
  });
@@ -11690,7 +11933,7 @@ var GlobalMailbox = class {
11690
11933
  }
11691
11934
  async clearAll() {
11692
11935
  await withFileLock(this.messagePath, async () => {
11693
- await fsp6.writeFile(this.messagePath, "", "utf8");
11936
+ await fsp7.writeFile(this.messagePath, "", "utf8");
11694
11937
  });
11695
11938
  this._setMessageCache([]);
11696
11939
  }
@@ -11722,7 +11965,7 @@ var GlobalMailbox = class {
11722
11965
  remaining = kept.length;
11723
11966
  if (kept.length < all.length) {
11724
11967
  const content = kept.map((m) => JSON.stringify(m)).join(LINE_SEPARATOR2) + LINE_SEPARATOR2;
11725
- await fsp6.writeFile(this.messagePath, content, "utf8");
11968
+ await fsp7.writeFile(this.messagePath, content, "utf8");
11726
11969
  }
11727
11970
  this._setMessageCache(kept);
11728
11971
  });
@@ -11742,7 +11985,7 @@ var GlobalMailbox = class {
11742
11985
  */
11743
11986
  async _readMessages() {
11744
11987
  try {
11745
- const raw = await fsp6.readFile(this.messagePath, "utf8");
11988
+ const raw = await fsp7.readFile(this.messagePath, "utf8");
11746
11989
  return this._parseLines(raw);
11747
11990
  } catch (err) {
11748
11991
  if (err.code === "ENOENT") return [];
@@ -11832,12 +12075,12 @@ var GlobalMailbox = class {
11832
12075
  */
11833
12076
  async _readMessagesCached() {
11834
12077
  try {
11835
- const st = await fsp6.stat(this.messagePath);
12078
+ const st = await fsp7.stat(this.messagePath);
11836
12079
  if (this._messageCache !== null && this._messageCacheMtime === st.mtimeMs && this._messageCacheSize === st.size) {
11837
12080
  return this._messageCache;
11838
12081
  }
11839
12082
  if (this._messageCache !== null && this._messageCacheSize >= 0 && st.size > this._messageCacheSize) {
11840
- const fd = await fsp6.open(this.messagePath, "r");
12083
+ const fd = await fsp7.open(this.messagePath, "r");
11841
12084
  try {
11842
12085
  const updated = await this._readNewMessagesOnly(fd, this._messageCacheSize, st.size);
11843
12086
  this._messageCacheMtime = st.mtimeMs;
@@ -11875,7 +12118,7 @@ var GlobalMailbox = class {
11875
12118
  this._messageCacheMtime = mtime;
11876
12119
  this._messageCacheSize = size;
11877
12120
  } else {
11878
- void fsp6.stat(this.messagePath).then((st) => {
12121
+ void fsp7.stat(this.messagePath).then((st) => {
11879
12122
  this._messageCacheMtime = st.mtimeMs;
11880
12123
  this._messageCacheSize = st.size;
11881
12124
  }).catch(() => {
@@ -11899,14 +12142,14 @@ var GlobalMailbox = class {
11899
12142
  this._messageCache.push(msg);
11900
12143
  }
11901
12144
  async _ensureRegistry() {
11902
- await fsp6.mkdir(path5.dirname(this.registryPath), { recursive: true });
12145
+ await fsp7.mkdir(path6.dirname(this.registryPath), { recursive: true });
11903
12146
  }
11904
12147
  async _readRegistry(opts) {
11905
12148
  if (!opts?.fresh && this._registryCache && Date.now() - this._registryCacheAt < REGISTRY_CACHE_TTL_MS) {
11906
12149
  return new Map(this._registryCache);
11907
12150
  }
11908
12151
  try {
11909
- const raw = await fsp6.readFile(this.registryPath, "utf8");
12152
+ const raw = await fsp7.readFile(this.registryPath, "utf8");
11910
12153
  const data = JSON.parse(raw);
11911
12154
  const map = /* @__PURE__ */ new Map();
11912
12155
  for (const [id, agent] of Object.entries(data)) {
@@ -11939,19 +12182,19 @@ var GlobalMailbox = class {
11939
12182
  obj[id] = agent;
11940
12183
  }
11941
12184
  const tmp = `${this.registryPath}.${randomUUID().slice(0, 8)}.tmp`;
11942
- await fsp6.writeFile(tmp, JSON.stringify(obj), "utf8");
11943
- await fsp6.rename(tmp, this.registryPath);
12185
+ await fsp7.writeFile(tmp, JSON.stringify(obj), "utf8");
12186
+ await fsp7.rename(tmp, this.registryPath);
11944
12187
  }
11945
12188
  // ── Client registry internals ───────────────────────────────────────────
11946
12189
  async _ensureClientRegistry() {
11947
- await fsp6.mkdir(path5.dirname(this.clientRegistryPath), { recursive: true });
12190
+ await fsp7.mkdir(path6.dirname(this.clientRegistryPath), { recursive: true });
11948
12191
  }
11949
12192
  async _readClientRegistry(opts) {
11950
12193
  if (!opts?.fresh && this._clientRegistryCache && Date.now() - this._clientRegistryCacheAt < REGISTRY_CACHE_TTL_MS) {
11951
12194
  return new Map(this._clientRegistryCache);
11952
12195
  }
11953
12196
  try {
11954
- const raw = await fsp6.readFile(this.clientRegistryPath, "utf8");
12197
+ const raw = await fsp7.readFile(this.clientRegistryPath, "utf8");
11955
12198
  const data = JSON.parse(raw);
11956
12199
  const map = /* @__PURE__ */ new Map();
11957
12200
  for (const [id, client] of Object.entries(data)) {
@@ -11984,8 +12227,8 @@ var GlobalMailbox = class {
11984
12227
  obj[id] = client;
11985
12228
  }
11986
12229
  const tmp = `${this.clientRegistryPath}.${randomUUID().slice(0, 8)}.tmp`;
11987
- await fsp6.writeFile(tmp, JSON.stringify(obj), "utf8");
11988
- await fsp6.rename(tmp, this.clientRegistryPath);
12230
+ await fsp7.writeFile(tmp, JSON.stringify(obj), "utf8");
12231
+ await fsp7.rename(tmp, this.clientRegistryPath);
11989
12232
  }
11990
12233
  };
11991
12234
  function defaultResolveProjectDir(ctx) {
@@ -12458,13 +12701,13 @@ function makeDependencyWatcherConfig(opts) {
12458
12701
  const globPatterns = patterns.filter((p) => p.includes("*"));
12459
12702
  const plainPatterns = patterns.filter((p) => !p.includes("*"));
12460
12703
  function matchesPattern(filePath) {
12461
- const basename6 = filePath.split("/").pop()?.split("\\").pop() ?? "";
12462
- if (plainPatterns.includes(basename6)) return true;
12704
+ const basename7 = filePath.split("/").pop()?.split("\\").pop() ?? "";
12705
+ if (plainPatterns.includes(basename7)) return true;
12463
12706
  for (const gp of globPatterns) {
12464
12707
  const regex = new RegExp(
12465
12708
  "^" + gp.replace(/\./g, "\\.").replace(/\*/g, ".*") + "$"
12466
12709
  );
12467
- if (regex.test(basename6)) return true;
12710
+ if (regex.test(basename7)) return true;
12468
12711
  }
12469
12712
  return false;
12470
12713
  }
@@ -12591,11 +12834,11 @@ function createMailboxHooks(opts) {
12591
12834
  var DEFAULT_MAX_ENTRIES = 1e4;
12592
12835
  var LOG_FILENAME = "package-authors.json";
12593
12836
  function logPath(storageDir) {
12594
- return path5.join(storageDir, LOG_FILENAME);
12837
+ return path6.join(storageDir, LOG_FILENAME);
12595
12838
  }
12596
12839
  async function loadLog(storageDir, projectRoot) {
12597
12840
  try {
12598
- const raw = await fsp6.readFile(logPath(storageDir), "utf-8");
12841
+ const raw = await fsp7.readFile(logPath(storageDir), "utf-8");
12599
12842
  const parsed = JSON.parse(raw);
12600
12843
  if (!parsed.entries || !Array.isArray(parsed.entries)) {
12601
12844
  return { projectRoot, entries: [] };
@@ -12609,13 +12852,13 @@ async function loadLog(storageDir, projectRoot) {
12609
12852
  }
12610
12853
  }
12611
12854
  async function saveLog(storageDir, log) {
12612
- await fsp6.mkdir(storageDir, { recursive: true });
12855
+ await fsp7.mkdir(storageDir, { recursive: true });
12613
12856
  const tmp = `${logPath(storageDir)}.tmp.${Date.now()}`;
12614
- await fsp6.writeFile(tmp, JSON.stringify(log, null, 2) + "\n", "utf-8");
12615
- await fsp6.rename(tmp, logPath(storageDir));
12857
+ await fsp7.writeFile(tmp, JSON.stringify(log, null, 2) + "\n", "utf-8");
12858
+ await fsp7.rename(tmp, logPath(storageDir));
12616
12859
  }
12617
12860
  function detectEcosystem(manifestPath) {
12618
- const name = path5.basename(manifestPath).toLowerCase();
12861
+ const name = path6.basename(manifestPath).toLowerCase();
12619
12862
  if (name === "package.json") return "npm";
12620
12863
  if (name === "go.mod") return "go";
12621
12864
  if (name === "cargo.toml") return "cargo";
@@ -12898,8 +13141,8 @@ var KnowledgeGraph = class {
12898
13141
  return this.index;
12899
13142
  }
12900
13143
  constructor(sessionDir) {
12901
- this.filePath = path5.join(sessionDir, "_knowledge_graph");
12902
- this.graphFilePath = path5.join(this.filePath, "graph.jsonl");
13144
+ this.filePath = path6.join(sessionDir, "_knowledge_graph");
13145
+ this.graphFilePath = path6.join(this.filePath, "graph.jsonl");
12903
13146
  }
12904
13147
  // ── Write ──────────────────────────────────────────────────────────────
12905
13148
  /**
@@ -13080,23 +13323,23 @@ var KnowledgeGraph = class {
13080
13323
  }
13081
13324
  }
13082
13325
  async _persist(node) {
13083
- await fsp6.mkdir(this.filePath, { recursive: true });
13326
+ await fsp7.mkdir(this.filePath, { recursive: true });
13084
13327
  const line = JSON.stringify(node) + "\n";
13085
13328
  await withFileLock(this.graphFilePath, async () => {
13086
- await fsp6.appendFile(this.graphFilePath, line, "utf8");
13329
+ await fsp7.appendFile(this.graphFilePath, line, "utf8");
13087
13330
  });
13088
13331
  }
13089
13332
  async _append(node) {
13090
- await fsp6.mkdir(this.filePath, { recursive: true });
13333
+ await fsp7.mkdir(this.filePath, { recursive: true });
13091
13334
  const line = JSON.stringify({ op: "update", node }) + "\n";
13092
13335
  await withFileLock(this.graphFilePath, async () => {
13093
- await fsp6.appendFile(this.graphFilePath, line, "utf8");
13336
+ await fsp7.appendFile(this.graphFilePath, line, "utf8");
13094
13337
  });
13095
13338
  }
13096
13339
  /** Rebuild in-memory state from the log file. Call on startup. */
13097
13340
  async load() {
13098
13341
  try {
13099
- const content = await fsp6.readFile(this.graphFilePath, "utf8");
13342
+ const content = await fsp7.readFile(this.graphFilePath, "utf8");
13100
13343
  const lines = content.split("\n").filter(Boolean);
13101
13344
  for (const line of lines) {
13102
13345
  try {
@@ -15527,11 +15770,11 @@ var AgentMonitorService = class {
15527
15770
  this._onEntry?.(entry);
15528
15771
  }
15529
15772
  async _appendToFile(subagentId, entry) {
15530
- const dir = path5.join(this._transcriptsDir, subagentId);
15531
- await fsp6.mkdir(dir, { recursive: true });
15532
- const filePath = path5.join(dir, "transcript.jsonl");
15773
+ const dir = path6.join(this._transcriptsDir, subagentId);
15774
+ await fsp7.mkdir(dir, { recursive: true });
15775
+ const filePath = path6.join(dir, "transcript.jsonl");
15533
15776
  const line = JSON.stringify(entry) + "\n";
15534
- await fsp6.appendFile(filePath, line, { encoding: "utf8" });
15777
+ await fsp7.appendFile(filePath, line, { encoding: "utf8" });
15535
15778
  }
15536
15779
  _uid() {
15537
15780
  return `${Date.now().toString(36)}${Math.random().toString(36).slice(2, 8)}`;