@wrongstack/core 0.273.1 → 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 (63) hide show
  1. package/dist/{agent-bridge-DpKIxHhE.d.ts → agent-bridge-DFo21wmY.d.ts} +1 -1
  2. package/dist/{agent-subagent-runner-Dx7fZ1bE.d.ts → agent-subagent-runner-BwmkIDEd.d.ts} +7 -7
  3. package/dist/{brain-BDcQaku-.d.ts → brain-gfZX3the.d.ts} +1 -1
  4. package/dist/{provider-model-resolve-Cz6OlIOp.d.ts → codex-catalog-CooZ6mOl.d.ts} +47 -4
  5. package/dist/{compactor-BuSdj3fq.d.ts → compactor-riTOds0f.d.ts} +1 -1
  6. package/dist/{config-CR2yoG8c.d.ts → config-BxcrDzri.d.ts} +7 -1
  7. package/dist/{context-DulAr8Zo.d.ts → context-DERiLofu.d.ts} +36 -1
  8. package/dist/coordination/index.d.ts +20 -16
  9. package/dist/coordination/index.js +1622 -1479
  10. package/dist/coordination/index.js.map +1 -1
  11. package/dist/defaults/index.d.ts +26 -26
  12. package/dist/defaults/index.js +1832 -1541
  13. package/dist/defaults/index.js.map +1 -1
  14. package/dist/execution/index.d.ts +15 -15
  15. package/dist/execution/index.js +2 -8
  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-CwcubDkA.d.ts → global-mailbox-Cr8TW4t_.d.ts} +1 -1
  20. package/dist/{goal-preamble-Bu0a2uCG.d.ts → goal-preamble-CEhROp1e.d.ts} +9 -9
  21. package/dist/{goal-store-CTmFuZ8J.d.ts → goal-store-xNSVxmpV.d.ts} +1 -1
  22. package/dist/hq/index.d.ts +5 -5
  23. package/dist/{index-CTq5wU3m.d.ts → index-00KPKAlm.d.ts} +5 -5
  24. package/dist/{index-CxP-HBhX.d.ts → index-pDBSBE1r.d.ts} +2 -2
  25. package/dist/index.d.ts +49 -43
  26. package/dist/index.js +1973 -1444
  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/{mcp-servers-BQaOE71z.d.ts → mcp-servers-BIwRiOxe.d.ts} +3 -3
  31. package/dist/models/index.d.ts +5 -5
  32. package/dist/models/index.js +40 -2
  33. package/dist/models/index.js.map +1 -1
  34. package/dist/{models-registry-BEcny4kP.d.ts → models-registry-8OorW51H.d.ts} +1 -1
  35. package/dist/{multi-agent-coordinator-Bx8EFkv2.d.ts → multi-agent-coordinator-CNx48Zoz.d.ts} +1 -1
  36. package/dist/{null-fleet-bus-BC5ZXCQw.d.ts → null-fleet-bus-B4ZUJYL6.d.ts} +6 -6
  37. package/dist/observability/index.d.ts +2 -2
  38. package/dist/{parallel-eternal-engine-C345TI3n.d.ts → parallel-eternal-engine-rsIclDqO.d.ts} +9 -9
  39. package/dist/{path-resolver-C-W_wzkF.d.ts → path-resolver-BqU-fwzD.d.ts} +3 -3
  40. package/dist/{permission-CsBGZkxp.d.ts → permission-Dgs3v-Xq.d.ts} +1 -1
  41. package/dist/{permission-policy-g3Sg0GdZ.d.ts → permission-policy-Dtht2k0e.d.ts} +2 -2
  42. package/dist/{pipeline-xnw_24Z8.d.ts → pipeline-B-dpCFYS.d.ts} +2 -2
  43. package/dist/{plan-templates-DGaiYEcS.d.ts → plan-templates-B7MxyY2o.d.ts} +32 -5
  44. package/dist/{provider-runner-7J0HqF6B.d.ts → provider-runner-DrmpBE5l.d.ts} +3 -3
  45. package/dist/{retry-policy-kqXJOVkX.d.ts → retry-policy-hYxsm10a.d.ts} +1 -1
  46. package/dist/sdd/index.d.ts +12 -10
  47. package/dist/sdd/index.js +7 -0
  48. package/dist/sdd/index.js.map +1 -1
  49. package/dist/{secret-vault-CMQUr-eB.d.ts → secret-vault-DnqIFhPF.d.ts} +1 -1
  50. package/dist/security/index.d.ts +5 -5
  51. package/dist/{selector-B4r34PWR.d.ts → selector-D21RjDIg.d.ts} +1 -1
  52. package/dist/{session-event-bridge-BD3LoyLC.d.ts → session-event-bridge-Dt54CTvq.d.ts} +1 -1
  53. package/dist/{session-reader-DjrKGD9c.d.ts → session-reader-CceH13Kq.d.ts} +5 -4
  54. package/dist/storage/index.d.ts +46 -12
  55. package/dist/storage/index.js +1987 -1548
  56. package/dist/storage/index.js.map +1 -1
  57. package/dist/tools/index.d.ts +2 -2
  58. package/dist/types/index.d.ts +19 -19
  59. package/dist/types/index.js +134 -42
  60. package/dist/types/index.js.map +1 -1
  61. package/dist/utils/index.d.ts +2 -2
  62. package/dist/{worktree-manager-DHdrWQ_7.d.ts → worktree-manager-DUfBbKzk.d.ts} +1 -1
  63. package/package.json +1 -1
@@ -1,6 +1,6 @@
1
1
  import * as crypto2 from 'crypto';
2
2
  import { randomBytes, createCipheriv, createDecipheriv, randomUUID, scryptSync, createHash } from 'crypto';
3
- import * as fsp2 from 'fs/promises';
3
+ import * as fsp3 from 'fs/promises';
4
4
  import { readFile, writeFile, mkdir } from 'fs/promises';
5
5
  import * as path4 from 'path';
6
6
  import { isAbsolute, join, resolve, sep } from 'path';
@@ -37,16 +37,16 @@ __export(atomic_write_exports, {
37
37
  });
38
38
  async function atomicWrite(targetPath, content, opts = {}) {
39
39
  const dir = path4.dirname(targetPath);
40
- await fsp2.mkdir(dir, { recursive: true });
40
+ await fsp3.mkdir(dir, { recursive: true });
41
41
  const tmp = path4.join(dir, `.${path4.basename(targetPath)}.${randomBytes(6).toString("hex")}.tmp`);
42
42
  try {
43
43
  if (typeof content === "string") {
44
- await fsp2.writeFile(tmp, content, { flag: "wx", encoding: opts.encoding ?? "utf8" });
44
+ await fsp3.writeFile(tmp, content, { flag: "wx", encoding: opts.encoding ?? "utf8" });
45
45
  } else {
46
- await fsp2.writeFile(tmp, content, { flag: "wx" });
46
+ await fsp3.writeFile(tmp, content, { flag: "wx" });
47
47
  }
48
48
  try {
49
- const fh = await fsp2.open(tmp, "r+");
49
+ const fh = await fsp3.open(tmp, "r+");
50
50
  try {
51
51
  await fh.sync();
52
52
  } finally {
@@ -56,29 +56,29 @@ async function atomicWrite(targetPath, content, opts = {}) {
56
56
  }
57
57
  let mode;
58
58
  try {
59
- const stat6 = await fsp2.stat(targetPath);
60
- mode = stat6.mode & 511;
59
+ const stat8 = await fsp3.stat(targetPath);
60
+ mode = stat8.mode & 511;
61
61
  } catch {
62
62
  mode = opts.mode;
63
63
  }
64
64
  if (mode !== void 0) {
65
- await fsp2.chmod(tmp, mode);
65
+ await fsp3.chmod(tmp, mode);
66
66
  }
67
67
  await renameWithRetry(tmp, targetPath);
68
68
  } catch (err) {
69
69
  try {
70
- await fsp2.unlink(tmp);
70
+ await fsp3.unlink(tmp);
71
71
  } catch {
72
72
  }
73
73
  throw err;
74
74
  }
75
75
  }
76
76
  async function ensureDir(dir) {
77
- await fsp2.mkdir(dir, { recursive: true });
77
+ await fsp3.mkdir(dir, { recursive: true });
78
78
  }
79
79
  async function withFileLock(targetPath, fn, opts = {}) {
80
80
  const dir = path4.dirname(targetPath);
81
- await fsp2.mkdir(dir, { recursive: true });
81
+ await fsp3.mkdir(dir, { recursive: true });
82
82
  const lockPath = path4.join(dir, `.${path4.basename(targetPath)}.lock`);
83
83
  const timeoutMs = opts.timeoutMs ?? 5e3;
84
84
  const staleMs = opts.staleMs ?? 3e4;
@@ -86,20 +86,20 @@ async function withFileLock(targetPath, fn, opts = {}) {
86
86
  let handle;
87
87
  for (; ; ) {
88
88
  try {
89
- handle = await fsp2.open(lockPath, "wx");
89
+ handle = await fsp3.open(lockPath, "wx");
90
90
  await handle.writeFile(`${process.pid}:${Date.now()}`);
91
91
  break;
92
92
  } catch (err) {
93
93
  const code = err.code;
94
94
  if (code === "ENOENT") {
95
- await fsp2.mkdir(dir, { recursive: true });
95
+ await fsp3.mkdir(dir, { recursive: true });
96
96
  continue;
97
97
  }
98
98
  if (code !== "EEXIST") throw err;
99
99
  try {
100
- const stat6 = await fsp2.stat(lockPath);
101
- if (Date.now() - stat6.mtimeMs > staleMs) {
102
- await fsp2.unlink(lockPath);
100
+ const stat8 = await fsp3.stat(lockPath);
101
+ if (Date.now() - stat8.mtimeMs > staleMs) {
102
+ await fsp3.unlink(lockPath);
103
103
  continue;
104
104
  }
105
105
  } catch {
@@ -119,21 +119,21 @@ async function withFileLock(targetPath, fn, opts = {}) {
119
119
  } catch {
120
120
  }
121
121
  try {
122
- await fsp2.unlink(lockPath);
122
+ await fsp3.unlink(lockPath);
123
123
  } catch {
124
124
  }
125
125
  }
126
126
  }
127
127
  async function renameWithRetry(from, to) {
128
128
  if (process.platform !== "win32") {
129
- await fsp2.rename(from, to);
129
+ await fsp3.rename(from, to);
130
130
  return;
131
131
  }
132
132
  const delays = [10, 25, 60, 120, 250];
133
133
  let lastErr;
134
134
  for (let i = 0; i <= delays.length; i++) {
135
135
  try {
136
- await fsp2.rename(from, to);
136
+ await fsp3.rename(from, to);
137
137
  return;
138
138
  } catch (err) {
139
139
  lastErr = err;
@@ -181,7 +181,7 @@ function envFlag(value) {
181
181
  return !/^(0|false|no|off)$/i.test(value.trim());
182
182
  }
183
183
  var COLOR = isColorTty();
184
- var wrap = (open3, close) => (s) => COLOR ? `\x1B[${open3}m${s}\x1B[${close}m` : s;
184
+ var wrap = (open4, close) => (s) => COLOR ? `\x1B[${open4}m${s}\x1B[${close}m` : s;
185
185
  var color = {
186
186
  reset: wrap("0", "0"),
187
187
  bold: wrap("1", "22"),
@@ -772,7 +772,7 @@ async function expandGlob(pattern) {
772
772
  async function walk3(dir, pat) {
773
773
  let entries;
774
774
  try {
775
- entries = await fsp2.readdir(dir);
775
+ entries = await fsp3.readdir(dir);
776
776
  } catch {
777
777
  return;
778
778
  }
@@ -794,8 +794,8 @@ async function expandGlob(pattern) {
794
794
  for (const e of entries) {
795
795
  const full = `${dir}${SEP}${e}`;
796
796
  try {
797
- const stat6 = await fsp2.stat(full);
798
- if (stat6.isDirectory()) await walk3(full, rest);
797
+ const stat8 = await fsp3.stat(full);
798
+ if (stat8.isDirectory()) await walk3(full, rest);
799
799
  } catch {
800
800
  }
801
801
  }
@@ -812,8 +812,8 @@ async function expandGlob(pattern) {
812
812
  if (entries.includes(seg)) {
813
813
  const full = `${dir}${SEP}${seg}`;
814
814
  try {
815
- const stat6 = await fsp2.stat(full);
816
- if (stat6.isDirectory()) await walk3(full, rest);
815
+ const stat8 = await fsp3.stat(full);
816
+ if (stat8.isDirectory()) await walk3(full, rest);
817
817
  } catch {
818
818
  }
819
819
  }
@@ -1003,11 +1003,11 @@ function validateAgainstSchema(value, schema) {
1003
1003
  walk(value, schema, "", errors);
1004
1004
  return { ok: errors.length === 0, errors };
1005
1005
  }
1006
- function walk(value, schema, path23, errors) {
1006
+ function walk(value, schema, path25, errors) {
1007
1007
  if (schema.enum !== void 0) {
1008
1008
  if (!schema.enum.some((e) => deepEqual(e, value))) {
1009
1009
  errors.push({
1010
- path: path23 || "<root>",
1010
+ path: path25 || "<root>",
1011
1011
  message: `expected one of ${JSON.stringify(schema.enum)}, got ${JSON.stringify(value)}`
1012
1012
  });
1013
1013
  return;
@@ -1016,7 +1016,7 @@ function walk(value, schema, path23, errors) {
1016
1016
  if (typeof schema.type === "string") {
1017
1017
  if (!checkType(value, schema.type)) {
1018
1018
  errors.push({
1019
- path: path23 || "<root>",
1019
+ path: path25 || "<root>",
1020
1020
  message: `expected ${schema.type}, got ${describeType(value)}`
1021
1021
  });
1022
1022
  return;
@@ -1026,20 +1026,20 @@ function walk(value, schema, path23, errors) {
1026
1026
  const obj = value;
1027
1027
  for (const req of schema.required ?? []) {
1028
1028
  if (!(req in obj)) {
1029
- errors.push({ path: joinPath(path23, req), message: "required property missing" });
1029
+ errors.push({ path: joinPath(path25, req), message: "required property missing" });
1030
1030
  }
1031
1031
  }
1032
1032
  if (schema.properties) {
1033
1033
  for (const [key, subSchema] of Object.entries(schema.properties)) {
1034
1034
  if (key in obj) {
1035
- walk(obj[key], subSchema, joinPath(path23, key), errors);
1035
+ walk(obj[key], subSchema, joinPath(path25, key), errors);
1036
1036
  }
1037
1037
  }
1038
1038
  }
1039
1039
  }
1040
1040
  if (schema.type === "array" && Array.isArray(value) && schema.items) {
1041
1041
  for (let i = 0; i < value.length; i++) {
1042
- walk(value[i], schema.items, `${path23}[${i}]`, errors);
1042
+ walk(value[i], schema.items, `${path25}[${i}]`, errors);
1043
1043
  }
1044
1044
  }
1045
1045
  }
@@ -2205,1541 +2205,1677 @@ function resolveWstackPaths(opts) {
2205
2205
  projectStatus: (projectHash2) => path4.join(globalRoot, "projects", projectHash2, "status.json")
2206
2206
  };
2207
2207
  }
2208
- function sanitizeModel(model) {
2209
- return model.replace(/[^a-zA-Z0-9_-]/g, "-").replace(/-+/g, "-").replace(/^-|-$/g, "").slice(0, 40);
2210
- }
2211
- function generateSessionId(startedAt, model) {
2212
- const date = startedAt.slice(0, 10);
2213
- const time = startedAt.slice(11, 19).replace(/:/g, "-");
2214
- const suffix = randomBytes(2).toString("hex");
2215
- const modelPart = model ? `_${sanitizeModel(model)}` : "";
2216
- return `${date}/${time}Z${modelPart}_${suffix}`;
2208
+
2209
+ // src/storage/file-session-writer.ts
2210
+ init_atomic_write();
2211
+
2212
+ // src/storage/session-helpers.ts
2213
+ function userInputTitle(content) {
2214
+ const text = typeof content === "string" ? content : content.filter((b) => b.type === "text").map((b) => b.text).join(" ");
2215
+ return (text || "(non-text input)").slice(0, 60);
2217
2216
  }
2218
2217
 
2219
- // src/storage/session-store.ts
2220
- var DefaultSessionStore = class _DefaultSessionStore {
2221
- dir;
2218
+ // src/storage/file-session-writer.ts
2219
+ var FileSessionWriter = class _FileSessionWriter {
2220
+ constructor(id, handle, startedAt, meta, events, opts = {}, traceId) {
2221
+ this.id = id;
2222
+ this.handle = handle;
2223
+ this.startedAt = startedAt;
2224
+ this.meta = meta;
2225
+ this.events = events;
2226
+ this.resumed = opts.resumed ?? false;
2227
+ this.manifestFile = opts.dir ? path4.join(opts.dir, `${path4.basename(id)}.summary.json`) : "";
2228
+ this.filePath = opts.filePath ?? "";
2229
+ this.secretScrubber = opts.secretScrubber;
2230
+ this.onCloseCb = opts.onClose;
2231
+ this.summary = {
2232
+ id,
2233
+ title: "(empty session)",
2234
+ startedAt,
2235
+ model: meta.model ?? "unknown",
2236
+ provider: meta.provider ?? "unknown",
2237
+ tokenTotal: 0
2238
+ };
2239
+ this.traceId = traceId;
2240
+ }
2241
+ id;
2242
+ handle;
2243
+ startedAt;
2244
+ meta;
2222
2245
  events;
2223
- secretScrubber;
2246
+ closed = false;
2247
+ closePromise = null;
2248
+ manifestFile;
2249
+ summary;
2250
+ tokenIn = 0;
2251
+ tokenOut = 0;
2252
+ filePath;
2253
+ get transcriptPath() {
2254
+ return this.filePath || void 0;
2255
+ }
2224
2256
  /**
2225
- * In-memory cache for load() results, keyed by session ID. The cache is
2226
- * invalidated when the file's mtimeMs or size changes (indicating the
2227
- * file was written to). This eliminates redundant full-file reads and
2228
- * JSON parses when the same session is loaded multiple times within the
2229
- * store's lifetime (e.g., webui session detail views, list() fallbacks).
2230
- *
2231
- * Max size is capped to prevent unbounded memory growth in long-running
2232
- * processes. When the limit is reached, the oldest entry is evicted.
2257
+ * Lazy session_start/session_resumed init, shared by all appenders.
2258
+ * A single promise (not a boolean) so a second append racing the first
2259
+ * can't push its event into the buffer BEFORE the first append's event —
2260
+ * every appender awaits the same init and resumes in FIFO call order.
2233
2261
  */
2234
- _loadCache = /* @__PURE__ */ new Map();
2235
- _indexCache = null;
2236
- static LOAD_CACHE_MAX_ENTRIES = 50;
2237
- static LIST_SCAN_CONCURRENCY = 32;
2238
- constructor(opts) {
2239
- this.dir = opts.dir;
2240
- this.events = opts.events;
2241
- this.secretScrubber = opts.secretScrubber;
2262
+ initPromise = null;
2263
+ ensureInit() {
2264
+ if (!this.initPromise) this.initPromise = this.writeSessionStartLazy();
2265
+ return this.initPromise;
2266
+ }
2267
+ resumed;
2268
+ appendFailCount = 0;
2269
+ lastAppendWarnAt = 0;
2270
+ secretScrubber;
2271
+ onCloseCb;
2272
+ /** Implements SessionWriter.traceId — propagated from ContextInit.traceId. */
2273
+ traceId;
2274
+ // ── Write buffer — batches events to reduce per-event disk I/O ──────────────
2275
+ //
2276
+ // Every append() pushes the scrubbed event into an in-memory buffer instead
2277
+ // of calling handle.appendFile() synchronously. The buffer flushes to disk
2278
+ // when it reaches FLUSH_SIZE events OR after FLUSH_INTERVAL_MS of inactivity.
2279
+ // This cuts the number of disk writes by ~95% without changing the on-disk
2280
+ // format — the JSONL is still one JSON object per line.
2281
+ writeBuffer = [];
2282
+ flushTimer = null;
2283
+ static FLUSH_INTERVAL_MS = 500;
2284
+ static FLUSH_SIZE = 50;
2285
+ // ── Write serialization ─────────────────────────────────────────────────────
2286
+ //
2287
+ // All disk writes are funneled through a FIFO promise chain. Without it,
2288
+ // a timer-driven flush racing an explicit flush()/close() issues two
2289
+ // concurrent appendFile() calls on the shared O_APPEND handle — the kernel
2290
+ // may complete them out of order (chronology breaks) or, for large
2291
+ // batches, interleave partial writes (torn JSONL lines). The chain keeps
2292
+ // exactly one write in flight; failures don't break the chain.
2293
+ writeChain = Promise.resolve();
2294
+ /** Enqueue a write on the FIFO chain. Resolves/rejects with that write. */
2295
+ enqueueWrite(data) {
2296
+ const write = this.writeChain.then(() => this.handle.appendFile(data, "utf8"));
2297
+ this.writeChain = write.then(
2298
+ () => void 0,
2299
+ () => void 0
2300
+ );
2301
+ return write;
2242
2302
  }
2303
+ // ── Enriched summary tracking ───────────────────────────────────────────────
2304
+ iterationCount = 0;
2305
+ toolCallCount = 0;
2306
+ toolErrorCount = 0;
2307
+ toolBreakdown = {};
2308
+ fileChangeCount = 0;
2309
+ compactionCount = 0;
2310
+ outcome = void 0;
2243
2311
  /**
2244
- * Clear the load() cache. Useful for testing or when the caller knows
2245
- * the file has changed externally (e.g., another process wrote to it).
2312
+ * Scrub secrets out of conversation-turn events before they are observed
2313
+ * for the summary, written to the JSONL log, or surfaced on resume. Only
2314
+ * `user_input` / `llm_response` carry free-form user/model text; other event
2315
+ * types either have no secret-bearing content or are already scrubbed
2316
+ * upstream (tool results). Returns the event unchanged when no scrubber is
2317
+ * configured.
2246
2318
  */
2247
- clearLoadCache(sessionId) {
2248
- if (sessionId !== void 0) {
2249
- this._loadCache.delete(sessionId);
2250
- } else {
2251
- this._loadCache.clear();
2319
+ scrubEvent(event) {
2320
+ const s = this.secretScrubber;
2321
+ if (!s) return event;
2322
+ if (event.type === "user_input") {
2323
+ return {
2324
+ ...event,
2325
+ content: typeof event.content === "string" ? s.scrub(event.content) : s.scrubObject(event.content)
2326
+ };
2327
+ }
2328
+ if (event.type === "llm_response") {
2329
+ return { ...event, content: s.scrubObject(event.content) };
2252
2330
  }
2331
+ return event;
2253
2332
  }
2254
- // ── Storage event helpers ───────────────────────────────────────────────────
2255
- emitRead(sessionId, filePath, operation, outcome, durationMs, error) {
2256
- this.events?.emit("storage.read", {
2257
- sessionId,
2258
- store: "session",
2259
- filePath,
2260
- operation,
2261
- outcome,
2262
- durationMs,
2263
- ...error !== void 0 ? { error } : {}
2264
- });
2333
+ pendingFileSnapshots = [];
2334
+ /** Tracks open tool_use IDs during the current run to serialize on close for resume. */
2335
+ openToolUses = /* @__PURE__ */ new Set();
2336
+ recordFileChange(input) {
2337
+ this.pendingFileSnapshots.push(input);
2265
2338
  }
2266
- emitWrite(sessionId, filePath, operation, outcome, durationMs, eventCount, error) {
2267
- this.events?.emit("storage.write", {
2268
- sessionId,
2269
- store: "session",
2270
- filePath,
2271
- operation,
2272
- outcome,
2273
- durationMs,
2274
- ...eventCount !== void 0 ? { eventCount } : {},
2275
- ...error !== void 0 ? { error } : {}
2276
- });
2339
+ get pendingToolUses() {
2340
+ return Array.from(this.openToolUses);
2277
2341
  }
2278
- emitError(sessionId, filePath, operation, error, recoverable) {
2279
- this.events?.emit("storage.error", {
2280
- sessionId,
2281
- store: "session",
2282
- filePath,
2283
- operation,
2284
- error,
2285
- recoverable
2286
- });
2342
+ async writeSessionStartLazy() {
2343
+ const record = `${JSON.stringify({
2344
+ type: this.resumed ? "session_resumed" : "session_start",
2345
+ ts: this.startedAt,
2346
+ id: this.id,
2347
+ model: this.meta.model ?? "unknown",
2348
+ provider: this.meta.provider ?? "unknown"
2349
+ })}
2350
+ `;
2351
+ try {
2352
+ await this.enqueueWrite(record);
2353
+ } catch {
2354
+ }
2287
2355
  }
2288
- /** Absolute path to the session index file. */
2289
- get indexFile() {
2290
- return path4.join(this.dir, "_index.jsonl");
2356
+ async append(event) {
2357
+ if (this.closed) return;
2358
+ await this.ensureInit();
2359
+ const scrubbed = this.scrubEvent(event);
2360
+ this.observeForSummary(scrubbed);
2361
+ this.writeBuffer.push(scrubbed);
2362
+ if (this.writeBuffer.length >= _FileSessionWriter.FLUSH_SIZE) {
2363
+ if (this.flushTimer) {
2364
+ clearTimeout(this.flushTimer);
2365
+ this.flushTimer = null;
2366
+ }
2367
+ await this.flushBuffer();
2368
+ } else {
2369
+ this.scheduleFlush();
2370
+ }
2291
2371
  }
2292
- /** Join session ID to its absolute path within the store directory. */
2293
- sessionPath(id, ext) {
2294
- return path4.join(this.dir, `${id}${ext}`);
2372
+ async appendBatch(events) {
2373
+ if (this.closed || events.length === 0) return;
2374
+ await this.ensureInit();
2375
+ for (const event of events) {
2376
+ const scrubbed = this.scrubEvent(event);
2377
+ this.observeForSummary(scrubbed);
2378
+ this.writeBuffer.push(scrubbed);
2379
+ }
2380
+ if (this.writeBuffer.length >= _FileSessionWriter.FLUSH_SIZE) {
2381
+ if (this.flushTimer) {
2382
+ clearTimeout(this.flushTimer);
2383
+ this.flushTimer = null;
2384
+ }
2385
+ await this.flushBuffer();
2386
+ } else {
2387
+ this.scheduleFlush();
2388
+ }
2295
2389
  }
2296
2390
  /**
2297
- * Ensure the directory implied by the session ID exists. When the ID
2298
- * contains a date prefix like `2026-06-06/...`, this creates the date
2299
- * subdirectory so sessions group naturally by day.
2391
+ * Flush buffered events to disk immediately. Critical events
2392
+ * (user_input, llm_response) call this so they survive SIGKILL/crash
2393
+ * instead of sitting in the in-memory buffer for up to 500ms.
2394
+ *
2395
+ * Idempotent — cancels any pending timer and writes whatever has
2396
+ * accumulated in the buffer. Safe to call even when the buffer
2397
+ * is empty (no-op).
2300
2398
  */
2301
- async ensureShardDir(id) {
2302
- const dirPath = path4.dirname(path4.join(this.dir, id));
2303
- await ensureDir(dirPath);
2304
- return dirPath;
2399
+ async flush() {
2400
+ if (this.flushTimer) {
2401
+ clearTimeout(this.flushTimer);
2402
+ this.flushTimer = null;
2403
+ }
2404
+ await this.flushBuffer();
2305
2405
  }
2306
- async create(meta) {
2307
- const startedAt = (/* @__PURE__ */ new Date()).toISOString();
2308
- const id = meta.id && meta.id.length > 0 ? meta.id : generateSessionId(startedAt, meta.model ?? meta.provider);
2309
- const shardDir = await this.ensureShardDir(id);
2310
- const file = path4.join(shardDir, `${path4.basename(id)}.jsonl`);
2311
- const t0 = Date.now();
2312
- let handle;
2313
- try {
2314
- handle = await fsp2.open(file, "a", 384);
2315
- } catch (err) {
2316
- this.emitError(id, file, "create", toErrorMessage(err), false);
2317
- throw new Error(
2318
- `Failed to open session file: ${toErrorMessage(err)}`,
2319
- { cause: err }
2320
- );
2321
- }
2322
- try {
2323
- const writer = new FileSessionWriter(id, handle, startedAt, meta, this.events, {
2324
- dir: shardDir,
2325
- filePath: file,
2326
- secretScrubber: this.secretScrubber,
2327
- onClose: (s) => this.appendToIndex(s)
2406
+ /** Schedule a deferred flush. No-op if a timer is already pending. */
2407
+ scheduleFlush() {
2408
+ if (this.flushTimer) return;
2409
+ this.flushTimer = setTimeout(() => {
2410
+ this.flushTimer = null;
2411
+ this.flushBuffer().catch(() => {
2328
2412
  });
2329
- this.emitWrite(id, file, "create", "success", Date.now() - t0);
2330
- return writer;
2331
- } catch (err) {
2332
- await handle.close().catch((e) => console.warn(JSON.stringify({
2333
- level: "warn",
2334
- event: "session_store.handle_close_failed",
2335
- message: e instanceof Error ? e.message : String(e),
2336
- timestamp: (/* @__PURE__ */ new Date()).toISOString()
2337
- })));
2338
- this.emitError(id, file, "create", toErrorMessage(err), true);
2339
- throw err;
2340
- }
2341
- }
2342
- async resume(id) {
2343
- const file = this.sessionPath(id, ".jsonl");
2344
- const t0 = Date.now();
2345
- const data = await this.load(id);
2346
- let handle;
2347
- try {
2348
- handle = await fsp2.open(file, "a", 384);
2349
- } catch (err) {
2350
- this.emitError(id, file, "resume", toErrorMessage(err), false);
2351
- throw new Error(
2352
- `Failed to open session "${id}" for append: ${toErrorMessage(err)}`,
2353
- { cause: err }
2354
- );
2355
- }
2356
- try {
2357
- const writer = new FileSessionWriter(
2358
- id,
2359
- handle,
2360
- (/* @__PURE__ */ new Date()).toISOString(),
2361
- {
2362
- id,
2363
- model: data.metadata.model,
2364
- provider: data.metadata.provider
2365
- },
2366
- this.events,
2367
- {
2368
- resumed: true,
2369
- // Shard directory (sessions/<date>/) — must match create() so the
2370
- // .summary.json sidecar lands next to the JSONL instead of the
2371
- // sessions root (where summaryFor() would never find it).
2372
- dir: path4.dirname(file),
2373
- filePath: file,
2374
- secretScrubber: this.secretScrubber,
2375
- onClose: (s) => this.appendToIndex(s)
2376
- }
2377
- );
2378
- this.emitWrite(id, file, "resume", "success", Date.now() - t0);
2379
- return { writer, data };
2380
- } catch (err) {
2381
- await handle.close().catch((e) => console.warn(JSON.stringify({
2382
- level: "warn",
2383
- event: "session_store.handle_close_failed",
2384
- message: e instanceof Error ? e.message : String(e),
2385
- timestamp: (/* @__PURE__ */ new Date()).toISOString()
2386
- })));
2387
- this.emitError(id, file, "resume", toErrorMessage(err), true);
2388
- throw err;
2389
- }
2413
+ }, _FileSessionWriter.FLUSH_INTERVAL_MS);
2390
2414
  }
2391
- async load(id) {
2392
- const file = this.sessionPath(id, ".jsonl");
2415
+ /**
2416
+ * Flush all buffered events to disk as a single appendFile call.
2417
+ * Errors use the same throttled-warning pattern the old per-event
2418
+ * append path used — one warning every 5s with a suppressed count.
2419
+ * On failure the buffer is cleared (events are best-effort, same as
2420
+ * the old per-event path where a failed write was silently dropped).
2421
+ */
2422
+ async flushBuffer() {
2423
+ if (this.writeBuffer.length === 0) return;
2424
+ const eventCount = this.writeBuffer.length;
2425
+ const batch = this.writeBuffer.map((e) => JSON.stringify(e)).join("\n") + "\n";
2426
+ this.writeBuffer = [];
2393
2427
  const t0 = Date.now();
2394
2428
  let outcome = "success";
2395
2429
  let errorMsg;
2396
- let cacheHit = false;
2397
2430
  try {
2398
- const s = await fsp2.stat(file);
2399
- const stat6 = { mtimeMs: s.mtimeMs, size: s.size };
2400
- const cached = this._loadCache.get(id);
2401
- if (cached && cached.mtimeMs === stat6.mtimeMs && cached.size === stat6.size) {
2402
- cacheHit = true;
2403
- this._loadCache.delete(id);
2404
- this._loadCache.set(id, cached);
2405
- return cached.data;
2406
- }
2407
- const raw = await fsp2.readFile(file, "utf8");
2408
- const lines = raw.split("\n").filter((l) => l.trim());
2409
- const events = [];
2410
- let sessionStartEvent;
2411
- let sessionEndEvent;
2412
- let sessionModel;
2413
- let sessionProvider;
2414
- let sessionPendingToolUses;
2415
- const messages = [];
2416
- const openToolUses = /* @__PURE__ */ new Set();
2417
- let usage = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 };
2418
- for (const line of lines) {
2419
- try {
2420
- const parsed = JSON.parse(line);
2421
- if (parsed !== null && typeof parsed === "object" && typeof parsed.type === "string" && typeof parsed.ts === "string") {
2422
- const ev = parsed;
2423
- events.push(ev);
2424
- if (ev.type === "session_start" && !sessionStartEvent) {
2425
- sessionStartEvent = ev;
2426
- sessionModel = ev.model;
2427
- sessionProvider = ev.provider;
2428
- }
2429
- if (ev.type === "session_end") {
2430
- sessionEndEvent = ev;
2431
- sessionPendingToolUses = ev.pendingToolUses;
2432
- }
2433
- if (ev.type === "user_input") {
2434
- openToolUses.clear();
2435
- messages.push({ role: "user", content: ev.content, ts: ev.ts });
2436
- } else if (ev.type === "llm_response") {
2437
- messages.push({ role: "assistant", content: ev.content, ts: ev.ts });
2438
- for (const b of ev.content) {
2439
- if (b.type === "tool_use") openToolUses.add(b.id);
2440
- }
2441
- usage = {
2442
- input: usage.input + (ev.usage.input ?? 0),
2443
- output: usage.output + (ev.usage.output ?? 0),
2444
- cacheRead: (usage.cacheRead ?? 0) + (ev.usage.cacheRead ?? 0),
2445
- cacheWrite: (usage.cacheWrite ?? 0) + (ev.usage.cacheWrite ?? 0)
2446
- };
2447
- } else if (ev.type === "tool_result") {
2448
- if (!openToolUses.has(ev.id)) {
2449
- this.events?.emit("session.damaged", {
2450
- sessionId: id,
2451
- detail: `Orphan tool_result "${ev.id}" has no matching tool_use`
2452
- });
2453
- continue;
2454
- }
2455
- openToolUses.delete(ev.id);
2456
- const resultBlock = {
2457
- type: "tool_result",
2458
- tool_use_id: ev.id,
2459
- content: typeof ev.content === "string" ? ev.content : JSON.stringify(ev.content),
2460
- is_error: ev.isError
2461
- };
2462
- const last = messages[messages.length - 1];
2463
- const lastIsToolResultUser = last?.role === "user" && Array.isArray(last.content) && last.content.every((b) => b.type === "tool_result");
2464
- if (lastIsToolResultUser && Array.isArray(last.content)) {
2465
- last.content.push(resultBlock);
2466
- } else {
2467
- messages.push({ role: "user", content: [resultBlock], ts: ev.ts });
2468
- }
2469
- }
2470
- }
2471
- } catch {
2472
- }
2473
- }
2474
- if (openToolUses.size > 0) {
2475
- this.events?.emit("session.damaged", {
2476
- sessionId: id,
2477
- detail: `${openToolUses.size} tool_use blocks without matching results - replay repaired`
2478
- });
2479
- }
2480
- const repaired = repairToolUseAdjacency(messages);
2481
- if (repaired.report.changed) {
2482
- this.events?.emit("session.damaged", {
2483
- sessionId: id,
2484
- detail: `Repaired replay adjacency: removed ${repaired.report.removedToolUses.length} tool_use, ${repaired.report.removedToolResults.length} tool_result, ${repaired.report.removedMessages} empty messages`
2485
- });
2486
- }
2487
- const meta = {
2488
- id,
2489
- startedAt: sessionStartEvent?.ts ?? (/* @__PURE__ */ new Date(0)).toISOString(),
2490
- endedAt: sessionEndEvent?.ts,
2491
- model: sessionModel,
2492
- provider: sessionProvider,
2493
- pendingToolUses: sessionPendingToolUses
2494
- };
2495
- const toolCallEnds = extractToolCallEnds(events);
2496
- const data = { metadata: meta, events, messages: repaired.messages, usage, toolCallEnds };
2497
- if (this._loadCache.size >= _DefaultSessionStore.LOAD_CACHE_MAX_ENTRIES) {
2498
- const oldest = this._loadCache.keys().next().value;
2499
- if (oldest !== void 0) {
2500
- this._loadCache.delete(oldest);
2501
- }
2502
- }
2503
- this._loadCache.set(id, { mtimeMs: stat6.mtimeMs, size: stat6.size, data });
2504
- return data;
2431
+ await this.enqueueWrite(batch);
2505
2432
  } catch (err) {
2506
2433
  outcome = "failure";
2507
2434
  errorMsg = toErrorMessage(err);
2508
- throw err;
2509
- } finally {
2510
- this.emitRead(id, file, "load", outcome, Date.now() - t0, errorMsg);
2511
- if (cacheHit) {
2512
- this.events?.emit("storage.cache_hit", {
2513
- sessionId: id,
2514
- store: "session",
2515
- filePath: file,
2516
- operation: "load",
2517
- durationMs: Date.now() - t0
2518
- });
2435
+ this.appendFailCount += eventCount;
2436
+ const now = Date.now();
2437
+ if (now - this.lastAppendWarnAt > 5e3) {
2438
+ const suppressed = this.appendFailCount - 1;
2439
+ const tail = suppressed > 0 ? ` (+${suppressed} suppressed)` : "";
2440
+ console.warn(
2441
+ "[session] flush failed:",
2442
+ toErrorMessage(err),
2443
+ tail
2444
+ );
2445
+ this.lastAppendWarnAt = now;
2446
+ this.appendFailCount = 0;
2519
2447
  }
2448
+ } finally {
2449
+ this.events?.emit("storage.write", {
2450
+ sessionId: this.id,
2451
+ store: "session",
2452
+ filePath: this.filePath,
2453
+ operation: "flush",
2454
+ outcome,
2455
+ durationMs: Date.now() - t0,
2456
+ ...errorMsg !== void 0 ? { error: errorMsg } : {},
2457
+ ...eventCount !== void 0 ? { eventCount } : {},
2458
+ ...this.traceId !== void 0 ? { traceId: this.traceId } : {}
2459
+ });
2520
2460
  }
2521
2461
  }
2522
- /**
2523
- * Streaming search over a session's JSONL. Walks the file once, parses
2524
- * each event lazily, and yields only the events that match `predicate`.
2525
- * Stops as soon as `opts.limit` matches are collected.
2526
- *
2527
- * Why this exists: `load()` parses the entire file into memory and
2528
- * rebuilds `messages`/`toolCallEnds` for every caller. `search()` only
2529
- * needs to know which events contain matching text — a per-line
2530
- * predicate is enough. The full parse work (and the `_loadCache` poll)
2531
- * is wasted in that case.
2532
- *
2533
- * Memory: O(hits) regardless of file size. Disk: one linear scan,
2534
- * terminated at `limit` if the caller asked for one.
2535
- *
2536
- * Errors: missing file yields []. Corrupt lines are skipped (same
2537
- * policy as `load()`). Aborting via `signal` rejects with `AbortError`.
2538
- */
2539
- async searchEvents(id, predicate, opts) {
2540
- const file = this.sessionPath(id, ".jsonl");
2541
- const limit = opts?.limit;
2542
- const signal = opts?.signal;
2543
- const out = [];
2544
- let stat6;
2545
- try {
2546
- stat6 = await fsp2.stat(file);
2547
- } catch (err) {
2548
- if (err.code === "ENOENT") return [];
2549
- throw err;
2550
- }
2551
- if (stat6.size === 0) return [];
2552
- let fh;
2553
- try {
2554
- fh = await fsp2.open(file, "r");
2555
- const CHUNK = 64 * 1024;
2556
- const buf = Buffer.alloc(CHUNK);
2557
- let leftover = "";
2558
- let eventIndex = 0;
2559
- for (let position = 0; ; position += buf.byteLength) {
2560
- if (signal?.aborted) {
2561
- const reason = signal.reason ?? new DOMException("Aborted", "AbortError");
2562
- throw reason;
2563
- }
2564
- const { bytesRead } = await fh.read(buf, 0, CHUNK, position);
2565
- if (bytesRead === 0) break;
2566
- const text = leftover + buf.subarray(0, bytesRead).toString("utf8");
2567
- const parts = text.split("\n");
2568
- leftover = parts.pop() ?? "";
2569
- for (const line of parts) {
2570
- if (!line) continue;
2571
- let ev;
2572
- try {
2573
- const parsed = JSON.parse(line);
2574
- if (parsed === null || typeof parsed !== "object" || typeof parsed.type !== "string" || typeof parsed.ts !== "string") {
2575
- continue;
2576
- }
2577
- ev = parsed;
2578
- } catch {
2579
- continue;
2580
- }
2581
- if (predicate(ev, eventIndex, ev.ts)) {
2582
- out.push({ event: ev, eventIndex, ts: ev.ts });
2583
- if (limit !== void 0 && out.length >= limit) {
2584
- return out;
2585
- }
2586
- }
2587
- eventIndex++;
2588
- }
2462
+ observeForSummary(event) {
2463
+ if (event.type === "llm_response") {
2464
+ for (const block of event.content) {
2465
+ if (block.type === "tool_use") this.openToolUses.add(block.id);
2589
2466
  }
2590
- if (leftover.trim()) {
2591
- try {
2592
- const parsed = JSON.parse(leftover);
2593
- if (parsed !== null && typeof parsed === "object" && typeof parsed.type === "string" && typeof parsed.ts === "string") {
2594
- const ev = parsed;
2595
- if (predicate(ev, eventIndex, ev.ts)) {
2596
- out.push({ event: ev, eventIndex, ts: ev.ts });
2597
- }
2598
- }
2599
- } catch {
2600
- }
2467
+ }
2468
+ if (event.type === "tool_use") {
2469
+ this.openToolUses.add(event.id);
2470
+ } else if (event.type === "tool_call_start") {
2471
+ this.toolCallCount++;
2472
+ this.toolBreakdown[event.name] = (this.toolBreakdown[event.name] ?? 0) + 1;
2473
+ } else if (event.type === "tool_result") {
2474
+ this.openToolUses.delete(event.id);
2475
+ if (event.isError) {
2476
+ this.toolErrorCount++;
2477
+ this.outcome = "error";
2601
2478
  }
2602
- return out;
2603
- } finally {
2604
- if (fh) await fh.close().catch(() => void 0);
2479
+ } else if (event.type === "file_snapshot") {
2480
+ this.fileChangeCount += event.files.length;
2481
+ } else if (event.type === "compaction") {
2482
+ this.compactionCount++;
2483
+ }
2484
+ if (event.type === "error" || event.type === "provider_error") {
2485
+ this.outcome = "error";
2486
+ }
2487
+ if (event.type === "user_input" && this.summary.title === "(empty session)") {
2488
+ this.summary = { ...this.summary, title: userInputTitle(event.content) };
2489
+ } else if (event.type === "llm_response") {
2490
+ this.tokenIn += event.usage.input;
2491
+ this.tokenOut += event.usage.output;
2492
+ this.summary = { ...this.summary, tokenTotal: this.tokenIn + this.tokenOut };
2493
+ } else if (event.type === "session_end") {
2494
+ const total = event.usage.input + event.usage.output;
2495
+ if (total > 0) this.summary = { ...this.summary, tokenTotal: total };
2496
+ } else if (event.type === "in_flight_start") {
2497
+ this.iterationCount++;
2605
2498
  }
2606
2499
  }
2607
- async list(limit = 20) {
2608
- try {
2609
- await ensureDir(this.dir);
2610
- const indexed = await this.readIndex();
2611
- if (indexed.length > 0) {
2612
- indexed.sort((a, b) => {
2613
- if (a.startedAt < b.startedAt) return 1;
2614
- if (a.startedAt > b.startedAt) return -1;
2615
- return a.id.localeCompare(b.id);
2500
+ async close() {
2501
+ if (this.closePromise) return this.closePromise;
2502
+ this.closePromise = this.doClose();
2503
+ return this.closePromise;
2504
+ }
2505
+ async doClose() {
2506
+ this.closed = true;
2507
+ if (this.flushTimer) {
2508
+ clearTimeout(this.flushTimer);
2509
+ this.flushTimer = null;
2510
+ }
2511
+ await this.flushBuffer();
2512
+ await this.writeChain;
2513
+ this.summary = {
2514
+ ...this.summary,
2515
+ endedAt: (/* @__PURE__ */ new Date()).toISOString(),
2516
+ iterationCount: this.iterationCount,
2517
+ toolCallCount: this.toolCallCount,
2518
+ toolErrorCount: this.toolErrorCount,
2519
+ fileChangeCount: this.fileChangeCount,
2520
+ compactionCount: this.compactionCount > 0 ? this.compactionCount : void 0,
2521
+ toolBreakdown: { ...this.toolBreakdown },
2522
+ outcome: this.outcome ?? "completed"
2523
+ };
2524
+ if (this.manifestFile) {
2525
+ const t0 = Date.now();
2526
+ let outcome = "success";
2527
+ let errorMsg;
2528
+ try {
2529
+ await atomicWrite(this.manifestFile, JSON.stringify(this.summary), { mode: 384 });
2530
+ } catch (err) {
2531
+ outcome = "failure";
2532
+ errorMsg = toErrorMessage(err);
2533
+ } finally {
2534
+ this.events?.emit("storage.write", {
2535
+ sessionId: this.id,
2536
+ store: "session",
2537
+ filePath: this.manifestFile,
2538
+ operation: "close",
2539
+ outcome,
2540
+ durationMs: Date.now() - t0,
2541
+ ...errorMsg !== void 0 ? { error: errorMsg } : {},
2542
+ ...this.traceId !== void 0 ? { traceId: this.traceId } : {}
2616
2543
  });
2617
- return indexed.slice(0, limit);
2618
2544
  }
2619
- return await this.listFromDirectoryScan(limit);
2620
- } catch {
2621
- return [];
2622
2545
  }
2623
- }
2624
- // ── Session index (_index.jsonl) ─────────────────────────────────────────
2625
- //
2626
- // One JSON line per closed session, appended atomically on close().
2627
- // When a session is deleted, a tombstone {action:"delete",id:"..."} is
2628
- // appended. On read, tombstones filter out matching session entries.
2629
- // This keeps listing O(lines-in-index) instead of O(files-on-disk).
2630
- //
2631
- // The index auto-compacts every N appends to prevent unbounded growth
2632
- // from tombstones and duplicate entries (resume cycles).
2633
- indexAppendCount = 0;
2634
- static COMPACT_EVERY = 30;
2635
- /** Append a session summary to the index. */
2636
- async appendToIndex(summary) {
2546
+ const idxT0 = Date.now();
2547
+ let idxOutcome = "success";
2548
+ let idxError;
2637
2549
  try {
2638
- await ensureDir(this.dir);
2639
- const line = JSON.stringify(summary) + "\n";
2640
- await fsp2.appendFile(this.indexFile, line, "utf8");
2641
- this._indexCache = null;
2642
- this.indexAppendCount++;
2643
- if (this.indexAppendCount >= _DefaultSessionStore.COMPACT_EVERY) {
2644
- await this.compactIndex();
2645
- this.indexAppendCount = 0;
2646
- }
2647
- } catch {
2550
+ await this.onCloseCb?.(this.summary);
2551
+ } catch (err) {
2552
+ idxOutcome = "failure";
2553
+ idxError = toErrorMessage(err);
2554
+ } finally {
2555
+ this.events?.emit("storage.write", {
2556
+ sessionId: this.summary.id,
2557
+ store: "session",
2558
+ filePath: this.filePath,
2559
+ operation: "index_append",
2560
+ outcome: idxOutcome,
2561
+ durationMs: Date.now() - idxT0,
2562
+ ...idxError !== void 0 ? { error: idxError } : {},
2563
+ ...this.traceId !== void 0 ? { traceId: this.traceId } : {}
2564
+ });
2648
2565
  }
2649
- }
2650
- /** Append a tombstone entry for a deleted session. */
2651
- async writeTombstone(id) {
2652
2566
  try {
2653
- await ensureDir(this.dir);
2654
- const line = JSON.stringify({ action: "delete", id }) + "\n";
2655
- await fsp2.appendFile(this.indexFile, line, "utf8");
2656
- this._indexCache = null;
2657
- this.indexAppendCount++;
2567
+ await this.handle.close();
2658
2568
  } catch {
2659
2569
  }
2660
2570
  }
2661
- /**
2662
- * Compact the index: read all entries, drop tombstones, deduplicate
2663
- * (keep latest per session), and rewrite. Atomic via temp+rename.
2664
- */
2665
- async compactIndex() {
2666
- const t0 = Date.now();
2667
- let outcome = "success";
2668
- let errorMsg;
2669
- try {
2670
- const entries = await this.readIndex();
2671
- if (entries.length === 0) return;
2672
- const tmp = `${this.indexFile}.compact.tmp`;
2673
- const lines = entries.map((s) => JSON.stringify(s)).join("\n") + "\n";
2674
- await fsp2.writeFile(tmp, lines, "utf8");
2675
- await fsp2.rename(tmp, this.indexFile);
2676
- this._indexCache = null;
2677
- } catch (err) {
2678
- outcome = "failure";
2679
- errorMsg = toErrorMessage(err);
2680
- } finally {
2681
- this.emitWrite("~compact~", this.indexFile, "compact", outcome, Date.now() - t0, void 0, errorMsg);
2571
+ async writeCheckpoint(promptIndex, promptPreview) {
2572
+ const fileCount = this.pendingFileSnapshots.length;
2573
+ if (fileCount > 0) {
2574
+ await this.writeFileSnapshot(promptIndex, [...this.pendingFileSnapshots]);
2575
+ this.pendingFileSnapshots = [];
2682
2576
  }
2577
+ await this.append({
2578
+ type: "checkpoint",
2579
+ ts: (/* @__PURE__ */ new Date()).toISOString(),
2580
+ promptIndex,
2581
+ promptPreview
2582
+ });
2583
+ this.events?.emit("checkpoint.written", {
2584
+ promptIndex,
2585
+ promptPreview,
2586
+ ts: (/* @__PURE__ */ new Date()).toISOString(),
2587
+ fileCount
2588
+ });
2589
+ }
2590
+ async writeFileSnapshot(promptIndex, files) {
2591
+ await this.append({
2592
+ type: "file_snapshot",
2593
+ ts: (/* @__PURE__ */ new Date()).toISOString(),
2594
+ promptIndex,
2595
+ files
2596
+ });
2683
2597
  }
2684
2598
  /**
2685
- * Read the index file and return deduplicated session summaries.
2686
- * Entries with a matching tombstone are filtered out.
2687
- * Returns empty array when the index doesn't exist or is corrupt.
2599
+ * Truncate the session file to the checkpoint with the given promptIndex,
2600
+ * removing all events that follow it. Uses a single-pass byte-offset scan
2601
+ * so post-checkpoint content is never read or parsed O(1) memory instead
2602
+ * of O(N) JSON.parse calls over the full file.
2688
2603
  */
2689
- async readIndex() {
2690
- let stat6;
2691
- try {
2692
- const s = await fsp2.stat(this.indexFile);
2693
- stat6 = { mtimeMs: s.mtimeMs, size: s.size };
2694
- } catch {
2695
- this._indexCache = null;
2696
- return [];
2697
- }
2698
- if (this._indexCache !== null && this._indexCache.mtimeMs === stat6.mtimeMs && this._indexCache.size === stat6.size) {
2699
- return [...this._indexCache.summaries];
2604
+ async truncateToCheckpoint(targetPromptIndex) {
2605
+ if (!this.filePath) return 0;
2606
+ if (this.flushTimer) {
2607
+ clearTimeout(this.flushTimer);
2608
+ this.flushTimer = null;
2700
2609
  }
2701
- let raw;
2610
+ await this.flushBuffer();
2611
+ await this.writeChain;
2612
+ const CHUNK_SIZE = 65536;
2613
+ let fd;
2614
+ let fileOffset = 0;
2615
+ let lineStartOffset = 0;
2616
+ let checkpointByteOffset = -1;
2617
+ let removedCount = 0;
2618
+ let targetCheckpointSeen = false;
2702
2619
  try {
2703
- raw = await fsp2.readFile(this.indexFile, "utf8");
2704
- } catch {
2705
- this._indexCache = null;
2706
- return [];
2620
+ fd = await fsp3.open(this.filePath, "r", 384);
2621
+ while (true) {
2622
+ const buf = Buffer.alloc(CHUNK_SIZE);
2623
+ const { bytesRead } = await fd.read(buf, 0, CHUNK_SIZE, fileOffset);
2624
+ if (bytesRead === 0) break;
2625
+ let chunkPos = 0;
2626
+ while (chunkPos < bytesRead) {
2627
+ const idx = buf.indexOf("\n", chunkPos);
2628
+ if (idx === -1) {
2629
+ lineStartOffset = fileOffset + chunkPos;
2630
+ break;
2631
+ }
2632
+ if (checkpointByteOffset !== -1) {
2633
+ removedCount++;
2634
+ } else {
2635
+ const lineBytes = buf.subarray(chunkPos, idx);
2636
+ const line = new TextDecoder("utf-8", { fatal: false }).decode(lineBytes);
2637
+ if (line.trim()) {
2638
+ try {
2639
+ const event = JSON.parse(line);
2640
+ if (event.type === "checkpoint") {
2641
+ if (event.promptIndex === targetPromptIndex) {
2642
+ checkpointByteOffset = lineStartOffset;
2643
+ targetCheckpointSeen = true;
2644
+ } else if (event.promptIndex !== void 0 && event.promptIndex > targetPromptIndex) {
2645
+ checkpointByteOffset = lineStartOffset;
2646
+ }
2647
+ } else if (targetCheckpointSeen && event.promptIndex !== void 0 && event.promptIndex > targetPromptIndex) {
2648
+ removedCount++;
2649
+ } else if (targetCheckpointSeen && event.promptIndex === void 0) {
2650
+ removedCount++;
2651
+ } else if (!targetCheckpointSeen && event.promptIndex === void 0) {
2652
+ removedCount++;
2653
+ } else if (!targetCheckpointSeen && event.promptIndex !== void 0 && event.promptIndex > targetPromptIndex) {
2654
+ removedCount++;
2655
+ }
2656
+ } catch {
2657
+ }
2658
+ }
2659
+ }
2660
+ chunkPos = idx + 1;
2661
+ lineStartOffset = fileOffset + chunkPos;
2662
+ }
2663
+ fileOffset += bytesRead;
2664
+ if (chunkPos >= bytesRead) {
2665
+ lineStartOffset = fileOffset;
2666
+ }
2667
+ }
2668
+ } finally {
2669
+ await fd?.close();
2707
2670
  }
2708
- const deleted = /* @__PURE__ */ new Set();
2709
- const seen = /* @__PURE__ */ new Map();
2710
- for (const line of raw.split("\n")) {
2711
- if (!line.trim()) continue;
2671
+ if (checkpointByteOffset === -1) return 0;
2672
+ await this.writeChain;
2673
+ await this.handle.close();
2674
+ const tmpPath = `${this.filePath}.rewind.tmp`;
2675
+ const src = await fsp3.open(this.filePath, "r", 384);
2676
+ try {
2677
+ const statResult = await src.stat();
2678
+ const totalSize = statResult.size;
2679
+ const prefixBytes = checkpointByteOffset;
2680
+ let newlineAfterCheckpoint = prefixBytes;
2681
+ if (prefixBytes < totalSize) {
2682
+ const probeBuf = Buffer.alloc(Math.min(CHUNK_SIZE, totalSize - prefixBytes));
2683
+ const { bytesRead: probeRead } = await src.read(probeBuf, 0, probeBuf.length, prefixBytes);
2684
+ if (probeRead > 0) {
2685
+ const nl = probeBuf.indexOf("\n");
2686
+ newlineAfterCheckpoint = nl !== -1 ? prefixBytes + nl + 1 : totalSize;
2687
+ }
2688
+ } else {
2689
+ newlineAfterCheckpoint = totalSize;
2690
+ }
2691
+ const writeFd = await fsp3.open(tmpPath, "w", 384);
2712
2692
  try {
2713
- const entry = JSON.parse(line);
2714
- if (entry.action === "delete" && entry.id) {
2715
- deleted.add(entry.id);
2716
- seen.delete(entry.id);
2717
- continue;
2693
+ let readOffset = 0;
2694
+ while (readOffset < newlineAfterCheckpoint) {
2695
+ const toCopy = Math.min(CHUNK_SIZE, newlineAfterCheckpoint - readOffset);
2696
+ const copyBuf = Buffer.alloc(toCopy);
2697
+ const { bytesRead: r } = await src.read(copyBuf, 0, toCopy, readOffset);
2698
+ if (r === 0) break;
2699
+ await writeFd.write(copyBuf, 0, r);
2700
+ readOffset += r;
2718
2701
  }
2719
- if (entry.id && !deleted.has(entry.id)) {
2720
- seen.set(entry.id, entry);
2702
+ let tailOffset = newlineAfterCheckpoint;
2703
+ let leftover = "";
2704
+ while (tailOffset < totalSize) {
2705
+ const toRead = Math.min(CHUNK_SIZE, totalSize - tailOffset);
2706
+ const tailBuf = Buffer.alloc(toRead);
2707
+ const { bytesRead: tr } = await src.read(tailBuf, 0, toRead, tailOffset);
2708
+ if (tr === 0) break;
2709
+ const chunk = leftover + tailBuf.subarray(0, tr).toString("utf8");
2710
+ const lastNl = chunk.lastIndexOf("\n");
2711
+ if (lastNl === -1) {
2712
+ leftover = chunk;
2713
+ } else {
2714
+ for (const line of chunk.slice(0, lastNl + 1).split("\n")) {
2715
+ if (!line.trim()) continue;
2716
+ try {
2717
+ JSON.parse(line);
2718
+ } catch {
2719
+ await writeFd.write(`${line}
2720
+ `, void 0, "utf8");
2721
+ }
2722
+ }
2723
+ leftover = chunk.slice(lastNl + 1);
2724
+ }
2725
+ tailOffset += tr;
2721
2726
  }
2722
- } catch {
2727
+ if (leftover.trim()) {
2728
+ try {
2729
+ JSON.parse(leftover);
2730
+ } catch {
2731
+ await writeFd.write(`${leftover}
2732
+ `, void 0, "utf8");
2733
+ }
2734
+ }
2735
+ } finally {
2736
+ await writeFd.close();
2723
2737
  }
2738
+ await src.close();
2739
+ await fsp3.rename(tmpPath, this.filePath);
2740
+ this.handle = await fsp3.open(this.filePath, "a", 384);
2741
+ } catch (err) {
2742
+ await fsp3.unlink(tmpPath).catch(() => void 0);
2743
+ this.handle = await fsp3.open(this.filePath, "a", 384).catch(() => this.handle);
2744
+ throw err;
2724
2745
  }
2725
- const summaries = Array.from(seen.values());
2726
- this._indexCache = { ...stat6, summaries };
2727
- return [...summaries];
2746
+ await this.append({
2747
+ type: "rewound",
2748
+ ts: (/* @__PURE__ */ new Date()).toISOString(),
2749
+ toPromptIndex: targetPromptIndex,
2750
+ revertedFiles: []
2751
+ });
2752
+ this.events?.emit("session.rewound", {
2753
+ toPromptIndex: targetPromptIndex,
2754
+ revertedFiles: [],
2755
+ removedEvents: removedCount
2756
+ });
2757
+ return removedCount;
2758
+ }
2759
+ async clearSession() {
2760
+ if (!this.filePath) return;
2761
+ if (this.flushTimer) {
2762
+ clearTimeout(this.flushTimer);
2763
+ this.flushTimer = null;
2764
+ }
2765
+ this.writeBuffer = [];
2766
+ await this.writeChain;
2767
+ const record = `${JSON.stringify({
2768
+ type: "session_start",
2769
+ ts: (/* @__PURE__ */ new Date()).toISOString(),
2770
+ id: this.id,
2771
+ model: this.meta.model ?? "unknown",
2772
+ provider: this.meta.provider ?? "unknown"
2773
+ })}
2774
+ `;
2775
+ await fsp3.writeFile(this.filePath, record, "utf8");
2728
2776
  }
2729
2777
  /**
2730
- * Rebuild the index from disk by scanning all sessions and writing a
2731
- * fresh _index.jsonl. Useful after manual cleanup or index corruption.
2778
+ * Write an in-flight marker. The agent loop should call
2779
+ * this at the start of each long-running operation; a matching
2780
+ * `clearInFlightMarker` follows on clean exit. A stale marker
2781
+ * (no end) is what `SessionRecovery.detectStale` looks for.
2732
2782
  */
2733
- async rebuildIndex() {
2734
- const ids = await this.collectSessionIds(this.dir);
2735
- const summaries = await Promise.all(ids.map((id) => this.summaryFor(id).catch(() => null)));
2736
- const valid = summaries.filter((s) => s !== null);
2737
- const tmp = `${this.indexFile}.tmp`;
2738
- const lines = valid.map((s) => JSON.stringify(s)).join("\n") + "\n";
2739
- await fsp2.writeFile(tmp, lines, "utf8");
2740
- await fsp2.rename(tmp, this.indexFile);
2741
- this._indexCache = null;
2742
- return valid.length;
2783
+ async writeInFlightMarker(context) {
2784
+ if (!context || context.length > 500) {
2785
+ throw new Error("In-flight context must be 1..500 chars");
2786
+ }
2787
+ await this.append({
2788
+ type: "in_flight_start",
2789
+ ts: (/* @__PURE__ */ new Date()).toISOString(),
2790
+ context
2791
+ });
2792
+ this.events?.emit("in_flight.started", { context, ts: (/* @__PURE__ */ new Date()).toISOString() });
2743
2793
  }
2744
- async listFromDirectoryScan(limit) {
2745
- const refs = await this.collectSessionFiles(this.dir);
2746
- const candidates = await mapWithConcurrency(
2747
- refs,
2748
- _DefaultSessionStore.LIST_SCAN_CONCURRENCY,
2749
- async (ref) => {
2750
- const manifest = await this.readSummaryManifest(ref.id);
2751
- if (manifest) return { summary: manifest, needsBackfill: false };
2752
- const summary = await this.summaryHeaderFor(ref);
2753
- return summary ? { summary, needsBackfill: true } : null;
2754
- }
2755
- );
2756
- const out = candidates.filter((s) => s !== null);
2757
- out.sort((a, b) => compareSessionSummaries(a.summary, b.summary));
2758
- const selected = out.slice(0, limit);
2759
- const summaries = await mapWithConcurrency(
2760
- selected,
2761
- Math.min(_DefaultSessionStore.LIST_SCAN_CONCURRENCY, Math.max(1, limit)),
2762
- async (candidate) => {
2763
- if (!candidate.needsBackfill) return candidate.summary;
2764
- return await this.summaryFor(candidate.summary.id).catch(() => candidate.summary);
2765
- }
2766
- );
2767
- return summaries.filter((s) => s !== null);
2794
+ /**
2795
+ * Close the in-flight marker. Idempotent in spirit
2796
+ * (you can call it after a successful iteration even if you
2797
+ * didn't open one this round) — but the session log records
2798
+ * every call so postmortem tooling can see "the agent finished
2799
+ * cleanly X times, then died without finishing Y".
2800
+ */
2801
+ async clearInFlightMarker(reason) {
2802
+ await this.append({
2803
+ type: "in_flight_end",
2804
+ ts: (/* @__PURE__ */ new Date()).toISOString(),
2805
+ reason
2806
+ });
2807
+ this.events?.emit("in_flight.ended", { reason, ts: (/* @__PURE__ */ new Date()).toISOString() });
2768
2808
  }
2769
- async collectSessionFiles(dir, prefix = "", depth = 0) {
2770
- let entries;
2771
- try {
2772
- entries = await fsp2.readdir(dir, { withFileTypes: true });
2773
- } catch {
2774
- return [];
2809
+ };
2810
+ function sanitizeModel(model) {
2811
+ return model.replace(/[^a-zA-Z0-9_-]/g, "-").replace(/-+/g, "-").replace(/^-|-$/g, "").slice(0, 40);
2812
+ }
2813
+ function generateSessionId(startedAt, model) {
2814
+ const date = startedAt.slice(0, 10);
2815
+ const time = startedAt.slice(11, 19).replace(/:/g, "-");
2816
+ const suffix = randomBytes(2).toString("hex");
2817
+ const modelPart = model ? `_${sanitizeModel(model)}` : "";
2818
+ return `${date}/${time}Z${modelPart}_${suffix}`;
2819
+ }
2820
+
2821
+ // src/storage/session-store.ts
2822
+ var DefaultSessionStore = class _DefaultSessionStore {
2823
+ dir;
2824
+ events;
2825
+ secretScrubber;
2826
+ /**
2827
+ * In-memory cache for load() results, keyed by session ID. The cache is
2828
+ * invalidated when the file's mtimeMs or size changes (indicating the
2829
+ * file was written to). This eliminates redundant full-file reads and
2830
+ * JSON parses when the same session is loaded multiple times within the
2831
+ * store's lifetime (e.g., webui session detail views, list() fallbacks).
2832
+ *
2833
+ * Max size is capped to prevent unbounded memory growth in long-running
2834
+ * processes. When the limit is reached, the oldest entry is evicted.
2835
+ */
2836
+ _loadCache = /* @__PURE__ */ new Map();
2837
+ _indexCache = null;
2838
+ shardManifestCache = /* @__PURE__ */ new Map();
2839
+ static LOAD_CACHE_MAX_ENTRIES = 50;
2840
+ static LIST_SCAN_CONCURRENCY = 32;
2841
+ constructor(opts) {
2842
+ this.dir = opts.dir;
2843
+ this.events = opts.events;
2844
+ this.secretScrubber = opts.secretScrubber;
2845
+ }
2846
+ /**
2847
+ * Clear the load() cache. Useful for testing or when the caller knows
2848
+ * the file has changed externally (e.g., another process wrote to it).
2849
+ */
2850
+ clearLoadCache(sessionId) {
2851
+ if (sessionId !== void 0) {
2852
+ this._loadCache.delete(sessionId);
2853
+ } else {
2854
+ this._loadCache.clear();
2775
2855
  }
2776
- const dirEntries = [];
2777
- const files = [];
2778
- for (const entry of entries) {
2779
- if (entry.name.startsWith(".") && entry.name !== ".wrongstack") continue;
2780
- if (entry.name === "shared" || entry.name === "subagents" || entry.name === "attachments")
2781
- continue;
2782
- if (entry.isDirectory()) {
2783
- dirEntries.push(entry);
2784
- } else if (entry.isFile() && entry.name.endsWith(".jsonl")) {
2785
- if (entry.name === "_index.jsonl") continue;
2786
- const base = entry.name.replace(/\.jsonl$/, "");
2787
- const id = prefix ? `${prefix}/${base}` : base;
2788
- files.push({ id, filePath: path4.join(dir, entry.name) });
2789
- }
2790
- }
2791
- const childFileArrays = await Promise.all(
2792
- dirEntries.map((entry) => {
2793
- const childPrefix = depth === 0 ? entry.name : `${prefix}/${entry.name}`;
2794
- return this.collectSessionFiles(path4.join(dir, entry.name), childPrefix, depth + 1);
2795
- })
2796
- );
2797
- return [...childFileArrays.flat(), ...files];
2798
2856
  }
2799
- /** Recursively collect session IDs from date-shard subdirectories.
2800
- * IDs include the date-prefix path (e.g. "2026-06-06/17-46-57Z_…").
2801
- * Skips `.jsonl`/`.summary.json` root files, dot-files, and
2802
- * sub-directories that belong to fleet/subagent sessions. */
2803
- async collectSessionIds(dir, prefix = "", depth = 0) {
2804
- let entries;
2805
- try {
2806
- entries = await fsp2.readdir(dir, { withFileTypes: true });
2807
- } catch {
2808
- return [];
2809
- }
2810
- const dirEntries = [];
2811
- const fileIds = [];
2812
- for (const entry of entries) {
2813
- if (entry.name.startsWith(".") && entry.name !== ".wrongstack") continue;
2814
- if (entry.name === "shared" || entry.name === "subagents" || entry.name === "attachments")
2815
- continue;
2816
- if (entry.isDirectory()) {
2817
- dirEntries.push(entry);
2818
- } else if (entry.isFile() && entry.name.endsWith(".jsonl")) {
2819
- if (entry.name === "_index.jsonl") continue;
2820
- const base = entry.name.replace(/\.jsonl$/, "");
2821
- fileIds.push(prefix ? `${prefix}/${base}` : base);
2822
- }
2823
- }
2824
- const childIdArrays = await Promise.all(
2825
- dirEntries.map((entry) => {
2826
- const childPrefix = depth === 0 ? entry.name : `${prefix}/${entry.name}`;
2827
- return this.collectSessionIds(path4.join(dir, entry.name), childPrefix, depth + 1);
2828
- })
2829
- );
2830
- return [...childIdArrays.flat(), ...fileIds];
2857
+ // ── Storage event helpers ───────────────────────────────────────────────────
2858
+ emitRead(sessionId, filePath, operation, outcome, durationMs, error) {
2859
+ this.events?.emit("storage.read", {
2860
+ sessionId,
2861
+ store: "session",
2862
+ filePath,
2863
+ operation,
2864
+ outcome,
2865
+ durationMs,
2866
+ ...error !== void 0 ? { error } : {}
2867
+ });
2831
2868
  }
2832
- async summaryFor(id) {
2833
- const manifest = this.sessionPath(id, ".summary.json");
2834
- const t0 = Date.now();
2835
- let outcome = "success";
2836
- let errorMsg;
2837
- const fromManifest = await this.readSummaryManifest(id, t0);
2838
- if (fromManifest) return fromManifest;
2839
- try {
2840
- const full = this.sessionPath(id, ".jsonl");
2841
- const stat6 = await fsp2.stat(full);
2842
- const summary = await this.summarize(id, stat6.mtime.toISOString());
2843
- await atomicWrite(manifest, JSON.stringify(summary), { mode: 384 }).catch((err) => {
2844
- const msg = toErrorMessage(err);
2845
- this.emitError(id, manifest, "summary_fallback", msg, true);
2846
- console.warn(JSON.stringify({
2847
- level: "warn",
2848
- event: "session_store.manifest_write_failed",
2849
- sessionId: id,
2850
- message: msg,
2851
- timestamp: (/* @__PURE__ */ new Date()).toISOString()
2852
- }));
2853
- });
2854
- outcome = "failure";
2855
- errorMsg = "summary fallback \xE2\u20AC\u201D manifest rebuilt";
2856
- this.emitRead(id, manifest, "summary", outcome, Date.now() - t0, errorMsg);
2857
- return summary;
2858
- } catch (err) {
2859
- outcome = "failure";
2860
- errorMsg = toErrorMessage(err);
2861
- this.emitRead(id, manifest, "summary", outcome, Date.now() - t0, errorMsg);
2862
- return {
2863
- id,
2864
- title: "(damaged)",
2865
- startedAt: (/* @__PURE__ */ new Date()).toISOString(),
2866
- model: "unknown",
2867
- provider: "unknown",
2868
- tokenTotal: 0
2869
- };
2870
- }
2869
+ emitWrite(sessionId, filePath, operation, outcome, durationMs, eventCount, error) {
2870
+ this.events?.emit("storage.write", {
2871
+ sessionId,
2872
+ store: "session",
2873
+ filePath,
2874
+ operation,
2875
+ outcome,
2876
+ durationMs,
2877
+ ...eventCount !== void 0 ? { eventCount } : {},
2878
+ ...error !== void 0 ? { error } : {}
2879
+ });
2871
2880
  }
2872
- async readSummaryManifest(id, startTime = Date.now()) {
2873
- const manifest = this.sessionPath(id, ".summary.json");
2874
- try {
2875
- const raw = await fsp2.readFile(manifest, "utf8");
2876
- this.emitRead(id, manifest, "summary", "success", Date.now() - startTime);
2877
- return JSON.parse(raw);
2878
- } catch {
2879
- return null;
2880
- }
2881
+ emitError(sessionId, filePath, operation, error, recoverable) {
2882
+ this.events?.emit("storage.error", {
2883
+ sessionId,
2884
+ store: "session",
2885
+ filePath,
2886
+ operation,
2887
+ error,
2888
+ recoverable
2889
+ });
2881
2890
  }
2882
- async summaryHeaderFor(ref) {
2883
- let mtime = (/* @__PURE__ */ new Date(0)).toISOString();
2884
- try {
2885
- const stat6 = await fsp2.stat(ref.filePath);
2886
- if (!stat6.isFile()) {
2887
- return {
2888
- id: ref.id,
2889
- title: "(damaged)",
2890
- startedAt: stat6.mtime.toISOString(),
2891
- model: "unknown",
2892
- provider: "unknown",
2893
- tokenTotal: 0
2894
- };
2895
- }
2896
- mtime = stat6.mtime.toISOString();
2897
- } catch {
2898
- return null;
2899
- }
2900
- try {
2901
- for await (const event of this.iterSessionEvents(ref.filePath)) {
2902
- if (event.type === "session_start") {
2903
- return {
2904
- id: ref.id,
2905
- title: "(empty session)",
2906
- startedAt: event.ts,
2907
- model: event.model ?? "unknown",
2908
- provider: event.provider ?? "unknown",
2909
- tokenTotal: 0
2910
- };
2911
- }
2912
- }
2913
- return {
2914
- id: ref.id,
2915
- title: "(empty session)",
2916
- startedAt: (/* @__PURE__ */ new Date(0)).toISOString(),
2917
- model: "unknown",
2918
- provider: "unknown",
2919
- tokenTotal: 0
2920
- };
2921
- } catch {
2922
- return {
2923
- id: ref.id,
2924
- title: "(damaged)",
2925
- startedAt: mtime,
2926
- model: "unknown",
2927
- provider: "unknown",
2928
- tokenTotal: 0
2929
- };
2930
- }
2891
+ /** Absolute path to the session index file. */
2892
+ get indexFile() {
2893
+ return path4.join(this.dir, "_index.jsonl");
2894
+ }
2895
+ /** Join session ID to its absolute path within the store directory. */
2896
+ sessionPath(id, ext) {
2897
+ return path4.join(this.dir, `${id}${ext}`);
2898
+ }
2899
+ shardManifestPath(shardKey) {
2900
+ return shardKey ? path4.join(this.dir, shardKey, "_manifest.json") : path4.join(this.dir, "_manifest.json");
2901
+ }
2902
+ shardKeyForSessionId(id) {
2903
+ const dirName = path4.dirname(id);
2904
+ return dirName === "." ? "" : dirName;
2905
+ }
2906
+ invalidateShardManifestBySessionId(id) {
2907
+ this.shardManifestCache.delete(this.shardKeyForSessionId(id));
2931
2908
  }
2932
2909
  /**
2933
- * Delete a session and all associated files: JSONL, summary, plan/todos
2934
- * sidecars, and the session directory (fleet.json, shared/, subagents/).
2935
- *
2936
- * Individual file deletions are best-effort (logged as structured warnings),
2937
- * but a tombstone is always written so readIndex() filters this session out.
2938
- * If the session directory itself can't be removed, the error is surfaced
2939
- * to the caller so prune() can report it.
2910
+ * Ensure the directory implied by the session ID exists. When the ID
2911
+ * contains a date prefix like `2026-06-06/...`, this creates the date
2912
+ * subdirectory so sessions group naturally by day.
2940
2913
  */
2941
- async deleteSession(id) {
2942
- const jsonlPath = this.sessionPath(id, ".jsonl");
2943
- const summaryPath = this.sessionPath(id, ".summary.json");
2944
- const shardDir = path4.dirname(path4.join(this.dir, id));
2945
- const base = path4.basename(id);
2946
- const sessDir = path4.join(shardDir, base);
2947
- const deletions = [
2948
- fsp2.unlink(jsonlPath),
2949
- fsp2.unlink(summaryPath),
2950
- fsp2.unlink(path4.join(shardDir, `${base}.plan.json`)),
2951
- fsp2.unlink(path4.join(shardDir, `${base}.todos.json`))
2952
- ];
2953
- const results = await Promise.allSettled(deletions);
2954
- for (const r of results) {
2955
- if (r.status === "rejected") {
2956
- const msg = r.reason instanceof Error ? r.reason.message : String(r.reason);
2957
- if (r.reason?.code !== "ENOENT") {
2958
- console.warn(JSON.stringify({
2959
- level: "warn",
2960
- event: "session_store.delete_failed",
2961
- sessionId: id,
2962
- message: msg,
2963
- timestamp: (/* @__PURE__ */ new Date()).toISOString()
2964
- }));
2965
- }
2966
- }
2967
- }
2968
- await fsp2.rm(sessDir, { recursive: true, force: true }).catch((err) => {
2969
- console.warn(JSON.stringify({
2970
- level: "warn",
2971
- event: "session_store.rmdir_failed",
2972
- sessionId: id,
2973
- message: toErrorMessage(err),
2974
- timestamp: (/* @__PURE__ */ new Date()).toISOString()
2975
- }));
2976
- });
2977
- await this.writeTombstone(id);
2978
- }
2979
- async delete(id) {
2980
- await this.deleteSession(id);
2914
+ async ensureShardDir(id) {
2915
+ const dirPath = path4.dirname(path4.join(this.dir, id));
2916
+ await ensureDir(dirPath);
2917
+ return dirPath;
2981
2918
  }
2982
- async prune(maxAgeDays = 30) {
2983
- const cutoff = Date.now() - maxAgeDays * 864e5;
2984
- let deleted = 0;
2985
- let activeSessionId = null;
2919
+ async create(meta) {
2920
+ const startedAt = (/* @__PURE__ */ new Date()).toISOString();
2921
+ const id = meta.id && meta.id.length > 0 ? meta.id : generateSessionId(startedAt, meta.model ?? meta.provider);
2922
+ const shardDir = await this.ensureShardDir(id);
2923
+ const file = path4.join(shardDir, `${path4.basename(id)}.jsonl`);
2924
+ const t0 = Date.now();
2925
+ let handle;
2986
2926
  try {
2987
- const raw = await fsp2.readFile(path4.join(this.dir, "active.json"), "utf8");
2988
- const active = JSON.parse(raw);
2989
- activeSessionId = active.sessionId ?? null;
2990
- } catch {
2991
- }
2992
- const isPrunableJsonl = (name) => name.endsWith(".jsonl") && name !== "_index.jsonl" && name !== "_mailbox.jsonl" && !name.endsWith(".replay.jsonl") && !name.endsWith(".audit.jsonl");
2993
- const pruneFile = async (dir, name, prefix) => {
2994
- const jsonlPath = path4.join(dir, name);
2995
- try {
2996
- const stat6 = await fsp2.stat(jsonlPath);
2997
- if (stat6.mtimeMs >= cutoff) return;
2998
- } catch {
2999
- return;
3000
- }
3001
- const base = name.replace(/\.jsonl$/, "");
3002
- const id = prefix ? `${prefix}/${base}` : base;
3003
- if (activeSessionId && id === activeSessionId) return;
3004
- await this.deleteSession(id);
3005
- deleted++;
3006
- };
3007
- const entries = await fsp2.readdir(this.dir, { withFileTypes: true }).catch(() => []);
3008
- for (const entry of entries) {
3009
- if (entry.isFile()) {
3010
- if (isPrunableJsonl(entry.name)) await pruneFile(this.dir, entry.name, "");
3011
- continue;
3012
- }
3013
- if (!entry.isDirectory()) continue;
3014
- const dateDir = path4.join(this.dir, entry.name);
3015
- const files = await fsp2.readdir(dateDir, { withFileTypes: true }).catch(() => []);
3016
- for (const file of files) {
3017
- if (!file.isFile() || !isPrunableJsonl(file.name)) continue;
3018
- await pruneFile(dateDir, file.name, entry.name);
3019
- }
3020
- }
3021
- if (deleted > 0) {
3022
- await this.compactIndex().catch(() => void 0);
2927
+ handle = await fsp3.open(file, "a", 384);
2928
+ } catch (err) {
2929
+ this.emitError(id, file, "create", toErrorMessage(err), false);
2930
+ throw new Error(
2931
+ `Failed to open session file: ${toErrorMessage(err)}`,
2932
+ { cause: err }
2933
+ );
3023
2934
  }
3024
- for (const entry of entries) {
3025
- if (!entry.isDirectory()) continue;
3026
- const dateDir = path4.join(this.dir, entry.name);
3027
- try {
3028
- const remaining = await fsp2.readdir(dateDir);
3029
- if (remaining.length === 0) {
3030
- await fsp2.rmdir(dateDir).catch(() => void 0);
3031
- }
3032
- } catch {
3033
- }
2935
+ try {
2936
+ const writer = new FileSessionWriter(id, handle, startedAt, meta, this.events, {
2937
+ dir: shardDir,
2938
+ filePath: file,
2939
+ secretScrubber: this.secretScrubber,
2940
+ onClose: (s) => this.appendToIndex(s)
2941
+ });
2942
+ this.emitWrite(id, file, "create", "success", Date.now() - t0);
2943
+ return writer;
2944
+ } catch (err) {
2945
+ await handle.close().catch((e) => console.warn(JSON.stringify({
2946
+ level: "warn",
2947
+ event: "session_store.handle_close_failed",
2948
+ message: e instanceof Error ? e.message : String(e),
2949
+ timestamp: (/* @__PURE__ */ new Date()).toISOString()
2950
+ })));
2951
+ this.emitError(id, file, "create", toErrorMessage(err), true);
2952
+ throw err;
3034
2953
  }
3035
- return deleted;
3036
2954
  }
3037
- async clearHistory(id) {
3038
- await this.ensureShardDir(id);
2955
+ async resume(id) {
3039
2956
  const file = this.sessionPath(id, ".jsonl");
3040
- const meta = this.sessionPath(id, ".summary.json");
3041
- const record = `${JSON.stringify({
3042
- type: "session_start",
3043
- ts: (/* @__PURE__ */ new Date()).toISOString(),
3044
- id,
3045
- model: "unknown",
3046
- provider: "unknown"
3047
- })}
3048
- `;
3049
- await fsp2.writeFile(file, record, "utf8");
3050
- await fsp2.unlink(meta).catch(() => void 0);
3051
- }
3052
- async summarize(id, mtime) {
2957
+ const t0 = Date.now();
2958
+ const data = await this.load(id);
2959
+ let handle;
3053
2960
  try {
3054
- const file = this.sessionPath(id, ".jsonl");
3055
- let title = "(empty session)";
3056
- let startedAt = (/* @__PURE__ */ new Date(0)).toISOString();
3057
- let endedAt;
3058
- let model = "unknown";
3059
- let provider = "unknown";
3060
- let tokenIn = 0;
3061
- let tokenOut = 0;
3062
- let iterationCount = 0;
3063
- let toolCallCount = 0;
3064
- let toolErrorCount = 0;
3065
- let fileChangeCount = 0;
3066
- const toolBreakdown = {};
3067
- let outcome;
3068
- let lastEventType;
3069
- let hasError = false;
3070
- let sawStart = false;
3071
- for await (const e of this.iterSessionEvents(file)) {
3072
- lastEventType = e.type;
3073
- if (e.type === "session_start") {
3074
- if (!sawStart) {
3075
- sawStart = true;
3076
- startedAt = e.ts;
3077
- model = e.model ?? "unknown";
3078
- provider = e.provider ?? "unknown";
3079
- }
3080
- } else if (e.type === "session_end") {
3081
- endedAt = e.ts;
3082
- } else if (e.type === "user_input") {
3083
- if (title === "(empty session)") title = userInputTitle(e.content);
3084
- } else if (e.type === "llm_response") {
3085
- tokenIn += e.usage.input ?? 0;
3086
- tokenOut += e.usage.output ?? 0;
3087
- } else if (e.type === "in_flight_start") iterationCount++;
3088
- else if (e.type === "tool_call_start") {
3089
- toolCallCount++;
3090
- toolBreakdown[e.name] = (toolBreakdown[e.name] ?? 0) + 1;
3091
- } else if (e.type === "tool_result" && e.isError) toolErrorCount++;
3092
- else if (e.type === "file_snapshot") fileChangeCount += e.files.length;
3093
- else if (e.type === "error" || e.type === "provider_error") hasError = true;
3094
- }
3095
- if (lastEventType === "session_end") {
3096
- outcome = "completed";
3097
- } else if (lastEventType === "in_flight_start") {
3098
- outcome = "aborted";
3099
- } else if (hasError) {
3100
- outcome = "error";
3101
- }
3102
- return {
3103
- id,
3104
- title,
3105
- startedAt,
3106
- endedAt,
3107
- model,
3108
- provider,
3109
- tokenTotal: tokenIn + tokenOut,
3110
- iterationCount: iterationCount > 0 ? iterationCount : void 0,
3111
- toolCallCount: toolCallCount > 0 ? toolCallCount : void 0,
3112
- toolErrorCount: toolErrorCount > 0 ? toolErrorCount : void 0,
3113
- fileChangeCount: fileChangeCount > 0 ? fileChangeCount : void 0,
3114
- toolBreakdown: Object.keys(toolBreakdown).length > 0 ? toolBreakdown : {},
3115
- outcome
3116
- };
3117
- } catch {
3118
- return {
2961
+ handle = await fsp3.open(file, "a", 384);
2962
+ } catch (err) {
2963
+ this.emitError(id, file, "resume", toErrorMessage(err), false);
2964
+ throw new Error(
2965
+ `Failed to open session "${id}" for append: ${toErrorMessage(err)}`,
2966
+ { cause: err }
2967
+ );
2968
+ }
2969
+ try {
2970
+ const writer = new FileSessionWriter(
3119
2971
  id,
3120
- title: "(damaged)",
3121
- startedAt: mtime,
3122
- model: "unknown",
3123
- provider: "unknown",
3124
- tokenTotal: 0
3125
- };
2972
+ handle,
2973
+ (/* @__PURE__ */ new Date()).toISOString(),
2974
+ {
2975
+ id,
2976
+ model: data.metadata.model,
2977
+ provider: data.metadata.provider
2978
+ },
2979
+ this.events,
2980
+ {
2981
+ resumed: true,
2982
+ // Shard directory (sessions/<date>/) — must match create() so the
2983
+ // .summary.json sidecar lands next to the JSONL instead of the
2984
+ // sessions root (where summaryFor() would never find it).
2985
+ dir: path4.dirname(file),
2986
+ filePath: file,
2987
+ secretScrubber: this.secretScrubber,
2988
+ onClose: (s) => this.appendToIndex(s)
2989
+ }
2990
+ );
2991
+ this.emitWrite(id, file, "resume", "success", Date.now() - t0);
2992
+ return { writer, data };
2993
+ } catch (err) {
2994
+ await handle.close().catch((e) => console.warn(JSON.stringify({
2995
+ level: "warn",
2996
+ event: "session_store.handle_close_failed",
2997
+ message: e instanceof Error ? e.message : String(e),
2998
+ timestamp: (/* @__PURE__ */ new Date()).toISOString()
2999
+ })));
3000
+ this.emitError(id, file, "resume", toErrorMessage(err), true);
3001
+ throw err;
3126
3002
  }
3127
3003
  }
3128
- async *iterSessionEvents(file) {
3129
- const stream = createReadStream(file, { encoding: "utf8" });
3130
- const lines = createInterface({ input: stream, crlfDelay: Infinity });
3004
+ async load(id) {
3005
+ const file = this.sessionPath(id, ".jsonl");
3006
+ const t0 = Date.now();
3007
+ let outcome = "success";
3008
+ let errorMsg;
3009
+ let cacheHit = false;
3131
3010
  try {
3132
- for await (const line of lines) {
3133
- if (!line.trim()) continue;
3011
+ const s = await fsp3.stat(file);
3012
+ const stat8 = { mtimeMs: s.mtimeMs, size: s.size };
3013
+ const cached = this._loadCache.get(id);
3014
+ if (cached && cached.mtimeMs === stat8.mtimeMs && cached.size === stat8.size) {
3015
+ cacheHit = true;
3016
+ this._loadCache.delete(id);
3017
+ this._loadCache.set(id, cached);
3018
+ return cached.data;
3019
+ }
3020
+ const raw = await fsp3.readFile(file, "utf8");
3021
+ const lines = raw.split("\n").filter((l) => l.trim());
3022
+ const events = [];
3023
+ let sessionStartEvent;
3024
+ let sessionEndEvent;
3025
+ let sessionModel;
3026
+ let sessionProvider;
3027
+ let sessionPendingToolUses;
3028
+ const messages = [];
3029
+ const openToolUses = /* @__PURE__ */ new Set();
3030
+ let usage = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 };
3031
+ for (const line of lines) {
3134
3032
  try {
3135
3033
  const parsed = JSON.parse(line);
3136
3034
  if (parsed !== null && typeof parsed === "object" && typeof parsed.type === "string" && typeof parsed.ts === "string") {
3137
- yield parsed;
3035
+ const ev = parsed;
3036
+ events.push(ev);
3037
+ if (ev.type === "session_start" && !sessionStartEvent) {
3038
+ sessionStartEvent = ev;
3039
+ sessionModel = ev.model;
3040
+ sessionProvider = ev.provider;
3041
+ }
3042
+ if (ev.type === "session_end") {
3043
+ sessionEndEvent = ev;
3044
+ sessionPendingToolUses = ev.pendingToolUses;
3045
+ }
3046
+ if (ev.type === "user_input") {
3047
+ openToolUses.clear();
3048
+ messages.push({ role: "user", content: ev.content, ts: ev.ts });
3049
+ } else if (ev.type === "llm_response") {
3050
+ messages.push({ role: "assistant", content: ev.content, ts: ev.ts });
3051
+ for (const b of ev.content) {
3052
+ if (b.type === "tool_use") openToolUses.add(b.id);
3053
+ }
3054
+ usage = {
3055
+ input: usage.input + (ev.usage.input ?? 0),
3056
+ output: usage.output + (ev.usage.output ?? 0),
3057
+ cacheRead: (usage.cacheRead ?? 0) + (ev.usage.cacheRead ?? 0),
3058
+ cacheWrite: (usage.cacheWrite ?? 0) + (ev.usage.cacheWrite ?? 0)
3059
+ };
3060
+ } else if (ev.type === "tool_result") {
3061
+ if (!openToolUses.has(ev.id)) {
3062
+ this.events?.emit("session.damaged", {
3063
+ sessionId: id,
3064
+ detail: `Orphan tool_result "${ev.id}" has no matching tool_use`
3065
+ });
3066
+ continue;
3067
+ }
3068
+ openToolUses.delete(ev.id);
3069
+ const resultBlock = {
3070
+ type: "tool_result",
3071
+ tool_use_id: ev.id,
3072
+ content: typeof ev.content === "string" ? ev.content : JSON.stringify(ev.content),
3073
+ is_error: ev.isError
3074
+ };
3075
+ const last = messages[messages.length - 1];
3076
+ const lastIsToolResultUser = last?.role === "user" && Array.isArray(last.content) && last.content.every((b) => b.type === "tool_result");
3077
+ if (lastIsToolResultUser && Array.isArray(last.content)) {
3078
+ last.content.push(resultBlock);
3079
+ } else {
3080
+ messages.push({ role: "user", content: [resultBlock], ts: ev.ts });
3081
+ }
3082
+ }
3138
3083
  }
3139
3084
  } catch {
3140
3085
  }
3141
3086
  }
3087
+ if (openToolUses.size > 0) {
3088
+ this.events?.emit("session.damaged", {
3089
+ sessionId: id,
3090
+ detail: `${openToolUses.size} tool_use blocks without matching results - replay repaired`
3091
+ });
3092
+ }
3093
+ const repaired = repairToolUseAdjacency(messages);
3094
+ if (repaired.report.changed) {
3095
+ this.events?.emit("session.damaged", {
3096
+ sessionId: id,
3097
+ detail: `Repaired replay adjacency: removed ${repaired.report.removedToolUses.length} tool_use, ${repaired.report.removedToolResults.length} tool_result, ${repaired.report.removedMessages} empty messages`
3098
+ });
3099
+ }
3100
+ const meta = {
3101
+ id,
3102
+ startedAt: sessionStartEvent?.ts ?? (/* @__PURE__ */ new Date(0)).toISOString(),
3103
+ endedAt: sessionEndEvent?.ts,
3104
+ model: sessionModel,
3105
+ provider: sessionProvider,
3106
+ pendingToolUses: sessionPendingToolUses
3107
+ };
3108
+ const toolCallEnds = extractToolCallEnds(events);
3109
+ const data = { metadata: meta, events, messages: repaired.messages, usage, toolCallEnds };
3110
+ if (this._loadCache.size >= _DefaultSessionStore.LOAD_CACHE_MAX_ENTRIES) {
3111
+ const oldest = this._loadCache.keys().next().value;
3112
+ if (oldest !== void 0) {
3113
+ this._loadCache.delete(oldest);
3114
+ }
3115
+ }
3116
+ this._loadCache.set(id, { mtimeMs: stat8.mtimeMs, size: stat8.size, data });
3117
+ return data;
3118
+ } catch (err) {
3119
+ outcome = "failure";
3120
+ errorMsg = toErrorMessage(err);
3121
+ throw err;
3142
3122
  } finally {
3143
- lines.close();
3144
- stream.destroy();
3145
- }
3146
- }
3147
- };
3148
- function extractToolCallEnds(events) {
3149
- const result = [];
3150
- for (const e of events) {
3151
- if (e.type === "tool_call_end") {
3152
- result.push({
3153
- name: e.name,
3154
- id: e.id,
3155
- durationMs: e.durationMs,
3156
- ok: e.ok ?? false,
3157
- outputBytes: e.outputBytes,
3158
- outputTokens: e.outputTokens,
3159
- outputLines: e.outputLines
3160
- });
3123
+ this.emitRead(id, file, "load", outcome, Date.now() - t0, errorMsg);
3124
+ if (cacheHit) {
3125
+ this.events?.emit("storage.cache_hit", {
3126
+ sessionId: id,
3127
+ store: "session",
3128
+ filePath: file,
3129
+ operation: "load",
3130
+ durationMs: Date.now() - t0
3131
+ });
3132
+ }
3161
3133
  }
3162
3134
  }
3163
- return result;
3164
- }
3165
- var FileSessionWriter = class _FileSessionWriter {
3166
- constructor(id, handle, startedAt, meta, events, opts = {}, traceId) {
3167
- this.id = id;
3168
- this.handle = handle;
3169
- this.startedAt = startedAt;
3170
- this.meta = meta;
3171
- this.events = events;
3172
- this.resumed = opts.resumed ?? false;
3173
- this.manifestFile = opts.dir ? path4.join(opts.dir, `${path4.basename(id)}.summary.json`) : "";
3174
- this.filePath = opts.filePath ?? "";
3175
- this.secretScrubber = opts.secretScrubber;
3176
- this.onCloseCb = opts.onClose;
3177
- this.summary = {
3178
- id,
3179
- title: "(empty session)",
3180
- startedAt,
3181
- model: meta.model ?? "unknown",
3182
- provider: meta.provider ?? "unknown",
3183
- tokenTotal: 0
3184
- };
3185
- this.traceId = traceId;
3186
- }
3187
- id;
3188
- handle;
3189
- startedAt;
3190
- meta;
3191
- events;
3192
- closed = false;
3193
- closePromise = null;
3194
- manifestFile;
3195
- summary;
3196
- tokenIn = 0;
3197
- tokenOut = 0;
3198
- filePath;
3199
- get transcriptPath() {
3200
- return this.filePath || void 0;
3201
- }
3202
- /**
3203
- * Lazy session_start/session_resumed init, shared by all appenders.
3204
- * A single promise (not a boolean) so a second append racing the first
3205
- * can't push its event into the buffer BEFORE the first append's event —
3206
- * every appender awaits the same init and resumes in FIFO call order.
3207
- */
3208
- initPromise = null;
3209
- ensureInit() {
3210
- if (!this.initPromise) this.initPromise = this.writeSessionStartLazy();
3211
- return this.initPromise;
3212
- }
3213
- resumed;
3214
- appendFailCount = 0;
3215
- lastAppendWarnAt = 0;
3216
- secretScrubber;
3217
- onCloseCb;
3218
- /** Implements SessionWriter.traceId — propagated from ContextInit.traceId. */
3219
- traceId;
3220
- // ── Write buffer — batches events to reduce per-event disk I/O ─────────
3221
- //
3222
- // Every append() pushes the scrubbed event into an in-memory buffer instead
3223
- // of calling handle.appendFile() synchronously. The buffer flushes to disk
3224
- // when it reaches FLUSH_SIZE events OR after FLUSH_INTERVAL_MS of inactivity.
3225
- // This cuts the number of disk writes by ~95% without changing the on-disk
3226
- // format — the JSONL is still one JSON object per line.
3227
- writeBuffer = [];
3228
- flushTimer = null;
3229
- static FLUSH_INTERVAL_MS = 500;
3230
- static FLUSH_SIZE = 50;
3231
- // ── Write serialization ─────────────────────────────────────────────────
3232
- //
3233
- // All disk writes are funneled through a FIFO promise chain. Without it,
3234
- // a timer-driven flush racing an explicit flush()/close() issues two
3235
- // concurrent appendFile() calls on the shared O_APPEND handle — the kernel
3236
- // may complete them out of order (chronology breaks) or, for large
3237
- // batches, interleave partial writes (torn JSONL lines). The chain keeps
3238
- // exactly one write in flight; failures don't break the chain.
3239
- writeChain = Promise.resolve();
3240
- /** Enqueue a write on the FIFO chain. Resolves/rejects with that write. */
3241
- enqueueWrite(data) {
3242
- const write = this.writeChain.then(() => this.handle.appendFile(data, "utf8"));
3243
- this.writeChain = write.then(
3244
- () => void 0,
3245
- () => void 0
3246
- );
3247
- return write;
3248
- }
3249
- // ── Enriched summary tracking ──────────────────────────────────────────
3250
- iterationCount = 0;
3251
- toolCallCount = 0;
3252
- toolErrorCount = 0;
3253
- toolBreakdown = {};
3254
- fileChangeCount = 0;
3255
- compactionCount = 0;
3256
- outcome = void 0;
3257
3135
  /**
3258
- * Scrub secrets out of conversation-turn events before they are observed
3259
- * for the summary, written to the JSONL log, or surfaced on resume. Only
3260
- * `user_input` / `llm_response` carry free-form user/model text; other event
3261
- * types either have no secret-bearing content or are already scrubbed
3262
- * upstream (tool results). Returns the event unchanged when no scrubber is
3263
- * configured.
3136
+ * Streaming search over a session's JSONL. Walks the file once, parses
3137
+ * each event lazily, and yields only the events that match `predicate`.
3138
+ * Stops as soon as `opts.limit` matches are collected.
3139
+ *
3140
+ * Why this exists: `load()` parses the entire file into memory and
3141
+ * rebuilds `messages`/`toolCallEnds` for every caller. `search()` only
3142
+ * needs to know which events contain matching text — a per-line
3143
+ * predicate is enough. The full parse work (and the `_loadCache` poll)
3144
+ * is wasted in that case.
3145
+ *
3146
+ * Memory: O(hits) regardless of file size. Disk: one linear scan,
3147
+ * terminated at `limit` if the caller asked for one.
3148
+ *
3149
+ * Errors: missing file yields []. Corrupt lines are skipped (same
3150
+ * policy as `load()`). Aborting via `signal` rejects with `AbortError`.
3264
3151
  */
3265
- scrubEvent(event) {
3266
- const s = this.secretScrubber;
3267
- if (!s) return event;
3268
- if (event.type === "user_input") {
3269
- return {
3270
- ...event,
3271
- content: typeof event.content === "string" ? s.scrub(event.content) : s.scrubObject(event.content)
3272
- };
3152
+ async searchEvents(id, predicate, opts) {
3153
+ const file = this.sessionPath(id, ".jsonl");
3154
+ const limit = opts?.limit;
3155
+ const signal = opts?.signal;
3156
+ const out = [];
3157
+ let stat8;
3158
+ try {
3159
+ stat8 = await fsp3.stat(file);
3160
+ } catch (err) {
3161
+ if (err.code === "ENOENT") return [];
3162
+ throw err;
3273
3163
  }
3274
- if (event.type === "llm_response") {
3275
- return { ...event, content: s.scrubObject(event.content) };
3164
+ if (stat8.size === 0) return [];
3165
+ let fh;
3166
+ try {
3167
+ fh = await fsp3.open(file, "r");
3168
+ const CHUNK = 64 * 1024;
3169
+ const buf = Buffer.alloc(CHUNK);
3170
+ let leftover = "";
3171
+ let eventIndex = 0;
3172
+ for (let position = 0; ; position += buf.byteLength) {
3173
+ if (signal?.aborted) {
3174
+ const reason = signal.reason ?? new DOMException("Aborted", "AbortError");
3175
+ throw reason;
3176
+ }
3177
+ const { bytesRead } = await fh.read(buf, 0, CHUNK, position);
3178
+ if (bytesRead === 0) break;
3179
+ const text = leftover + buf.subarray(0, bytesRead).toString("utf8");
3180
+ const parts = text.split("\n");
3181
+ leftover = parts.pop() ?? "";
3182
+ for (const line of parts) {
3183
+ if (!line) continue;
3184
+ let ev;
3185
+ try {
3186
+ const parsed = JSON.parse(line);
3187
+ if (parsed === null || typeof parsed !== "object" || typeof parsed.type !== "string" || typeof parsed.ts !== "string") {
3188
+ continue;
3189
+ }
3190
+ ev = parsed;
3191
+ } catch {
3192
+ continue;
3193
+ }
3194
+ if (predicate(ev, eventIndex, ev.ts)) {
3195
+ out.push({ event: ev, eventIndex, ts: ev.ts });
3196
+ if (limit !== void 0 && out.length >= limit) {
3197
+ return out;
3198
+ }
3199
+ }
3200
+ eventIndex++;
3201
+ }
3202
+ }
3203
+ if (leftover.trim()) {
3204
+ try {
3205
+ const parsed = JSON.parse(leftover);
3206
+ if (parsed !== null && typeof parsed === "object" && typeof parsed.type === "string" && typeof parsed.ts === "string") {
3207
+ const ev = parsed;
3208
+ if (predicate(ev, eventIndex, ev.ts)) {
3209
+ out.push({ event: ev, eventIndex, ts: ev.ts });
3210
+ }
3211
+ }
3212
+ } catch {
3213
+ }
3214
+ }
3215
+ return out;
3216
+ } finally {
3217
+ if (fh) await fh.close().catch(() => void 0);
3276
3218
  }
3277
- return event;
3278
- }
3279
- pendingFileSnapshots = [];
3280
- /** Tracks open tool_use IDs during the current run to serialize on close for resume. */
3281
- openToolUses = /* @__PURE__ */ new Set();
3282
- recordFileChange(input) {
3283
- this.pendingFileSnapshots.push(input);
3284
3219
  }
3285
- get pendingToolUses() {
3286
- return Array.from(this.openToolUses);
3220
+ async list(limit = 20) {
3221
+ try {
3222
+ await ensureDir(this.dir);
3223
+ const indexed = await this.readIndex();
3224
+ if (indexed.length > 0) {
3225
+ indexed.sort((a, b) => {
3226
+ if (a.startedAt < b.startedAt) return 1;
3227
+ if (a.startedAt > b.startedAt) return -1;
3228
+ return a.id.localeCompare(b.id);
3229
+ });
3230
+ return indexed.slice(0, limit);
3231
+ }
3232
+ return await this.listFromDirectoryScan(limit);
3233
+ } catch {
3234
+ return [];
3235
+ }
3287
3236
  }
3288
- async writeSessionStartLazy() {
3289
- const record = `${JSON.stringify({
3290
- type: this.resumed ? "session_resumed" : "session_start",
3291
- ts: this.startedAt,
3292
- id: this.id,
3293
- model: this.meta.model ?? "unknown",
3294
- provider: this.meta.provider ?? "unknown"
3295
- })}
3296
- `;
3237
+ /**
3238
+ * List sessions matching filter criteria, using the cached index.
3239
+ * Filters are applied BEFORE sorting and slicing, so the caller gets
3240
+ * exactly `limit` matching sessions — not a slice of a larger fetch.
3241
+ *
3242
+ * This avoids the DefaultSessionReader pattern of fetching 1000 sessions
3243
+ * then linear-filtering: the index is already in memory (readIndex
3244
+ * caches it), and the filter runs over the cached array without any
3245
+ * additional disk I/O.
3246
+ */
3247
+ async listFiltered(criteria) {
3248
+ const limit = criteria.limit ?? 100;
3297
3249
  try {
3298
- await this.enqueueWrite(record);
3250
+ await ensureDir(this.dir);
3251
+ const indexed = await this.readIndex();
3252
+ if (indexed.length === 0) {
3253
+ const raw = await this.list(Math.max(limit, 100));
3254
+ return raw.filter((s) => matchesSessionFilter(s, criteria)).slice(0, limit);
3255
+ }
3256
+ const filtered = indexed.filter((s) => matchesSessionFilter(s, criteria));
3257
+ filtered.sort((a, b) => {
3258
+ if (a.startedAt < b.startedAt) return 1;
3259
+ if (a.startedAt > b.startedAt) return -1;
3260
+ return a.id.localeCompare(b.id);
3261
+ });
3262
+ return filtered.slice(0, limit);
3299
3263
  } catch {
3264
+ return [];
3300
3265
  }
3301
3266
  }
3302
- async append(event) {
3303
- if (this.closed) return;
3304
- await this.ensureInit();
3305
- const scrubbed = this.scrubEvent(event);
3306
- this.observeForSummary(scrubbed);
3307
- this.writeBuffer.push(scrubbed);
3308
- if (this.writeBuffer.length >= _FileSessionWriter.FLUSH_SIZE) {
3309
- if (this.flushTimer) {
3310
- clearTimeout(this.flushTimer);
3311
- this.flushTimer = null;
3267
+ // ── Session index (_index.jsonl) ─────────────────────────────────────────
3268
+ //
3269
+ // One JSON line per closed session, appended atomically on close().
3270
+ // When a session is deleted, a tombstone {action:"delete",id:"..."} is
3271
+ // appended. On read, tombstones filter out matching session entries.
3272
+ // This keeps listing O(lines-in-index) instead of O(files-on-disk).
3273
+ //
3274
+ // The index auto-compacts every N appends to prevent unbounded growth
3275
+ // from tombstones and duplicate entries (resume cycles).
3276
+ indexAppendCount = 0;
3277
+ static COMPACT_EVERY = 30;
3278
+ /** Append a session summary to the index. */
3279
+ async appendToIndex(summary) {
3280
+ try {
3281
+ await ensureDir(this.dir);
3282
+ const line = JSON.stringify(summary) + "\n";
3283
+ await fsp3.appendFile(this.indexFile, line, "utf8");
3284
+ this._indexCache = null;
3285
+ this.invalidateShardManifestBySessionId(summary.id);
3286
+ this.indexAppendCount++;
3287
+ if (this.indexAppendCount >= _DefaultSessionStore.COMPACT_EVERY) {
3288
+ await this.compactIndex();
3289
+ this.indexAppendCount = 0;
3312
3290
  }
3313
- await this.flushBuffer();
3314
- } else {
3315
- this.scheduleFlush();
3291
+ } catch {
3316
3292
  }
3317
3293
  }
3318
- async appendBatch(events) {
3319
- if (this.closed || events.length === 0) return;
3320
- await this.ensureInit();
3321
- for (const event of events) {
3322
- const scrubbed = this.scrubEvent(event);
3323
- this.observeForSummary(scrubbed);
3324
- this.writeBuffer.push(scrubbed);
3325
- }
3326
- if (this.writeBuffer.length >= _FileSessionWriter.FLUSH_SIZE) {
3327
- if (this.flushTimer) {
3328
- clearTimeout(this.flushTimer);
3329
- this.flushTimer = null;
3330
- }
3331
- await this.flushBuffer();
3332
- } else {
3333
- this.scheduleFlush();
3294
+ /** Append a tombstone entry for a deleted session. */
3295
+ async writeTombstone(id) {
3296
+ try {
3297
+ await ensureDir(this.dir);
3298
+ const line = JSON.stringify({ action: "delete", id }) + "\n";
3299
+ await fsp3.appendFile(this.indexFile, line, "utf8");
3300
+ this._indexCache = null;
3301
+ this.invalidateShardManifestBySessionId(id);
3302
+ this.indexAppendCount++;
3303
+ } catch {
3334
3304
  }
3335
3305
  }
3336
3306
  /**
3337
- * Flush buffered events to disk immediately. Critical events
3338
- * (user_input, llm_response) call this so they survive SIGKILL/crash
3339
- * instead of sitting in the in-memory buffer for up to 500ms.
3340
- *
3341
- * Idempotent — cancels any pending timer and writes whatever has
3342
- * accumulated in the buffer. Safe to call even when the buffer
3343
- * is empty (no-op).
3307
+ * Compact the index: read all entries, drop tombstones, deduplicate
3308
+ * (keep latest per session), and rewrite. Atomic via temp+rename.
3344
3309
  */
3345
- async flush() {
3346
- if (this.flushTimer) {
3347
- clearTimeout(this.flushTimer);
3348
- this.flushTimer = null;
3310
+ async compactIndex() {
3311
+ const t0 = Date.now();
3312
+ let outcome = "success";
3313
+ let errorMsg;
3314
+ try {
3315
+ const entries = await this.readIndex();
3316
+ if (entries.length === 0) return;
3317
+ const tmp = `${this.indexFile}.compact.tmp`;
3318
+ const lines = entries.map((s) => JSON.stringify(s)).join("\n") + "\n";
3319
+ await fsp3.writeFile(tmp, lines, "utf8");
3320
+ await fsp3.rename(tmp, this.indexFile);
3321
+ this._indexCache = null;
3322
+ } catch (err) {
3323
+ outcome = "failure";
3324
+ errorMsg = toErrorMessage(err);
3325
+ } finally {
3326
+ this.emitWrite("~compact~", this.indexFile, "compact", outcome, Date.now() - t0, void 0, errorMsg);
3349
3327
  }
3350
- await this.flushBuffer();
3351
3328
  }
3352
- /** Schedule a deferred flush. No-op if a timer is already pending. */
3353
- scheduleFlush() {
3354
- if (this.flushTimer) return;
3355
- this.flushTimer = setTimeout(() => {
3356
- this.flushTimer = null;
3357
- this.flushBuffer().catch(() => {
3358
- });
3359
- }, _FileSessionWriter.FLUSH_INTERVAL_MS);
3329
+ /**
3330
+ * Read the index file and return deduplicated session summaries.
3331
+ * Entries with a matching tombstone are filtered out.
3332
+ * Returns empty array when the index doesn't exist or is corrupt.
3333
+ */
3334
+ async readIndex() {
3335
+ let stat8;
3336
+ try {
3337
+ const s = await fsp3.stat(this.indexFile);
3338
+ stat8 = { mtimeMs: s.mtimeMs, size: s.size };
3339
+ } catch {
3340
+ this._indexCache = null;
3341
+ return [];
3342
+ }
3343
+ if (this._indexCache !== null && this._indexCache.mtimeMs === stat8.mtimeMs && this._indexCache.size === stat8.size) {
3344
+ return [...this._indexCache.summaries];
3345
+ }
3346
+ let raw;
3347
+ try {
3348
+ raw = await fsp3.readFile(this.indexFile, "utf8");
3349
+ } catch {
3350
+ this._indexCache = null;
3351
+ return [];
3352
+ }
3353
+ const deleted = /* @__PURE__ */ new Set();
3354
+ const seen = /* @__PURE__ */ new Map();
3355
+ for (const line of raw.split("\n")) {
3356
+ if (!line.trim()) continue;
3357
+ try {
3358
+ const entry = JSON.parse(line);
3359
+ if (entry.action === "delete" && entry.id) {
3360
+ deleted.add(entry.id);
3361
+ seen.delete(entry.id);
3362
+ continue;
3363
+ }
3364
+ if (entry.id && !deleted.has(entry.id)) {
3365
+ seen.set(entry.id, entry);
3366
+ }
3367
+ } catch {
3368
+ }
3369
+ }
3370
+ const summaries = Array.from(seen.values());
3371
+ this._indexCache = { ...stat8, summaries };
3372
+ return [...summaries];
3360
3373
  }
3361
3374
  /**
3362
- * Flush all buffered events to disk as a single appendFile call.
3363
- * Errors use the same throttled-warning pattern the old per-event
3364
- * append path used — one warning every 5s with a suppressed count.
3365
- * On failure the buffer is cleared (events are best-effort, same as
3366
- * the old per-event path where a failed write was silently dropped).
3375
+ * Rebuild the index from disk by scanning all sessions and writing a
3376
+ * fresh _index.jsonl. Useful after manual cleanup or index corruption.
3367
3377
  */
3368
- async flushBuffer() {
3369
- if (this.writeBuffer.length === 0) return;
3370
- const eventCount = this.writeBuffer.length;
3371
- const batch = this.writeBuffer.map((e) => JSON.stringify(e)).join("\n") + "\n";
3372
- this.writeBuffer = [];
3373
- const t0 = Date.now();
3374
- let outcome = "success";
3375
- let errorMsg;
3376
- try {
3377
- await this.enqueueWrite(batch);
3378
- } catch (err) {
3379
- outcome = "failure";
3380
- errorMsg = toErrorMessage(err);
3381
- this.appendFailCount += eventCount;
3382
- const now = Date.now();
3383
- if (now - this.lastAppendWarnAt > 5e3) {
3384
- const suppressed = this.appendFailCount - 1;
3385
- const tail = suppressed > 0 ? ` (+${suppressed} suppressed)` : "";
3386
- console.warn(
3387
- "[session] flush failed:",
3388
- toErrorMessage(err),
3389
- tail
3390
- );
3391
- this.lastAppendWarnAt = now;
3392
- this.appendFailCount = 0;
3393
- }
3394
- } finally {
3395
- this.events?.emit("storage.write", {
3396
- sessionId: this.id,
3397
- store: "session",
3398
- filePath: this.filePath,
3399
- operation: "flush",
3400
- outcome,
3401
- durationMs: Date.now() - t0,
3402
- ...errorMsg !== void 0 ? { error: errorMsg } : {},
3403
- ...eventCount !== void 0 ? { eventCount } : {},
3404
- ...this.traceId !== void 0 ? { traceId: this.traceId } : {}
3405
- });
3406
- }
3378
+ async rebuildIndex() {
3379
+ const ids = await this.collectSessionIds(this.dir);
3380
+ const summaries = await Promise.all(ids.map((id) => this.summaryFor(id).catch(() => null)));
3381
+ const valid = summaries.filter((s) => s !== null);
3382
+ const tmp = `${this.indexFile}.tmp`;
3383
+ const lines = valid.map((s) => JSON.stringify(s)).join("\n") + "\n";
3384
+ await fsp3.writeFile(tmp, lines, "utf8");
3385
+ await fsp3.rename(tmp, this.indexFile);
3386
+ this._indexCache = null;
3387
+ return valid.length;
3407
3388
  }
3408
- observeForSummary(event) {
3409
- if (event.type === "llm_response") {
3410
- for (const block of event.content) {
3411
- if (block.type === "tool_use") this.openToolUses.add(block.id);
3389
+ async listFromDirectoryScan(limit) {
3390
+ const shardKeys = await this.collectShardKeys();
3391
+ const shardEntries = await mapWithConcurrency(
3392
+ shardKeys,
3393
+ _DefaultSessionStore.LIST_SCAN_CONCURRENCY,
3394
+ async (shardKey) => await this.readOrBuildShardManifest(shardKey)
3395
+ );
3396
+ const out = [];
3397
+ for (const entry of shardEntries) {
3398
+ for (const summary of entry.summaries) {
3399
+ out.push({ summary, needsBackfill: false });
3412
3400
  }
3413
3401
  }
3414
- if (event.type === "tool_use") {
3415
- this.openToolUses.add(event.id);
3416
- } else if (event.type === "tool_call_start") {
3417
- this.toolCallCount++;
3418
- this.toolBreakdown[event.name] = (this.toolBreakdown[event.name] ?? 0) + 1;
3419
- } else if (event.type === "tool_result") {
3420
- this.openToolUses.delete(event.id);
3421
- if (event.isError) {
3422
- this.toolErrorCount++;
3423
- this.outcome = "error";
3424
- }
3425
- } else if (event.type === "file_snapshot") {
3426
- this.fileChangeCount += event.files.length;
3427
- } else if (event.type === "compaction") {
3428
- this.compactionCount++;
3402
+ out.sort((a, b) => compareSessionSummaries(a.summary, b.summary));
3403
+ const selected = out.slice(0, limit);
3404
+ const summaries = await mapWithConcurrency(
3405
+ selected,
3406
+ Math.min(_DefaultSessionStore.LIST_SCAN_CONCURRENCY, Math.max(1, limit)),
3407
+ async (candidate) => candidate.summary
3408
+ );
3409
+ return summaries.filter((s) => s !== null);
3410
+ }
3411
+ async collectShardKeys() {
3412
+ let entries;
3413
+ try {
3414
+ entries = await fsp3.readdir(this.dir, { withFileTypes: true });
3415
+ } catch {
3416
+ return [""];
3429
3417
  }
3430
- if (event.type === "error" || event.type === "provider_error") {
3431
- this.outcome = "error";
3418
+ const shardKeys = [""];
3419
+ for (const entry of entries) {
3420
+ if (entry.name.startsWith(".") && entry.name !== ".wrongstack") continue;
3421
+ if (entry.name === "shared" || entry.name === "subagents" || entry.name === "attachments") continue;
3422
+ if (entry.isDirectory()) shardKeys.push(entry.name);
3432
3423
  }
3433
- if (event.type === "user_input" && this.summary.title === "(empty session)") {
3434
- this.summary = { ...this.summary, title: userInputTitle(event.content) };
3435
- } else if (event.type === "llm_response") {
3436
- this.tokenIn += event.usage.input;
3437
- this.tokenOut += event.usage.output;
3438
- this.summary = { ...this.summary, tokenTotal: this.tokenIn + this.tokenOut };
3439
- } else if (event.type === "session_end") {
3440
- const total = event.usage.input + event.usage.output;
3441
- if (total > 0) this.summary = { ...this.summary, tokenTotal: total };
3442
- } else if (event.type === "in_flight_start") {
3443
- this.iterationCount++;
3424
+ return shardKeys;
3425
+ }
3426
+ async readOrBuildShardManifest(shardKey) {
3427
+ const cached = this.shardManifestCache.get(shardKey);
3428
+ if (cached) return cached;
3429
+ const manifestPath = this.shardManifestPath(shardKey);
3430
+ try {
3431
+ const raw = await fsp3.readFile(manifestPath, "utf8");
3432
+ const parsed = JSON.parse(raw);
3433
+ const entry2 = {
3434
+ summaries: Array.isArray(parsed.summaries) ? parsed.summaries : [],
3435
+ ids: Array.isArray(parsed.ids) ? parsed.ids : []
3436
+ };
3437
+ this.shardManifestCache.set(shardKey, entry2);
3438
+ return entry2;
3439
+ } catch {
3444
3440
  }
3441
+ const refs = await this.collectSessionFilesInShard(shardKey);
3442
+ const candidates = await mapWithConcurrency(
3443
+ refs,
3444
+ _DefaultSessionStore.LIST_SCAN_CONCURRENCY,
3445
+ async (ref) => {
3446
+ const manifest = await this.readSummaryManifest(ref.id);
3447
+ if (manifest) return { summary: manifest, needsBackfill: false };
3448
+ const summary = await this.summaryHeaderFor(ref);
3449
+ if (!summary) return null;
3450
+ const hydrated = await this.summaryFor(summary.id).catch(() => summary);
3451
+ return { summary: hydrated, needsBackfill: false };
3452
+ }
3453
+ );
3454
+ const summaries = candidates.filter((candidate) => candidate !== null).map((candidate) => candidate.summary);
3455
+ summaries.sort(compareSessionSummaries);
3456
+ const entry = { summaries, ids: summaries.map((summary) => summary.id) };
3457
+ this.shardManifestCache.set(shardKey, entry);
3458
+ await atomicWrite(manifestPath, JSON.stringify(entry), { mode: 384 }).catch(() => void 0);
3459
+ return entry;
3460
+ }
3461
+ async collectSessionFilesInShard(shardKey) {
3462
+ const dir = shardKey ? path4.join(this.dir, shardKey) : this.dir;
3463
+ const entries = await this.collectSessionFiles(dir, shardKey);
3464
+ return shardKey ? entries.filter((entry) => entry.id.startsWith(`${shardKey}/`)) : entries.filter((entry) => !entry.id.includes("/"));
3445
3465
  }
3446
- async close() {
3447
- if (this.closePromise) return this.closePromise;
3448
- this.closePromise = this.doClose();
3449
- return this.closePromise;
3466
+ async collectSessionFiles(dir, prefix = "", depth = 0) {
3467
+ let entries;
3468
+ try {
3469
+ entries = await fsp3.readdir(dir, { withFileTypes: true });
3470
+ } catch {
3471
+ return [];
3472
+ }
3473
+ const dirEntries = [];
3474
+ const files = [];
3475
+ for (const entry of entries) {
3476
+ if (entry.name.startsWith(".") && entry.name !== ".wrongstack") continue;
3477
+ if (entry.name === "shared" || entry.name === "subagents" || entry.name === "attachments")
3478
+ continue;
3479
+ if (entry.isDirectory()) {
3480
+ dirEntries.push(entry);
3481
+ } else if (entry.isFile() && entry.name.endsWith(".jsonl")) {
3482
+ if (entry.name === "_index.jsonl") continue;
3483
+ const base = entry.name.replace(/\.jsonl$/, "");
3484
+ const id = prefix ? `${prefix}/${base}` : base;
3485
+ files.push({ id, filePath: path4.join(dir, entry.name) });
3486
+ }
3487
+ }
3488
+ const childFileArrays = await Promise.all(
3489
+ dirEntries.map((entry) => {
3490
+ const childPrefix = depth === 0 ? entry.name : `${prefix}/${entry.name}`;
3491
+ return this.collectSessionFiles(path4.join(dir, entry.name), childPrefix, depth + 1);
3492
+ })
3493
+ );
3494
+ return [...childFileArrays.flat(), ...files];
3450
3495
  }
3451
- async doClose() {
3452
- this.closed = true;
3453
- if (this.flushTimer) {
3454
- clearTimeout(this.flushTimer);
3455
- this.flushTimer = null;
3496
+ /** Recursively collect session IDs from date-shard subdirectories.
3497
+ * IDs include the date-prefix path (e.g. "2026-06-06/17-46-57Z_…").
3498
+ * Skips `.jsonl`/`.summary.json` root files, dot-files, and
3499
+ * sub-directories that belong to fleet/subagent sessions. */
3500
+ async collectSessionIds(dir, prefix = "", depth = 0) {
3501
+ let entries;
3502
+ try {
3503
+ entries = await fsp3.readdir(dir, { withFileTypes: true });
3504
+ } catch {
3505
+ return [];
3456
3506
  }
3457
- await this.flushBuffer();
3458
- await this.writeChain;
3459
- this.summary = {
3460
- ...this.summary,
3461
- endedAt: (/* @__PURE__ */ new Date()).toISOString(),
3462
- iterationCount: this.iterationCount,
3463
- toolCallCount: this.toolCallCount,
3464
- toolErrorCount: this.toolErrorCount,
3465
- fileChangeCount: this.fileChangeCount,
3466
- compactionCount: this.compactionCount > 0 ? this.compactionCount : void 0,
3467
- toolBreakdown: { ...this.toolBreakdown },
3468
- outcome: this.outcome ?? "completed"
3469
- };
3470
- if (this.manifestFile) {
3471
- const t0 = Date.now();
3472
- let outcome = "success";
3473
- let errorMsg;
3474
- try {
3475
- await atomicWrite(this.manifestFile, JSON.stringify(this.summary), { mode: 384 });
3476
- } catch (err) {
3477
- outcome = "failure";
3478
- errorMsg = toErrorMessage(err);
3479
- } finally {
3480
- this.events?.emit("storage.write", {
3481
- sessionId: this.id,
3482
- store: "session",
3483
- filePath: this.manifestFile,
3484
- operation: "close",
3485
- outcome,
3486
- durationMs: Date.now() - t0,
3487
- ...errorMsg !== void 0 ? { error: errorMsg } : {},
3488
- ...this.traceId !== void 0 ? { traceId: this.traceId } : {}
3489
- });
3507
+ const dirEntries = [];
3508
+ const fileIds = [];
3509
+ for (const entry of entries) {
3510
+ if (entry.name.startsWith(".") && entry.name !== ".wrongstack") continue;
3511
+ if (entry.name === "shared" || entry.name === "subagents" || entry.name === "attachments")
3512
+ continue;
3513
+ if (entry.isDirectory()) {
3514
+ dirEntries.push(entry);
3515
+ } else if (entry.isFile() && entry.name.endsWith(".jsonl")) {
3516
+ if (entry.name === "_index.jsonl") continue;
3517
+ const base = entry.name.replace(/\.jsonl$/, "");
3518
+ fileIds.push(prefix ? `${prefix}/${base}` : base);
3490
3519
  }
3491
3520
  }
3492
- const idxT0 = Date.now();
3493
- let idxOutcome = "success";
3494
- let idxError;
3521
+ const childIdArrays = await Promise.all(
3522
+ dirEntries.map((entry) => {
3523
+ const childPrefix = depth === 0 ? entry.name : `${prefix}/${entry.name}`;
3524
+ return this.collectSessionIds(path4.join(dir, entry.name), childPrefix, depth + 1);
3525
+ })
3526
+ );
3527
+ return [...childIdArrays.flat(), ...fileIds];
3528
+ }
3529
+ async summaryFor(id) {
3530
+ const manifest = this.sessionPath(id, ".summary.json");
3531
+ const t0 = Date.now();
3532
+ let outcome = "success";
3533
+ let errorMsg;
3534
+ const fromManifest = await this.readSummaryManifest(id, t0);
3535
+ if (fromManifest) return fromManifest;
3495
3536
  try {
3496
- await this.onCloseCb?.(this.summary);
3497
- } catch (err) {
3498
- idxOutcome = "failure";
3499
- idxError = toErrorMessage(err);
3500
- } finally {
3501
- this.events?.emit("storage.write", {
3502
- sessionId: this.summary.id,
3503
- store: "session",
3504
- filePath: this.filePath,
3505
- operation: "index_append",
3506
- outcome: idxOutcome,
3507
- durationMs: Date.now() - idxT0,
3508
- ...idxError !== void 0 ? { error: idxError } : {},
3509
- ...this.traceId !== void 0 ? { traceId: this.traceId } : {}
3537
+ const full = this.sessionPath(id, ".jsonl");
3538
+ const stat8 = await fsp3.stat(full);
3539
+ const summary = await this.summarize(id, stat8.mtime.toISOString());
3540
+ await atomicWrite(manifest, JSON.stringify(summary), { mode: 384 }).catch((err) => {
3541
+ const msg = toErrorMessage(err);
3542
+ this.emitError(id, manifest, "summary_fallback", msg, true);
3543
+ console.warn(JSON.stringify({
3544
+ level: "warn",
3545
+ event: "session_store.manifest_write_failed",
3546
+ sessionId: id,
3547
+ message: msg,
3548
+ timestamp: (/* @__PURE__ */ new Date()).toISOString()
3549
+ }));
3510
3550
  });
3551
+ outcome = "failure";
3552
+ errorMsg = "summary fallback \xE2\u20AC\u201D manifest rebuilt";
3553
+ this.emitRead(id, manifest, "summary", outcome, Date.now() - t0, errorMsg);
3554
+ return summary;
3555
+ } catch (err) {
3556
+ outcome = "failure";
3557
+ errorMsg = toErrorMessage(err);
3558
+ this.emitRead(id, manifest, "summary", outcome, Date.now() - t0, errorMsg);
3559
+ return {
3560
+ id,
3561
+ title: "(damaged)",
3562
+ startedAt: (/* @__PURE__ */ new Date()).toISOString(),
3563
+ model: "unknown",
3564
+ provider: "unknown",
3565
+ tokenTotal: 0
3566
+ };
3511
3567
  }
3568
+ }
3569
+ async readSummaryManifest(id, startTime = Date.now()) {
3570
+ const manifest = this.sessionPath(id, ".summary.json");
3512
3571
  try {
3513
- await this.handle.close();
3572
+ const raw = await fsp3.readFile(manifest, "utf8");
3573
+ this.emitRead(id, manifest, "summary", "success", Date.now() - startTime);
3574
+ return JSON.parse(raw);
3514
3575
  } catch {
3576
+ return null;
3515
3577
  }
3516
3578
  }
3517
- async writeCheckpoint(promptIndex, promptPreview) {
3518
- const fileCount = this.pendingFileSnapshots.length;
3519
- if (fileCount > 0) {
3520
- await this.writeFileSnapshot(promptIndex, [...this.pendingFileSnapshots]);
3521
- this.pendingFileSnapshots = [];
3579
+ async summaryHeaderFor(ref) {
3580
+ let mtime = (/* @__PURE__ */ new Date(0)).toISOString();
3581
+ try {
3582
+ const stat8 = await fsp3.stat(ref.filePath);
3583
+ if (!stat8.isFile()) {
3584
+ return {
3585
+ id: ref.id,
3586
+ title: "(damaged)",
3587
+ startedAt: stat8.mtime.toISOString(),
3588
+ model: "unknown",
3589
+ provider: "unknown",
3590
+ tokenTotal: 0
3591
+ };
3592
+ }
3593
+ mtime = stat8.mtime.toISOString();
3594
+ } catch {
3595
+ return null;
3596
+ }
3597
+ try {
3598
+ for await (const event of this.iterSessionEvents(ref.filePath)) {
3599
+ if (event.type === "session_start") {
3600
+ return {
3601
+ id: ref.id,
3602
+ title: "(empty session)",
3603
+ startedAt: event.ts,
3604
+ model: event.model ?? "unknown",
3605
+ provider: event.provider ?? "unknown",
3606
+ tokenTotal: 0
3607
+ };
3608
+ }
3609
+ }
3610
+ return {
3611
+ id: ref.id,
3612
+ title: "(empty session)",
3613
+ startedAt: (/* @__PURE__ */ new Date(0)).toISOString(),
3614
+ model: "unknown",
3615
+ provider: "unknown",
3616
+ tokenTotal: 0
3617
+ };
3618
+ } catch {
3619
+ return {
3620
+ id: ref.id,
3621
+ title: "(damaged)",
3622
+ startedAt: mtime,
3623
+ model: "unknown",
3624
+ provider: "unknown",
3625
+ tokenTotal: 0
3626
+ };
3522
3627
  }
3523
- await this.append({
3524
- type: "checkpoint",
3525
- ts: (/* @__PURE__ */ new Date()).toISOString(),
3526
- promptIndex,
3527
- promptPreview
3528
- });
3529
- this.events?.emit("checkpoint.written", {
3530
- promptIndex,
3531
- promptPreview,
3532
- ts: (/* @__PURE__ */ new Date()).toISOString(),
3533
- fileCount
3534
- });
3535
- }
3536
- async writeFileSnapshot(promptIndex, files) {
3537
- await this.append({
3538
- type: "file_snapshot",
3539
- ts: (/* @__PURE__ */ new Date()).toISOString(),
3540
- promptIndex,
3541
- files
3542
- });
3543
3628
  }
3544
3629
  /**
3545
- * Truncate the session file to the checkpoint with the given promptIndex,
3546
- * removing all events that follow it. Uses a single-pass byte-offset scan
3547
- * so post-checkpoint content is never read or parsed — O(1) memory instead
3548
- * of O(N) JSON.parse calls over the full file.
3630
+ * Delete a session and all associated files: JSONL, summary, plan/todos
3631
+ * sidecars, and the session directory (fleet.json, shared/, subagents/).
3632
+ *
3633
+ * Individual file deletions are best-effort (logged as structured warnings),
3634
+ * but a tombstone is always written so readIndex() filters this session out.
3635
+ * If the session directory itself can't be removed, the error is surfaced
3636
+ * to the caller so prune() can report it.
3549
3637
  */
3550
- async truncateToCheckpoint(targetPromptIndex) {
3551
- if (!this.filePath) return 0;
3552
- if (this.flushTimer) {
3553
- clearTimeout(this.flushTimer);
3554
- this.flushTimer = null;
3555
- }
3556
- await this.flushBuffer();
3557
- await this.writeChain;
3558
- const CHUNK_SIZE = 65536;
3559
- let fd;
3560
- let fileOffset = 0;
3561
- let lineStartOffset = 0;
3562
- let checkpointByteOffset = -1;
3563
- let removedCount = 0;
3564
- let targetCheckpointSeen = false;
3565
- try {
3566
- fd = await fsp2.open(this.filePath, "r", 384);
3567
- while (true) {
3568
- const buf = Buffer.alloc(CHUNK_SIZE);
3569
- const { bytesRead } = await fd.read(buf, 0, CHUNK_SIZE, fileOffset);
3570
- if (bytesRead === 0) break;
3571
- let chunkPos = 0;
3572
- while (chunkPos < bytesRead) {
3573
- const idx = buf.indexOf("\n", chunkPos);
3574
- if (idx === -1) {
3575
- lineStartOffset = fileOffset + chunkPos;
3576
- break;
3577
- }
3578
- if (checkpointByteOffset !== -1) {
3579
- removedCount++;
3580
- } else {
3581
- const lineBytes = buf.subarray(chunkPos, idx);
3582
- const line = new TextDecoder("utf-8", { fatal: false }).decode(lineBytes);
3583
- if (line.trim()) {
3584
- try {
3585
- const event = JSON.parse(line);
3586
- if (event.type === "checkpoint") {
3587
- if (event.promptIndex === targetPromptIndex) {
3588
- checkpointByteOffset = lineStartOffset;
3589
- targetCheckpointSeen = true;
3590
- } else if (event.promptIndex !== void 0 && event.promptIndex > targetPromptIndex) {
3591
- checkpointByteOffset = lineStartOffset;
3592
- }
3593
- } else if (targetCheckpointSeen && event.promptIndex !== void 0 && event.promptIndex > targetPromptIndex) {
3594
- removedCount++;
3595
- } else if (targetCheckpointSeen && event.promptIndex === void 0) {
3596
- removedCount++;
3597
- } else if (!targetCheckpointSeen && event.promptIndex === void 0) {
3598
- removedCount++;
3599
- } else if (!targetCheckpointSeen && event.promptIndex !== void 0 && event.promptIndex > targetPromptIndex) {
3600
- removedCount++;
3601
- }
3602
- } catch {
3603
- }
3604
- }
3605
- }
3606
- chunkPos = idx + 1;
3607
- lineStartOffset = fileOffset + chunkPos;
3608
- }
3609
- fileOffset += bytesRead;
3610
- if (chunkPos >= bytesRead) {
3611
- lineStartOffset = fileOffset;
3638
+ async deleteSession(id) {
3639
+ const jsonlPath = this.sessionPath(id, ".jsonl");
3640
+ const summaryPath = this.sessionPath(id, ".summary.json");
3641
+ const shardDir = path4.dirname(path4.join(this.dir, id));
3642
+ const base = path4.basename(id);
3643
+ const sessDir = path4.join(shardDir, base);
3644
+ const deletions = [
3645
+ fsp3.unlink(jsonlPath),
3646
+ fsp3.unlink(summaryPath),
3647
+ fsp3.unlink(path4.join(shardDir, `${base}.plan.json`)),
3648
+ fsp3.unlink(path4.join(shardDir, `${base}.todos.json`))
3649
+ ];
3650
+ const results = await Promise.allSettled(deletions);
3651
+ for (const r of results) {
3652
+ if (r.status === "rejected") {
3653
+ const msg = r.reason instanceof Error ? r.reason.message : String(r.reason);
3654
+ if (r.reason?.code !== "ENOENT") {
3655
+ console.warn(JSON.stringify({
3656
+ level: "warn",
3657
+ event: "session_store.delete_failed",
3658
+ sessionId: id,
3659
+ message: msg,
3660
+ timestamp: (/* @__PURE__ */ new Date()).toISOString()
3661
+ }));
3612
3662
  }
3613
3663
  }
3614
- } finally {
3615
- await fd?.close();
3616
3664
  }
3617
- if (checkpointByteOffset === -1) return 0;
3618
- await this.writeChain;
3619
- await this.handle.close();
3620
- const tmpPath = `${this.filePath}.rewind.tmp`;
3621
- const src = await fsp2.open(this.filePath, "r", 384);
3665
+ await fsp3.rm(sessDir, { recursive: true, force: true }).catch((err) => {
3666
+ console.warn(JSON.stringify({
3667
+ level: "warn",
3668
+ event: "session_store.rmdir_failed",
3669
+ sessionId: id,
3670
+ message: toErrorMessage(err),
3671
+ timestamp: (/* @__PURE__ */ new Date()).toISOString()
3672
+ }));
3673
+ });
3674
+ await this.writeTombstone(id);
3675
+ }
3676
+ async delete(id) {
3677
+ await this.deleteSession(id);
3678
+ }
3679
+ async prune(maxAgeDays = 30) {
3680
+ const cutoff = Date.now() - maxAgeDays * 864e5;
3681
+ let deleted = 0;
3682
+ let activeSessionId = null;
3622
3683
  try {
3623
- const statResult = await src.stat();
3624
- const totalSize = statResult.size;
3625
- const prefixBytes = checkpointByteOffset;
3626
- let newlineAfterCheckpoint = prefixBytes;
3627
- if (prefixBytes < totalSize) {
3628
- const probeBuf = Buffer.alloc(Math.min(CHUNK_SIZE, totalSize - prefixBytes));
3629
- const { bytesRead: probeRead } = await src.read(probeBuf, 0, probeBuf.length, prefixBytes);
3630
- if (probeRead > 0) {
3631
- const nl = probeBuf.indexOf("\n");
3632
- newlineAfterCheckpoint = nl !== -1 ? prefixBytes + nl + 1 : totalSize;
3633
- }
3634
- } else {
3635
- newlineAfterCheckpoint = totalSize;
3636
- }
3637
- const writeFd = await fsp2.open(tmpPath, "w", 384);
3684
+ const raw = await fsp3.readFile(path4.join(this.dir, "active.json"), "utf8");
3685
+ const active = JSON.parse(raw);
3686
+ activeSessionId = active.sessionId ?? null;
3687
+ } catch {
3688
+ }
3689
+ const isPrunableJsonl = (name) => name.endsWith(".jsonl") && name !== "_index.jsonl" && name !== "_mailbox.jsonl" && !name.endsWith(".replay.jsonl") && !name.endsWith(".audit.jsonl");
3690
+ const pruneFile = async (dir, name, prefix) => {
3691
+ const jsonlPath = path4.join(dir, name);
3638
3692
  try {
3639
- let readOffset = 0;
3640
- while (readOffset < newlineAfterCheckpoint) {
3641
- const toCopy = Math.min(CHUNK_SIZE, newlineAfterCheckpoint - readOffset);
3642
- const copyBuf = Buffer.alloc(toCopy);
3643
- const { bytesRead: r } = await src.read(copyBuf, 0, toCopy, readOffset);
3644
- if (r === 0) break;
3645
- await writeFd.write(copyBuf, 0, r);
3646
- readOffset += r;
3647
- }
3648
- const raw = await fsp2.readFile(this.filePath);
3649
- const tail = raw.subarray(newlineAfterCheckpoint).toString("utf8");
3650
- for (const line of tail.split("\n")) {
3651
- if (!line.trim()) continue;
3652
- try {
3653
- JSON.parse(line);
3654
- } catch {
3655
- await writeFd.write(`${line}
3656
- `, void 0, "utf8");
3657
- }
3693
+ const stat8 = await fsp3.stat(jsonlPath);
3694
+ if (stat8.mtimeMs >= cutoff) return;
3695
+ } catch {
3696
+ return;
3697
+ }
3698
+ const base = name.replace(/\.jsonl$/, "");
3699
+ const id = prefix ? `${prefix}/${base}` : base;
3700
+ if (activeSessionId && id === activeSessionId) return;
3701
+ await this.deleteSession(id);
3702
+ deleted++;
3703
+ };
3704
+ const entries = await fsp3.readdir(this.dir, { withFileTypes: true }).catch(() => []);
3705
+ for (const entry of entries) {
3706
+ if (entry.isFile()) {
3707
+ if (isPrunableJsonl(entry.name)) await pruneFile(this.dir, entry.name, "");
3708
+ continue;
3709
+ }
3710
+ if (!entry.isDirectory()) continue;
3711
+ const dateDir = path4.join(this.dir, entry.name);
3712
+ const files = await fsp3.readdir(dateDir, { withFileTypes: true }).catch(() => []);
3713
+ for (const file of files) {
3714
+ if (!file.isFile() || !isPrunableJsonl(file.name)) continue;
3715
+ await pruneFile(dateDir, file.name, entry.name);
3716
+ }
3717
+ }
3718
+ if (deleted > 0) {
3719
+ await this.compactIndex().catch(() => void 0);
3720
+ }
3721
+ for (const entry of entries) {
3722
+ if (!entry.isDirectory()) continue;
3723
+ const dateDir = path4.join(this.dir, entry.name);
3724
+ try {
3725
+ const remaining = await fsp3.readdir(dateDir);
3726
+ if (remaining.length === 0) {
3727
+ await fsp3.rmdir(dateDir).catch(() => void 0);
3658
3728
  }
3659
- } finally {
3660
- await writeFd.close();
3729
+ } catch {
3661
3730
  }
3662
- await src.close();
3663
- await fsp2.rename(tmpPath, this.filePath);
3664
- this.handle = await fsp2.open(this.filePath, "a", 384);
3665
- } catch (err) {
3666
- await fsp2.unlink(tmpPath).catch(() => void 0);
3667
- this.handle = await fsp2.open(this.filePath, "a", 384).catch(() => this.handle);
3668
- throw err;
3669
3731
  }
3670
- await this.append({
3671
- type: "rewound",
3672
- ts: (/* @__PURE__ */ new Date()).toISOString(),
3673
- toPromptIndex: targetPromptIndex,
3674
- revertedFiles: []
3675
- });
3676
- this.events?.emit("session.rewound", {
3677
- toPromptIndex: targetPromptIndex,
3678
- revertedFiles: [],
3679
- removedEvents: removedCount
3680
- });
3681
- return removedCount;
3732
+ return deleted;
3682
3733
  }
3683
- async clearSession() {
3684
- if (!this.filePath) return;
3685
- if (this.flushTimer) {
3686
- clearTimeout(this.flushTimer);
3687
- this.flushTimer = null;
3688
- }
3689
- this.writeBuffer = [];
3690
- await this.writeChain;
3734
+ async clearHistory(id) {
3735
+ await this.ensureShardDir(id);
3736
+ const file = this.sessionPath(id, ".jsonl");
3737
+ const meta = this.sessionPath(id, ".summary.json");
3691
3738
  const record = `${JSON.stringify({
3692
3739
  type: "session_start",
3693
3740
  ts: (/* @__PURE__ */ new Date()).toISOString(),
3694
- id: this.id,
3695
- model: this.meta.model ?? "unknown",
3696
- provider: this.meta.provider ?? "unknown"
3741
+ id,
3742
+ model: "unknown",
3743
+ provider: "unknown"
3697
3744
  })}
3698
3745
  `;
3699
- await fsp2.writeFile(this.filePath, record, "utf8");
3746
+ await fsp3.writeFile(file, record, "utf8");
3747
+ await fsp3.unlink(meta).catch(() => void 0);
3700
3748
  }
3701
- /**
3702
- * Idea #1 — write an in-flight marker. The agent loop should call
3703
- * this at the start of each long-running operation; a matching
3704
- * `clearInFlightMarker` follows on clean exit. A stale marker
3705
- * (no end) is what `SessionRecovery.detectStale` looks for.
3706
- */
3707
- async writeInFlightMarker(context) {
3708
- if (!context || context.length > 500) {
3709
- throw new Error("In-flight context must be 1..500 chars");
3749
+ async summarize(id, mtime) {
3750
+ try {
3751
+ const file = this.sessionPath(id, ".jsonl");
3752
+ let title = "(empty session)";
3753
+ let startedAt = (/* @__PURE__ */ new Date(0)).toISOString();
3754
+ let endedAt;
3755
+ let model = "unknown";
3756
+ let provider = "unknown";
3757
+ let tokenIn = 0;
3758
+ let tokenOut = 0;
3759
+ let iterationCount = 0;
3760
+ let toolCallCount = 0;
3761
+ let toolErrorCount = 0;
3762
+ let fileChangeCount = 0;
3763
+ const toolBreakdown = {};
3764
+ let outcome;
3765
+ let lastEventType;
3766
+ let hasError = false;
3767
+ let sawStart = false;
3768
+ for await (const e of this.iterSessionEvents(file)) {
3769
+ lastEventType = e.type;
3770
+ if (e.type === "session_start") {
3771
+ if (!sawStart) {
3772
+ sawStart = true;
3773
+ startedAt = e.ts;
3774
+ model = e.model ?? "unknown";
3775
+ provider = e.provider ?? "unknown";
3776
+ }
3777
+ } else if (e.type === "session_end") {
3778
+ endedAt = e.ts;
3779
+ } else if (e.type === "user_input") {
3780
+ if (title === "(empty session)") title = userInputTitle(e.content);
3781
+ } else if (e.type === "llm_response") {
3782
+ tokenIn += e.usage.input ?? 0;
3783
+ tokenOut += e.usage.output ?? 0;
3784
+ } else if (e.type === "in_flight_start") iterationCount++;
3785
+ else if (e.type === "tool_call_start") {
3786
+ toolCallCount++;
3787
+ toolBreakdown[e.name] = (toolBreakdown[e.name] ?? 0) + 1;
3788
+ } else if (e.type === "tool_result" && e.isError) toolErrorCount++;
3789
+ else if (e.type === "file_snapshot") fileChangeCount += e.files.length;
3790
+ else if (e.type === "error" || e.type === "provider_error") hasError = true;
3791
+ }
3792
+ if (lastEventType === "session_end") {
3793
+ outcome = "completed";
3794
+ } else if (lastEventType === "in_flight_start") {
3795
+ outcome = "aborted";
3796
+ } else if (hasError) {
3797
+ outcome = "error";
3798
+ }
3799
+ return {
3800
+ id,
3801
+ title,
3802
+ startedAt,
3803
+ endedAt,
3804
+ model,
3805
+ provider,
3806
+ tokenTotal: tokenIn + tokenOut,
3807
+ iterationCount: iterationCount > 0 ? iterationCount : void 0,
3808
+ toolCallCount: toolCallCount > 0 ? toolCallCount : void 0,
3809
+ toolErrorCount: toolErrorCount > 0 ? toolErrorCount : void 0,
3810
+ fileChangeCount: fileChangeCount > 0 ? fileChangeCount : void 0,
3811
+ toolBreakdown: Object.keys(toolBreakdown).length > 0 ? toolBreakdown : {},
3812
+ outcome
3813
+ };
3814
+ } catch {
3815
+ return {
3816
+ id,
3817
+ title: "(damaged)",
3818
+ startedAt: mtime,
3819
+ model: "unknown",
3820
+ provider: "unknown",
3821
+ tokenTotal: 0
3822
+ };
3710
3823
  }
3711
- await this.append({
3712
- type: "in_flight_start",
3713
- ts: (/* @__PURE__ */ new Date()).toISOString(),
3714
- context
3715
- });
3716
- this.events?.emit("in_flight.started", { context, ts: (/* @__PURE__ */ new Date()).toISOString() });
3717
3824
  }
3718
- /**
3719
- * Idea #1 — close the in-flight marker. Idempotent in spirit
3720
- * (you can call it after a successful iteration even if you
3721
- * didn't open one this round) — but the session log records
3722
- * every call so postmortem tooling can see "the agent finished
3723
- * cleanly X times, then died without finishing Y".
3724
- */
3725
- async clearInFlightMarker(reason) {
3726
- await this.append({
3727
- type: "in_flight_end",
3728
- ts: (/* @__PURE__ */ new Date()).toISOString(),
3729
- reason
3730
- });
3731
- this.events?.emit("in_flight.ended", { reason, ts: (/* @__PURE__ */ new Date()).toISOString() });
3825
+ async *iterSessionEvents(file) {
3826
+ const stream = createReadStream(file, { encoding: "utf8" });
3827
+ const lines = createInterface({ input: stream, crlfDelay: Infinity });
3828
+ try {
3829
+ for await (const line of lines) {
3830
+ if (!line.trim()) continue;
3831
+ try {
3832
+ const parsed = JSON.parse(line);
3833
+ if (parsed !== null && typeof parsed === "object" && typeof parsed.type === "string" && typeof parsed.ts === "string") {
3834
+ yield parsed;
3835
+ }
3836
+ } catch {
3837
+ }
3838
+ }
3839
+ } finally {
3840
+ lines.close();
3841
+ stream.destroy();
3842
+ }
3732
3843
  }
3733
3844
  };
3734
- function userInputTitle(content) {
3735
- const text = typeof content === "string" ? content : content.filter((b) => b.type === "text").map((b) => b.text).join(" ");
3736
- return (text || "(non-text input)").slice(0, 60);
3845
+ function extractToolCallEnds(events) {
3846
+ const result = [];
3847
+ for (const e of events) {
3848
+ if (e.type === "tool_call_end") {
3849
+ result.push({
3850
+ name: e.name,
3851
+ id: e.id,
3852
+ durationMs: e.durationMs,
3853
+ ok: e.ok ?? false,
3854
+ outputBytes: e.outputBytes,
3855
+ outputTokens: e.outputTokens,
3856
+ outputLines: e.outputLines
3857
+ });
3858
+ }
3859
+ }
3860
+ return result;
3737
3861
  }
3738
3862
  function compareSessionSummaries(a, b) {
3739
3863
  if (a.startedAt < b.startedAt) return 1;
3740
3864
  if (a.startedAt > b.startedAt) return -1;
3741
3865
  return a.id.localeCompare(b.id);
3742
3866
  }
3867
+ function matchesSessionFilter(s, criteria) {
3868
+ if (criteria.since && s.startedAt < criteria.since) return false;
3869
+ if (criteria.until && s.startedAt > criteria.until) return false;
3870
+ if (criteria.provider && s.provider !== criteria.provider) return false;
3871
+ if (criteria.model && s.model !== criteria.model) return false;
3872
+ if (criteria.minTokens !== void 0 && s.tokenTotal < criteria.minTokens) return false;
3873
+ if (criteria.titleContains) {
3874
+ const needle = criteria.titleContains.toLowerCase();
3875
+ if (!s.title.toLowerCase().includes(needle)) return false;
3876
+ }
3877
+ return true;
3878
+ }
3743
3879
  async function mapWithConcurrency(items, concurrency, fn) {
3744
3880
  if (items.length === 0) return [];
3745
3881
  const out = new Array(items.length);
@@ -3811,7 +3947,7 @@ var QueueStore = class {
3811
3947
  const t0 = Date.now();
3812
3948
  let raw;
3813
3949
  try {
3814
- raw = await fsp2.readFile(this.file, "utf8");
3950
+ raw = await fsp3.readFile(this.file, "utf8");
3815
3951
  } catch (err) {
3816
3952
  const code = err.code;
3817
3953
  if (code === "ENOENT") {
@@ -3892,7 +4028,7 @@ var QueueStore = class {
3892
4028
  async clear() {
3893
4029
  const t0 = Date.now();
3894
4030
  try {
3895
- await fsp2.unlink(this.file);
4031
+ await fsp3.unlink(this.file);
3896
4032
  this.events?.emit("storage.write", {
3897
4033
  sessionId: this.traceId ?? "~boot~",
3898
4034
  store: "queue",
@@ -3952,7 +4088,7 @@ var DefaultAttachmentStore = class {
3952
4088
  let spooledPath;
3953
4089
  let data = input.data;
3954
4090
  if (this.spoolDir && bytes >= this.spoolThreshold) {
3955
- await fsp2.mkdir(this.spoolDir, { recursive: true });
4091
+ await fsp3.mkdir(this.spoolDir, { recursive: true });
3956
4092
  spooledPath = path4.join(this.spoolDir, `${id}.bin`);
3957
4093
  await atomicWrite(spooledPath, input.data, {
3958
4094
  encoding: input.kind === "image" ? "base64" : "utf8"
@@ -4015,7 +4151,7 @@ var DefaultAttachmentStore = class {
4015
4151
  for (const att of this.items.values()) {
4016
4152
  if (att.path) toDelete.push(att.path);
4017
4153
  }
4018
- await Promise.all(toDelete.map((p) => fsp2.unlink(p).catch(() => void 0)));
4154
+ await Promise.all(toDelete.map((p) => fsp3.unlink(p).catch(() => void 0)));
4019
4155
  }
4020
4156
  this.items.clear();
4021
4157
  this.refs.length = 0;
@@ -4023,7 +4159,7 @@ var DefaultAttachmentStore = class {
4023
4159
  }
4024
4160
  async toBlock(att) {
4025
4161
  if (att.kind === "image") {
4026
- const data = att.data ?? (att.path ? await fsp2.readFile(att.path, { encoding: "base64" }) : "");
4162
+ const data = att.data ?? (att.path ? await fsp3.readFile(att.path, { encoding: "base64" }) : "");
4027
4163
  return {
4028
4164
  type: "image",
4029
4165
  source: {
@@ -4033,7 +4169,7 @@ var DefaultAttachmentStore = class {
4033
4169
  }
4034
4170
  };
4035
4171
  }
4036
- const raw = att.data ?? (att.path ? await fsp2.readFile(att.path, "utf8") : "");
4172
+ const raw = att.data ?? (att.path ? await fsp3.readFile(att.path, "utf8") : "");
4037
4173
  const label = att.meta.filename ? `<file path="${att.meta.filename}">` : "<pasted>";
4038
4174
  const close = att.meta.filename ? "</file>" : "</pasted>";
4039
4175
  return { type: "text", text: `${label}
@@ -4169,7 +4305,7 @@ var FileMemoryBackend = class {
4169
4305
  await ensureDir(path4.dirname(file));
4170
4306
  let existing = "";
4171
4307
  try {
4172
- existing = await fsp2.readFile(file, "utf8");
4308
+ existing = await fsp3.readFile(file, "utf8");
4173
4309
  } catch {
4174
4310
  }
4175
4311
  const id = `mem_${Date.now()}_${randomUUID().slice(0, 8)}`;
@@ -4186,7 +4322,7 @@ ${line}`;
4186
4322
  return withFileLock(file, async () => {
4187
4323
  let existing;
4188
4324
  try {
4189
- existing = await fsp2.readFile(file, "utf8");
4325
+ existing = await fsp3.readFile(file, "utf8");
4190
4326
  } catch {
4191
4327
  return 0;
4192
4328
  }
@@ -4222,7 +4358,7 @@ ${line}`;
4222
4358
  async readAll(scope, filePath) {
4223
4359
  const file = this.resolveFile(filePath, scope);
4224
4360
  try {
4225
- return await fsp2.readFile(file, "utf8");
4361
+ return await fsp3.readFile(file, "utf8");
4226
4362
  } catch {
4227
4363
  return "";
4228
4364
  }
@@ -4257,7 +4393,7 @@ ${line}`;
4257
4393
  const file = this.resolveFile(filePath, scope);
4258
4394
  let existing;
4259
4395
  try {
4260
- existing = await fsp2.readFile(file, "utf8");
4396
+ existing = await fsp3.readFile(file, "utf8");
4261
4397
  } catch {
4262
4398
  return 0;
4263
4399
  }
@@ -4277,7 +4413,7 @@ ${line}`;
4277
4413
  const next = lines.join("\n");
4278
4414
  const backup = `${file}.bak.${Date.now()}`;
4279
4415
  try {
4280
- await fsp2.copyFile(file, backup);
4416
+ await fsp3.copyFile(file, backup);
4281
4417
  await pruneConsolidateBackups(file);
4282
4418
  } catch {
4283
4419
  }
@@ -4293,11 +4429,11 @@ async function pruneConsolidateBackups(file) {
4293
4429
  const dir = path4.dirname(file);
4294
4430
  const base = path4.basename(file);
4295
4431
  const prefix = `${base}.bak.`;
4296
- const backups = (await fsp2.readdir(dir)).filter((name) => name.startsWith(prefix)).sort().reverse();
4432
+ const backups = (await fsp3.readdir(dir)).filter((name) => name.startsWith(prefix)).sort().reverse();
4297
4433
  await Promise.all(
4298
4434
  backups.slice(MAX_MEMORY_CONSOLIDATE_BACKUPS).map(async (name) => {
4299
4435
  try {
4300
- await fsp2.unlink(path4.join(dir, name));
4436
+ await fsp3.unlink(path4.join(dir, name));
4301
4437
  } catch {
4302
4438
  }
4303
4439
  })
@@ -4852,9 +4988,9 @@ ${body.trim()}`);
4852
4988
  if (!this.persistBackup || scope === "project-agents") return;
4853
4989
  try {
4854
4990
  const content = await this.backend.readAll(scope, this.files[scope]);
4855
- const { writeFile: writeFile7, mkdir: mkdir8 } = await import('fs/promises');
4991
+ const { writeFile: writeFile8, mkdir: mkdir8 } = await import('fs/promises');
4856
4992
  await mkdir8(this.backupDir, { recursive: true });
4857
- await writeFile7(`${this.backupDir}/${scope}.md`, content, "utf8");
4993
+ await writeFile8(`${this.backupDir}/${scope}.md`, content, "utf8");
4858
4994
  } catch {
4859
4995
  }
4860
4996
  }
@@ -5188,8 +5324,8 @@ function unwrapDataKey(buf, keyFile) {
5188
5324
  function checkKeyFilePermissions(keyFile) {
5189
5325
  if (process.platform === "win32") return;
5190
5326
  try {
5191
- const stat6 = fs4.statSync(keyFile);
5192
- const actualMode = stat6.mode & 511;
5327
+ const stat8 = fs4.statSync(keyFile);
5328
+ const actualMode = stat8.mode & 511;
5193
5329
  if (actualMode !== KEY_FILE_MODE) {
5194
5330
  console.warn(JSON.stringify({
5195
5331
  level: "warn",
@@ -5454,20 +5590,20 @@ function isSecretField(name) {
5454
5590
  async function rewriteConfigEncrypted(configPath, vault, patch) {
5455
5591
  let current = {};
5456
5592
  try {
5457
- const raw = await fsp2.readFile(configPath, "utf8");
5593
+ const raw = await fsp3.readFile(configPath, "utf8");
5458
5594
  current = JSON.parse(raw);
5459
5595
  } catch {
5460
5596
  }
5461
5597
  const merged = deepMerge(current, patch ?? {});
5462
5598
  const encrypted = encryptConfigSecrets(merged, vault);
5463
- await fsp2.mkdir(path4.dirname(configPath), { recursive: true });
5599
+ await fsp3.mkdir(path4.dirname(configPath), { recursive: true });
5464
5600
  await atomicWrite(configPath, JSON.stringify(encrypted, null, 2), { mode: 384 });
5465
5601
  await restrictFilePermissions(configPath);
5466
5602
  }
5467
5603
  async function migratePlaintextSecrets(configPath, vault, logger) {
5468
5604
  let raw;
5469
5605
  try {
5470
- raw = await fsp2.readFile(configPath, "utf8");
5606
+ raw = await fsp3.readFile(configPath, "utf8");
5471
5607
  } catch {
5472
5608
  return { migrated: 0, file: configPath };
5473
5609
  }
@@ -5509,7 +5645,7 @@ async function restrictFilePermissions(filePath, opts) {
5509
5645
  }
5510
5646
  } else {
5511
5647
  try {
5512
- await fsp2.chmod(filePath, 384);
5648
+ await fsp3.chmod(filePath, 384);
5513
5649
  } catch {
5514
5650
  }
5515
5651
  }
@@ -5703,7 +5839,12 @@ var BEHAVIOR_DEFAULTS = {
5703
5839
  },
5704
5840
  circuitBreaker: { ...DEFAULT_CIRCUIT_BREAKER_CONFIG },
5705
5841
  modelRuntime: {
5706
- reasoning: { mode: "auto", effort: "high", preserve: false },
5842
+ // `effort` is intentionally undefined by default. Leaving it unset lets
5843
+ // each model use its provider-recommended reasoning effort (or none at
5844
+ // all) instead of forcing an opinionated value that may be unsupported,
5845
+ // silently omitted, and surfaced as a per-request warning. Users who
5846
+ // want a specific effort can opt in via `/settings` or the WebUI panel.
5847
+ reasoning: { mode: "auto" },
5707
5848
  cache: {}
5708
5849
  }
5709
5850
  };
@@ -5959,6 +6100,7 @@ var DefaultConfigLoader = class {
5959
6100
  extraSources;
5960
6101
  events;
5961
6102
  traceId;
6103
+ jsonCache = /* @__PURE__ */ new Map();
5962
6104
  constructor(opts) {
5963
6105
  this.paths = opts.paths;
5964
6106
  this.strict = opts.strict ?? false;
@@ -6041,7 +6183,7 @@ var DefaultConfigLoader = class {
6041
6183
  await withFileLock(fp, async () => {
6042
6184
  let parsed;
6043
6185
  try {
6044
- const raw = await fsp2.readFile(fp, "utf8");
6186
+ const raw = await fsp3.readFile(fp, "utf8");
6045
6187
  const result = safeParse(raw);
6046
6188
  if (!result.ok || !isPlainRecord(result.value)) {
6047
6189
  return;
@@ -6156,7 +6298,7 @@ var DefaultConfigLoader = class {
6156
6298
  const fp = this.paths.syncConfig;
6157
6299
  const t0 = Date.now();
6158
6300
  try {
6159
- const raw = await fsp2.readFile(fp, "utf8");
6301
+ const raw = await fsp3.readFile(fp, "utf8");
6160
6302
  const parsed = safeParse(raw);
6161
6303
  if (!parsed.ok || !parsed.value) {
6162
6304
  this.events?.emit("storage.read", {
@@ -6217,10 +6359,42 @@ var DefaultConfigLoader = class {
6217
6359
  }
6218
6360
  }
6219
6361
  async readJson(file) {
6220
- let raw;
6221
6362
  const t0 = Date.now();
6363
+ let mtimeMs = null;
6364
+ try {
6365
+ const stat8 = await fsp3.stat(file);
6366
+ mtimeMs = stat8.mtimeMs;
6367
+ const cached = this.jsonCache.get(file);
6368
+ if (cached && cached.mtimeMs === mtimeMs) {
6369
+ return structuredClone(cached.value);
6370
+ }
6371
+ } catch (err) {
6372
+ if (err.code === "ENOENT") {
6373
+ this.jsonCache.set(file, { mtimeMs: null, value: {} });
6374
+ return {};
6375
+ }
6376
+ this.events?.emit("storage.read", {
6377
+ sessionId: "~config~",
6378
+ store: "config",
6379
+ filePath: file,
6380
+ operation: "read_json",
6381
+ outcome: "failure",
6382
+ durationMs: Date.now() - t0,
6383
+ error: storageErrorString(err),
6384
+ ...this.traceId !== void 0 ? { traceId: this.traceId } : {}
6385
+ });
6386
+ console.warn(JSON.stringify({
6387
+ level: "warn",
6388
+ event: "config.read_failed",
6389
+ path: file,
6390
+ message: toErrorMessage(err),
6391
+ timestamp: (/* @__PURE__ */ new Date()).toISOString()
6392
+ }));
6393
+ return {};
6394
+ }
6395
+ let raw;
6222
6396
  try {
6223
- raw = await fsp2.readFile(file, "utf8");
6397
+ raw = await fsp3.readFile(file, "utf8");
6224
6398
  } catch (err) {
6225
6399
  if (err.code !== "ENOENT") {
6226
6400
  this.events?.emit("storage.read", {
@@ -6241,6 +6415,7 @@ var DefaultConfigLoader = class {
6241
6415
  timestamp: (/* @__PURE__ */ new Date()).toISOString()
6242
6416
  }));
6243
6417
  }
6418
+ this.jsonCache.set(file, { mtimeMs: null, value: {} });
6244
6419
  return {};
6245
6420
  }
6246
6421
  const parsed = safeParse(raw);
@@ -6264,6 +6439,7 @@ var DefaultConfigLoader = class {
6264
6439
  }));
6265
6440
  return {};
6266
6441
  }
6442
+ this.jsonCache.set(file, { mtimeMs, value: structuredClone(parsed.value) });
6267
6443
  return parsed.value;
6268
6444
  }
6269
6445
  validateBehavior(cfg) {
@@ -6461,7 +6637,7 @@ var RecoveryLock = class {
6461
6637
  startedAt: (/* @__PURE__ */ new Date()).toISOString()
6462
6638
  };
6463
6639
  try {
6464
- await fsp2.writeFile(this.file, JSON.stringify(lock), { flag: "wx", mode: 384 });
6640
+ await fsp3.writeFile(this.file, JSON.stringify(lock), { flag: "wx", mode: 384 });
6465
6641
  } catch (err) {
6466
6642
  const code = err.code;
6467
6643
  if (code === "EEXIST") {
@@ -6477,7 +6653,7 @@ var RecoveryLock = class {
6477
6653
  */
6478
6654
  async clear() {
6479
6655
  try {
6480
- await fsp2.unlink(this.file);
6656
+ await fsp3.unlink(this.file);
6481
6657
  } catch (err) {
6482
6658
  const code = err.code;
6483
6659
  if (code === "ENOENT") return;
@@ -6487,7 +6663,7 @@ var RecoveryLock = class {
6487
6663
  async readLock() {
6488
6664
  let raw;
6489
6665
  try {
6490
- raw = await fsp2.readFile(this.file, "utf8");
6666
+ raw = await fsp3.readFile(this.file, "utf8");
6491
6667
  } catch (err) {
6492
6668
  const code = err.code;
6493
6669
  if (code === "ENOENT") return null;
@@ -6518,26 +6694,82 @@ function defaultIsPidAlive(pid) {
6518
6694
  return false;
6519
6695
  }
6520
6696
  }
6521
-
6522
- // src/storage/session-reader.ts
6523
- var DefaultSessionReader = class {
6697
+ var DefaultSessionReader = class _DefaultSessionReader {
6524
6698
  store;
6699
+ eventCache = /* @__PURE__ */ new Map();
6700
+ eventCacheMtimes = /* @__PURE__ */ new Map();
6701
+ static EVENT_CACHE_MAX_ENTRIES = 32;
6525
6702
  constructor(opts) {
6526
6703
  this.store = opts.store;
6527
6704
  }
6705
+ async loadCachedSessionData(sessionId) {
6706
+ const storeWithPath = this.store;
6707
+ const rootDir = storeWithPath.dir;
6708
+ if (!rootDir) {
6709
+ return await this.store.load(sessionId);
6710
+ }
6711
+ const sessionPath = path4.join(rootDir, `${sessionId}.jsonl`);
6712
+ let mtimeMs = null;
6713
+ try {
6714
+ const stat8 = await fsp3.stat(sessionPath);
6715
+ mtimeMs = stat8.mtimeMs;
6716
+ } catch {
6717
+ this.eventCache.delete(sessionId);
6718
+ this.eventCacheMtimes.delete(sessionId);
6719
+ return await this.store.load(sessionId);
6720
+ }
6721
+ const cachedMtime = this.eventCacheMtimes.get(sessionId);
6722
+ const cachedData = this.eventCache.get(sessionId);
6723
+ if (cachedData && cachedMtime === mtimeMs) {
6724
+ this.eventCache.delete(sessionId);
6725
+ this.eventCacheMtimes.delete(sessionId);
6726
+ this.eventCache.set(sessionId, cachedData);
6727
+ this.eventCacheMtimes.set(sessionId, mtimeMs);
6728
+ return cachedData;
6729
+ }
6730
+ const data = await this.store.load(sessionId);
6731
+ this.eventCache.delete(sessionId);
6732
+ this.eventCacheMtimes.delete(sessionId);
6733
+ this.eventCache.set(sessionId, data);
6734
+ this.eventCacheMtimes.set(sessionId, mtimeMs);
6735
+ while (this.eventCache.size > _DefaultSessionReader.EVENT_CACHE_MAX_ENTRIES) {
6736
+ const oldest = this.eventCache.keys().next().value;
6737
+ if (oldest === void 0) break;
6738
+ this.eventCache.delete(oldest);
6739
+ this.eventCacheMtimes.delete(oldest);
6740
+ }
6741
+ if (data.metadata.endedAt) {
6742
+ storeWithPath.clearLoadCache?.(sessionId);
6743
+ }
6744
+ return data;
6745
+ }
6528
6746
  async query(q = {}) {
6529
- const raw = await this.store.list(q.limit ? Math.max(q.limit, 100) : 1e3);
6530
- const titleNeedle = q.titleContains?.toLowerCase();
6531
- const filtered = raw.filter((s) => {
6532
- if (q.since && s.startedAt < q.since) return false;
6533
- if (q.until && s.startedAt > q.until) return false;
6534
- if (q.provider && s.provider !== q.provider) return false;
6535
- if (q.model && s.model !== q.model) return false;
6536
- if (q.minTokens !== void 0 && s.tokenTotal < q.minTokens) return false;
6537
- if (titleNeedle && !s.title.toLowerCase().includes(titleNeedle)) return false;
6538
- return true;
6539
- });
6540
- const out = filtered.map((s) => ({
6747
+ const storeWithFilter = this.store;
6748
+ let raw;
6749
+ if (typeof storeWithFilter.listFiltered === "function") {
6750
+ raw = await storeWithFilter.listFiltered({
6751
+ since: q.since,
6752
+ until: q.until,
6753
+ provider: q.provider,
6754
+ model: q.model,
6755
+ minTokens: q.minTokens,
6756
+ titleContains: q.titleContains,
6757
+ limit: q.limit
6758
+ });
6759
+ } else {
6760
+ const fetched = await this.store.list(q.limit ? Math.max(q.limit, 100) : 1e3);
6761
+ const titleNeedle = q.titleContains?.toLowerCase();
6762
+ raw = fetched.filter((s) => {
6763
+ if (q.since && s.startedAt < q.since) return false;
6764
+ if (q.until && s.startedAt > q.until) return false;
6765
+ if (q.provider && s.provider !== q.provider) return false;
6766
+ if (q.model && s.model !== q.model) return false;
6767
+ if (q.minTokens !== void 0 && s.tokenTotal < q.minTokens) return false;
6768
+ if (titleNeedle && !s.title.toLowerCase().includes(titleNeedle)) return false;
6769
+ return true;
6770
+ });
6771
+ }
6772
+ const out = raw.map((s) => ({
6541
6773
  id: s.id,
6542
6774
  title: s.title,
6543
6775
  startedAt: s.startedAt,
@@ -6548,7 +6780,7 @@ var DefaultSessionReader = class {
6548
6780
  return q.limit ? out.slice(0, q.limit) : out;
6549
6781
  }
6550
6782
  async *replay(sessionId) {
6551
- const data = await this.store.load(sessionId);
6783
+ const data = await this.loadCachedSessionData(sessionId);
6552
6784
  for (const e of data.events) yield e;
6553
6785
  }
6554
6786
  async search(q, sessionId, sessionQuery) {
@@ -6559,18 +6791,32 @@ var DefaultSessionReader = class {
6559
6791
  if (sessionId) {
6560
6792
  ids = [sessionId];
6561
6793
  } else {
6562
- const sessions = await this.store.list(1e3);
6563
- const titleNeedle = sessionQuery?.titleContains?.toLowerCase();
6564
- const filtered = sessions.filter((s) => {
6565
- if (sessionQuery?.since && s.startedAt < sessionQuery.since) return false;
6566
- if (sessionQuery?.until && s.startedAt > sessionQuery.until) return false;
6567
- if (sessionQuery?.provider && s.provider !== sessionQuery.provider) return false;
6568
- if (sessionQuery?.model && s.model !== sessionQuery.model) return false;
6569
- if (sessionQuery?.minTokens !== void 0 && s.tokenTotal < sessionQuery.minTokens) return false;
6570
- if (titleNeedle && !s.title.toLowerCase().includes(titleNeedle)) return false;
6571
- return true;
6572
- });
6573
- ids = filtered.map((s) => s.id);
6794
+ const storeWithFilter = this.store;
6795
+ let sessions;
6796
+ if (typeof storeWithFilter.listFiltered === "function") {
6797
+ sessions = await storeWithFilter.listFiltered({
6798
+ since: sessionQuery?.since,
6799
+ until: sessionQuery?.until,
6800
+ provider: sessionQuery?.provider,
6801
+ model: sessionQuery?.model,
6802
+ minTokens: sessionQuery?.minTokens,
6803
+ titleContains: sessionQuery?.titleContains,
6804
+ limit: 1e3
6805
+ });
6806
+ } else {
6807
+ sessions = await this.store.list(1e3);
6808
+ const titleNeedle = sessionQuery?.titleContains?.toLowerCase();
6809
+ sessions = sessions.filter((s) => {
6810
+ if (sessionQuery?.since && s.startedAt < sessionQuery.since) return false;
6811
+ if (sessionQuery?.until && s.startedAt > sessionQuery.until) return false;
6812
+ if (sessionQuery?.provider && s.provider !== sessionQuery.provider) return false;
6813
+ if (sessionQuery?.model && s.model !== sessionQuery.model) return false;
6814
+ if (sessionQuery?.minTokens !== void 0 && s.tokenTotal < sessionQuery.minTokens) return false;
6815
+ if (titleNeedle && !s.title.toLowerCase().includes(titleNeedle)) return false;
6816
+ return true;
6817
+ });
6818
+ }
6819
+ ids = sessions.map((s) => s.id);
6574
6820
  }
6575
6821
  const hits = [];
6576
6822
  const streaming = this.store.searchEvents?.bind(this.store);
@@ -6604,7 +6850,7 @@ var DefaultSessionReader = class {
6604
6850
  for (const id of ids) {
6605
6851
  let data;
6606
6852
  try {
6607
- data = await this.store.load(id);
6853
+ data = await this.loadCachedSessionData(id);
6608
6854
  } catch {
6609
6855
  continue;
6610
6856
  }
@@ -6628,7 +6874,7 @@ var DefaultSessionReader = class {
6628
6874
  return hits;
6629
6875
  }
6630
6876
  async export(sessionId, opts) {
6631
- const data = await this.store.load(sessionId);
6877
+ const data = await this.loadCachedSessionData(sessionId);
6632
6878
  const includeTools = opts.includeTools ?? true;
6633
6879
  const includeDiagnostics = opts.includeDiagnostics ?? true;
6634
6880
  const filtered = data.events.filter((e) => {
@@ -6649,7 +6895,7 @@ var DefaultSessionReader = class {
6649
6895
  return renderMarkdown(data.metadata, filtered);
6650
6896
  }
6651
6897
  async metadata(sessionId) {
6652
- const data = await this.store.load(sessionId);
6898
+ const data = await this.loadCachedSessionData(sessionId);
6653
6899
  return data.metadata;
6654
6900
  }
6655
6901
  };
@@ -7043,7 +7289,7 @@ async function loadTodosCheckpoint(filePath, events, traceId) {
7043
7289
  const t0 = Date.now();
7044
7290
  let raw;
7045
7291
  try {
7046
- raw = await fsp2.readFile(filePath, "utf8");
7292
+ raw = await fsp3.readFile(filePath, "utf8");
7047
7293
  } catch (err) {
7048
7294
  events?.emit("storage.error", {
7049
7295
  sessionId: traceId ?? "~boot~",
@@ -7185,7 +7431,7 @@ async function loadPlan(filePath, events) {
7185
7431
  const t0 = Date.now();
7186
7432
  let raw;
7187
7433
  try {
7188
- raw = await fsp2.readFile(filePath, "utf8");
7434
+ raw = await fsp3.readFile(filePath, "utf8");
7189
7435
  } catch (err) {
7190
7436
  events?.emit("storage.error", {
7191
7437
  sessionId: "~boot~",
@@ -7479,7 +7725,7 @@ init_atomic_write();
7479
7725
  async function loadDirectorState(filePath) {
7480
7726
  let raw;
7481
7727
  try {
7482
- raw = await fsp2.readFile(filePath, "utf8");
7728
+ raw = await fsp3.readFile(filePath, "utf8");
7483
7729
  } catch {
7484
7730
  return null;
7485
7731
  }
@@ -7494,7 +7740,7 @@ async function loadDirectorState(filePath) {
7494
7740
  async function acquireDirectorStateLock(lockPath, processId = process.pid) {
7495
7741
  let existing;
7496
7742
  try {
7497
- existing = await fsp2.readFile(lockPath, "utf8");
7743
+ existing = await fsp3.readFile(lockPath, "utf8");
7498
7744
  } catch {
7499
7745
  }
7500
7746
  if (existing) {
@@ -7518,7 +7764,7 @@ async function acquireDirectorStateLock(lockPath, processId = process.pid) {
7518
7764
  }
7519
7765
  async function releaseDirectorStateLock(lockPath) {
7520
7766
  try {
7521
- await fsp2.unlink(lockPath);
7767
+ await fsp3.unlink(lockPath);
7522
7768
  } catch {
7523
7769
  }
7524
7770
  }
@@ -8063,7 +8309,7 @@ var DefaultPermissionPolicy = class {
8063
8309
  }
8064
8310
  async reload() {
8065
8311
  try {
8066
- const raw = await fsp2.readFile(this.trustFile, "utf8");
8312
+ const raw = await fsp3.readFile(this.trustFile, "utf8");
8067
8313
  const parsed = safeParse(raw);
8068
8314
  if (parsed.ok && parsed.value) this.policy = parsed.value;
8069
8315
  } catch {
@@ -8549,12 +8795,12 @@ var DefaultSkillLoader = class {
8549
8795
  const seen = /* @__PURE__ */ new Set();
8550
8796
  for (const { dir, source } of this.dirs) {
8551
8797
  try {
8552
- const entries = await fsp2.readdir(dir, { withFileTypes: true });
8798
+ const entries = await fsp3.readdir(dir, { withFileTypes: true });
8553
8799
  for (const e of entries) {
8554
8800
  if (!e.isDirectory()) continue;
8555
8801
  const skillFile = path4.join(dir, e.name, "SKILL.md");
8556
8802
  try {
8557
- const raw = await fsp2.readFile(skillFile, "utf8");
8803
+ const raw = await fsp3.readFile(skillFile, "utf8");
8558
8804
  const meta = parseFrontmatter(raw);
8559
8805
  if (!meta.name || !meta.description) continue;
8560
8806
  if (seen.has(meta.name)) continue;
@@ -8613,7 +8859,7 @@ var DefaultSkillLoader = class {
8613
8859
  if (cached !== void 0) return cached;
8614
8860
  const m = await this.find(name);
8615
8861
  if (!m) throw new Error(`Skill "${name}" not found`);
8616
- const body = await fsp2.readFile(m.path, "utf8");
8862
+ const body = await fsp3.readFile(m.path, "utf8");
8617
8863
  this.bodyCache.set(key, body);
8618
8864
  return body;
8619
8865
  }
@@ -8626,9 +8872,9 @@ var DefaultSkillLoader = class {
8626
8872
  const savePath = path4.join(path4.dirname(m.path), "SKILL.save.md");
8627
8873
  let result;
8628
8874
  try {
8629
- result = await fsp2.readFile(savePath, "utf8");
8875
+ result = await fsp3.readFile(savePath, "utf8");
8630
8876
  } catch {
8631
- const full = await fsp2.readFile(m.path, "utf8");
8877
+ const full = await fsp3.readFile(m.path, "utf8");
8632
8878
  const body = stripFrontmatter(full);
8633
8879
  const compact = compactSkillBody(body);
8634
8880
  if (compact) {
@@ -10721,7 +10967,7 @@ ${errorDetails}`,
10721
10967
  "tool.name": tool.name,
10722
10968
  "tool.mutating": tool.mutating,
10723
10969
  "tool.permission": tool.permission,
10724
- "tool.capabilities": JSON.stringify(tool.capabilities ?? []),
10970
+ "tool.capabilities": toolCapsForAudit.length > 0 ? JSON.stringify(tool.capabilities ?? []) : "[]",
10725
10971
  "tool.has_dangerous_capabilities": toolCapsForAudit.length > 0
10726
10972
  });
10727
10973
  try {
@@ -11077,11 +11323,11 @@ async function maybePersistLargeToolOutput(toolName, content, budget) {
11077
11323
  }
11078
11324
  try {
11079
11325
  const dir = path4.join(wstackGlobalRoot(), "tool-output");
11080
- await fsp2.mkdir(dir, { recursive: true });
11326
+ await fsp3.mkdir(dir, { recursive: true });
11081
11327
  const safeTool = toolName.replace(/[^a-zA-Z0-9._-]+/g, "_").slice(0, 40) || "tool";
11082
11328
  const stamp = (/* @__PURE__ */ new Date()).toISOString().replace(/[:.]/g, "-");
11083
11329
  const filePath = path4.join(dir, `${stamp}-${safeTool}-${randomUUID()}.log`);
11084
- await fsp2.writeFile(filePath, content, "utf8");
11330
+ await fsp3.writeFile(filePath, content, "utf8");
11085
11331
  return content + `
11086
11332
  [full tool output: ${bytes} bytes at ${filePath}; read/grep that file selectively instead of re-running or requesting more output]`;
11087
11333
  } catch {
@@ -11276,7 +11522,7 @@ async function loadGoal(filePath, events) {
11276
11522
  const t0 = Date.now();
11277
11523
  let raw;
11278
11524
  try {
11279
- raw = await fsp2.readFile(filePath, "utf8");
11525
+ raw = await fsp3.readFile(filePath, "utf8");
11280
11526
  } catch (err) {
11281
11527
  const code = err.code;
11282
11528
  if (code === "ENOENT") {
@@ -12026,8 +12272,8 @@ ${recentJournal}` : "No prior iterations.",
12026
12272
  await saveGoal(this.goalPath, abandoned, this.opts.events);
12027
12273
  }
12028
12274
  try {
12029
- const { unlink: unlink13 } = await import('fs/promises');
12030
- await unlink13(this.goalPath);
12275
+ const { unlink: unlink14 } = await import('fs/promises');
12276
+ await unlink14(this.goalPath);
12031
12277
  } catch {
12032
12278
  }
12033
12279
  this.opts.onEternalStop?.();
@@ -17689,9 +17935,9 @@ var CollabSession = class extends EventEmitter {
17689
17935
  }
17690
17936
  for (const filePath of allFiles) {
17691
17937
  try {
17692
- const [content, stat6] = await Promise.all([
17693
- fsp2.readFile(filePath, "utf8"),
17694
- fsp2.stat(filePath)
17938
+ const [content, stat8] = await Promise.all([
17939
+ fsp3.readFile(filePath, "utf8"),
17940
+ fsp3.stat(filePath)
17695
17941
  ]);
17696
17942
  const ext = filePath.split(".").pop() ?? "";
17697
17943
  const language = ext === "ts" || ext === "tsx" ? "typescript" : ext === "js" || ext === "jsx" ? "javascript" : ext === "md" ? "markdown" : ext === "json" ? "json" : void 0;
@@ -17699,8 +17945,8 @@ var CollabSession = class extends EventEmitter {
17699
17945
  path: filePath,
17700
17946
  content,
17701
17947
  language,
17702
- snapshotMtimeMs: stat6.mtimeMs,
17703
- snapshotSizeBytes: stat6.size
17948
+ snapshotMtimeMs: stat8.mtimeMs,
17949
+ snapshotSizeBytes: stat8.size
17704
17950
  });
17705
17951
  } catch {
17706
17952
  this.snapshot.files.push({ path: filePath, content: "", language: void 0 });
@@ -18113,9 +18359,9 @@ Emit each evaluation immediately. Do not wait until you have read all reports.`;
18113
18359
  for (const file of this.snapshot.files) {
18114
18360
  if (file.snapshotMtimeMs === void 0 && file.snapshotSizeBytes === void 0) continue;
18115
18361
  try {
18116
- const stat6 = await fsp2.stat(file.path);
18117
- const mtimeChanged = file.snapshotMtimeMs !== void 0 && stat6.mtimeMs > file.snapshotMtimeMs + 1;
18118
- const sizeChanged = file.snapshotSizeBytes !== void 0 && stat6.size !== file.snapshotSizeBytes;
18362
+ const stat8 = await fsp3.stat(file.path);
18363
+ const mtimeChanged = file.snapshotMtimeMs !== void 0 && stat8.mtimeMs > file.snapshotMtimeMs + 1;
18364
+ const sizeChanged = file.snapshotSizeBytes !== void 0 && stat8.size !== file.snapshotSizeBytes;
18119
18365
  if (mtimeChanged || sizeChanged) {
18120
18366
  warnings.push(`${file.path} changed after the collab snapshot was captured.`);
18121
18367
  }
@@ -19317,7 +19563,7 @@ var Director = class _Director {
19317
19563
  this.fleetManager = opts.fleetManager;
19318
19564
  this.logger = opts.logger;
19319
19565
  if (this.sharedScratchpadPath) {
19320
- void fsp2.mkdir(this.sharedScratchpadPath, { recursive: true }).catch((err) => this.logShutdownError("shared_scratchpad_mkdir", err));
19566
+ void fsp3.mkdir(this.sharedScratchpadPath, { recursive: true }).catch((err) => this.logShutdownError("shared_scratchpad_mkdir", err));
19321
19567
  }
19322
19568
  this.transport = new InMemoryBridgeTransport();
19323
19569
  this.bridge = new InMemoryAgentBridge(
@@ -19917,7 +20163,7 @@ var Director = class _Director {
19917
20163
  })),
19918
20164
  usage: this.usage.snapshot()
19919
20165
  };
19920
- await fsp2.mkdir(path4.dirname(this.manifestPath), { recursive: true });
20166
+ await fsp3.mkdir(path4.dirname(this.manifestPath), { recursive: true });
19921
20167
  await atomicWrite(this.manifestPath, JSON.stringify(manifest, null, 2), { mode: 384 });
19922
20168
  return this.manifestPath;
19923
20169
  }
@@ -20151,7 +20397,7 @@ var Director = class _Director {
20151
20397
  const filePath = path4.join(this.sessionsRoot, this.directorRunId, `${subagentId}.jsonl`);
20152
20398
  let raw;
20153
20399
  try {
20154
- raw = await fsp2.readFile(filePath, "utf8");
20400
+ raw = await fsp3.readFile(filePath, "utf8");
20155
20401
  } catch {
20156
20402
  return null;
20157
20403
  }
@@ -20681,7 +20927,7 @@ async function readSubagentPartial(opts, subagentId) {
20681
20927
  candidates.push(path4.join(opts.sessionsRoot, opts.directorRunId, `${subagentId}.jsonl`));
20682
20928
  } else {
20683
20929
  try {
20684
- const entries = await fsp2.readdir(opts.sessionsRoot, { withFileTypes: true });
20930
+ const entries = await fsp3.readdir(opts.sessionsRoot, { withFileTypes: true });
20685
20931
  for (const entry of entries) {
20686
20932
  if (entry.isDirectory()) {
20687
20933
  candidates.push(path4.join(opts.sessionsRoot, entry.name, `${subagentId}.jsonl`));
@@ -20694,7 +20940,7 @@ async function readSubagentPartial(opts, subagentId) {
20694
20940
  for (const file of candidates) {
20695
20941
  let raw;
20696
20942
  try {
20697
- raw = await fsp2.readFile(file, "utf8");
20943
+ raw = await fsp3.readFile(file, "utf8");
20698
20944
  } catch {
20699
20945
  continue;
20700
20946
  }
@@ -21024,7 +21270,7 @@ var DefaultModelsRegistry = class {
21024
21270
  async readOverlayFile() {
21025
21271
  if (!this.overlayFile) return void 0;
21026
21272
  try {
21027
- const raw = await fsp2.readFile(this.overlayFile, "utf8");
21273
+ const raw = await fsp3.readFile(this.overlayFile, "utf8");
21028
21274
  return JSON.parse(raw);
21029
21275
  } catch {
21030
21276
  return void 0;
@@ -21103,7 +21349,7 @@ var DefaultModelsRegistry = class {
21103
21349
  }
21104
21350
  async readCacheAt(file) {
21105
21351
  try {
21106
- const raw = await fsp2.readFile(file, "utf8");
21352
+ const raw = await fsp3.readFile(file, "utf8");
21107
21353
  return JSON.parse(raw);
21108
21354
  } catch {
21109
21355
  return void 0;
@@ -21489,7 +21735,7 @@ var DefaultModeStore = class {
21489
21735
  async loadActiveMode() {
21490
21736
  try {
21491
21737
  const configPath = path4.join(this.configDir, "mode.json");
21492
- const content = await fsp2.readFile(configPath, "utf8");
21738
+ const content = await fsp3.readFile(configPath, "utf8");
21493
21739
  const data = JSON.parse(content);
21494
21740
  this.activeModeId = data.activeMode ?? null;
21495
21741
  } catch {
@@ -21498,7 +21744,7 @@ var DefaultModeStore = class {
21498
21744
  }
21499
21745
  async saveActiveMode() {
21500
21746
  try {
21501
- await fsp2.mkdir(this.configDir, { recursive: true });
21747
+ await fsp3.mkdir(this.configDir, { recursive: true });
21502
21748
  const configPath = path4.join(this.configDir, "mode.json");
21503
21749
  await atomicWrite(
21504
21750
  configPath,
@@ -21511,13 +21757,13 @@ var DefaultModeStore = class {
21511
21757
  async function loadProjectModes(modesDir) {
21512
21758
  const modes = [];
21513
21759
  try {
21514
- const entries = await fsp2.readdir(modesDir);
21760
+ const entries = await fsp3.readdir(modesDir);
21515
21761
  for (const entry of entries) {
21516
21762
  if (!entry.endsWith(".md") && !entry.endsWith(".txt")) continue;
21517
21763
  const filePath = path4.join(modesDir, entry);
21518
- const stat6 = await fsp2.stat(filePath);
21519
- if (!stat6.isFile()) continue;
21520
- const content = await fsp2.readFile(filePath, "utf8");
21764
+ const stat8 = await fsp3.stat(filePath);
21765
+ if (!stat8.isFile()) continue;
21766
+ const content = await fsp3.readFile(filePath, "utf8");
21521
21767
  const id = path4.basename(entry, path4.extname(entry));
21522
21768
  modes.push({
21523
21769
  id,
@@ -21535,7 +21781,7 @@ async function loadUserModes(modesDir) {
21535
21781
  const modes = [];
21536
21782
  try {
21537
21783
  const manifestPath = path4.join(modesDir, "modes.json");
21538
- const content = await fsp2.readFile(manifestPath, "utf8");
21784
+ const content = await fsp3.readFile(manifestPath, "utf8");
21539
21785
  const manifest = JSON.parse(content);
21540
21786
  for (const mode of manifest.modes) {
21541
21787
  modes.push(mode);
@@ -21545,11 +21791,41 @@ async function loadUserModes(modesDir) {
21545
21791
  return modes;
21546
21792
  }
21547
21793
 
21794
+ // src/models/codex-catalog.ts
21795
+ var CODEX_MODELS = [
21796
+ {
21797
+ id: "gpt-5.5",
21798
+ name: "GPT-5.5",
21799
+ description: "Frontier model for complex coding, research, and real-world work.",
21800
+ current: true
21801
+ },
21802
+ {
21803
+ id: "gpt-5.4",
21804
+ name: "GPT-5.4",
21805
+ description: "Strong model for everyday coding."
21806
+ },
21807
+ {
21808
+ id: "gpt-5.4-mini",
21809
+ name: "GPT-5.4 Mini",
21810
+ description: "Small, fast, and cost-efficient model for simpler coding tasks."
21811
+ },
21812
+ {
21813
+ id: "gpt-5.3-codex-spark",
21814
+ name: "GPT-5.3 Codex Spark",
21815
+ description: "Ultra-fast coding model."
21816
+ }
21817
+ ];
21818
+ var BY_ID = new Map(CODEX_MODELS.map((m) => [m.id, m]));
21819
+ function codexModelMeta(id) {
21820
+ return BY_ID.get(id);
21821
+ }
21822
+
21548
21823
  // src/models/provider-model-resolve.ts
21549
21824
  function describeCatalogModel(m) {
21550
21825
  return {
21551
21826
  id: m.id,
21552
21827
  name: m.name,
21828
+ ...m.description !== void 0 ? { description: m.description } : {},
21553
21829
  releaseDate: m.release_date,
21554
21830
  contextWindow: m.limit?.context,
21555
21831
  inputCost: m.cost?.input,
@@ -21566,8 +21842,16 @@ function resolveProviderModelList(savedModels, catalog) {
21566
21842
  if (savedModels && savedModels.length > 0) {
21567
21843
  const byId = new Map((catalog?.models ?? []).map((m) => [m.id, m]));
21568
21844
  return savedModels.map((id) => {
21845
+ const codex = codexModelMeta(id);
21569
21846
  const hit = byId.get(id);
21570
- return hit ? describeCatalogModel(hit) : { id, name: id, capabilities: [] };
21847
+ if (hit) {
21848
+ const described = describeCatalogModel(hit);
21849
+ return codex ? { ...described, description: codex.description } : described;
21850
+ }
21851
+ if (codex) {
21852
+ return { id, name: codex.name, description: codex.description, capabilities: [] };
21853
+ }
21854
+ return { id, name: id, capabilities: [] };
21571
21855
  });
21572
21856
  }
21573
21857
  if (catalog) return catalog.models.map(describeCatalogModel);
@@ -22638,7 +22922,7 @@ var SpecStore = class {
22638
22922
  }
22639
22923
  async load(id) {
22640
22924
  try {
22641
- const raw = await fsp2.readFile(this.filePath(id), "utf8");
22925
+ const raw = await fsp3.readFile(this.filePath(id), "utf8");
22642
22926
  return JSON.parse(raw);
22643
22927
  } catch {
22644
22928
  return null;
@@ -22650,7 +22934,7 @@ var SpecStore = class {
22650
22934
  }
22651
22935
  async delete(id) {
22652
22936
  try {
22653
- await fsp2.unlink(this.filePath(id));
22937
+ await fsp3.unlink(this.filePath(id));
22654
22938
  await this.removeFromIndex(id);
22655
22939
  return true;
22656
22940
  } catch {
@@ -22659,7 +22943,7 @@ var SpecStore = class {
22659
22943
  }
22660
22944
  async exists(id) {
22661
22945
  try {
22662
- await fsp2.access(this.filePath(id));
22946
+ await fsp3.access(this.filePath(id));
22663
22947
  return true;
22664
22948
  } catch {
22665
22949
  return false;
@@ -22701,7 +22985,7 @@ var SpecStore = class {
22701
22985
  }
22702
22986
  async readIndex() {
22703
22987
  try {
22704
- const raw = await fsp2.readFile(this.indexPath, "utf8");
22988
+ const raw = await fsp3.readFile(this.indexPath, "utf8");
22705
22989
  const parsed = JSON.parse(raw);
22706
22990
  if (parsed?.version === 1) return parsed;
22707
22991
  } catch {
@@ -22764,7 +23048,7 @@ var TaskGraphStore = class {
22764
23048
  }
22765
23049
  async load(id) {
22766
23050
  try {
22767
- const raw = await fsp2.readFile(this.filePath(id), "utf8");
23051
+ const raw = await fsp3.readFile(this.filePath(id), "utf8");
22768
23052
  return graphFromJSON(raw);
22769
23053
  } catch {
22770
23054
  return null;
@@ -22776,7 +23060,7 @@ var TaskGraphStore = class {
22776
23060
  }
22777
23061
  async delete(id) {
22778
23062
  try {
22779
- await fsp2.unlink(this.filePath(id));
23063
+ await fsp3.unlink(this.filePath(id));
22780
23064
  await this.removeFromIndex(id);
22781
23065
  return true;
22782
23066
  } catch {
@@ -22785,7 +23069,7 @@ var TaskGraphStore = class {
22785
23069
  }
22786
23070
  async exists(id) {
22787
23071
  try {
22788
- await fsp2.access(this.filePath(id));
23072
+ await fsp3.access(this.filePath(id));
22789
23073
  return true;
22790
23074
  } catch {
22791
23075
  return false;
@@ -22796,7 +23080,7 @@ var TaskGraphStore = class {
22796
23080
  }
22797
23081
  async readIndex() {
22798
23082
  try {
22799
- const raw = await fsp2.readFile(this.indexPath, "utf8");
23083
+ const raw = await fsp3.readFile(this.indexPath, "utf8");
22800
23084
  const parsed = JSON.parse(raw);
22801
23085
  if (parsed?.version === 1) return parsed;
22802
23086
  } catch {
@@ -22868,6 +23152,9 @@ function buildQuestioningPrompt(session, min, max) {
22868
23152
  "- Adapt based on previous answers",
22869
23153
  "- Cover: scope, constraints, edge cases, integrations, security, performance as relevant",
22870
23154
  "- When you have enough info, respond with the full specification in JSON format",
23155
+ "- This is a planning interview: respond with TEXT ONLY (a question, or the spec JSON).",
23156
+ " Do NOT write or edit files, and do NOT run shell/terminal commands \u2014 the code is",
23157
+ " written later, after the plan is approved.",
22871
23158
  "",
22872
23159
  `**Question budget:** ${budget}/${max} remaining`,
22873
23160
  `**Minimum required:** ${remaining > 0 ? remaining : "met"}`
@@ -22935,6 +23222,9 @@ function buildImplementationPrompt(session) {
22935
23222
  "",
22936
23223
  "**Instructions for AI:**",
22937
23224
  "Generate a detailed implementation plan for this specification.",
23225
+ "This is a PLANNING step \u2014 describe the plan and emit the task JSON as TEXT. Do NOT",
23226
+ "create or edit files and do NOT run shell/terminal commands here; the tasks you list",
23227
+ "are executed later, one by one, after you approve them.",
22938
23228
  "Include:",
22939
23229
  "1. Architecture decisions",
22940
23230
  "2. File structure changes",
@@ -23046,10 +23336,10 @@ var AISpecBuilder = class {
23046
23336
  async saveSession() {
23047
23337
  if (!this.sessionPath) return;
23048
23338
  try {
23049
- const fsp18 = await import('fs/promises');
23050
- const path23 = await import('path');
23339
+ const fsp19 = await import('fs/promises');
23340
+ const path25 = await import('path');
23051
23341
  const { atomicWrite: atomicWrite2 } = await Promise.resolve().then(() => (init_atomic_write(), atomic_write_exports));
23052
- await fsp18.mkdir(path23.dirname(this.sessionPath), { recursive: true });
23342
+ await fsp19.mkdir(path25.dirname(this.sessionPath), { recursive: true });
23053
23343
  await atomicWrite2(this.sessionPath, JSON.stringify(this.session, null, 2));
23054
23344
  } catch {
23055
23345
  }
@@ -23058,8 +23348,8 @@ var AISpecBuilder = class {
23058
23348
  async loadSession() {
23059
23349
  if (!this.sessionPath) return false;
23060
23350
  try {
23061
- const fsp18 = await import('fs/promises');
23062
- const raw = await fsp18.readFile(this.sessionPath, "utf8");
23351
+ const fsp19 = await import('fs/promises');
23352
+ const raw = await fsp19.readFile(this.sessionPath, "utf8");
23063
23353
  const loaded = JSON.parse(raw);
23064
23354
  if (loaded?.id && loaded?.phase && loaded?.title) {
23065
23355
  this.session = loaded;
@@ -23073,8 +23363,8 @@ var AISpecBuilder = class {
23073
23363
  async deleteSession() {
23074
23364
  if (!this.sessionPath) return;
23075
23365
  try {
23076
- const fsp18 = await import('fs/promises');
23077
- await fsp18.unlink(this.sessionPath);
23366
+ const fsp19 = await import('fs/promises');
23367
+ await fsp19.unlink(this.sessionPath);
23078
23368
  } catch {
23079
23369
  }
23080
23370
  }
@@ -23776,15 +24066,15 @@ function computeCriticalPath(graph, _topoOrder, blockedByMap) {
23776
24066
  maxId = id;
23777
24067
  }
23778
24068
  }
23779
- const path23 = [];
24069
+ const path25 = [];
23780
24070
  let current = maxId;
23781
24071
  const visited = /* @__PURE__ */ new Set();
23782
24072
  while (current && !visited.has(current)) {
23783
24073
  visited.add(current);
23784
- path23.unshift(current);
24074
+ path25.unshift(current);
23785
24075
  current = prev.get(current) ?? null;
23786
24076
  }
23787
- return path23;
24077
+ return path25;
23788
24078
  }
23789
24079
  function computeParallelGroups(graph, blockedByMap) {
23790
24080
  const groups = [];
@@ -25551,7 +25841,7 @@ var SddBoardStore = class {
25551
25841
  }
25552
25842
  async load(runId) {
25553
25843
  try {
25554
- const raw = await fsp2.readFile(this.snapshotPath(runId), "utf8");
25844
+ const raw = await fsp3.readFile(this.snapshotPath(runId), "utf8");
25555
25845
  return JSON.parse(raw);
25556
25846
  } catch {
25557
25847
  return null;
@@ -25569,7 +25859,7 @@ var SddBoardStore = class {
25569
25859
  async appendEvent(runId, event) {
25570
25860
  try {
25571
25861
  await ensureDir(this.baseDir);
25572
- await fsp2.appendFile(this.eventsPath(runId), `${JSON.stringify(event)}
25862
+ await fsp3.appendFile(this.eventsPath(runId), `${JSON.stringify(event)}
25573
25863
  `, { mode: 384 });
25574
25864
  } catch {
25575
25865
  }
@@ -25577,7 +25867,7 @@ var SddBoardStore = class {
25577
25867
  /** Append a control command (used by readers to steer a CLI-owned run). */
25578
25868
  async appendControl(runId, command) {
25579
25869
  await ensureDir(this.baseDir);
25580
- await fsp2.appendFile(this.controlPath(runId), `${JSON.stringify(command)}
25870
+ await fsp3.appendFile(this.controlPath(runId), `${JSON.stringify(command)}
25581
25871
  `, { mode: 384 });
25582
25872
  }
25583
25873
  /** Read + truncate the control queue (the run drains it). Returns parsed commands. */
@@ -25585,12 +25875,12 @@ var SddBoardStore = class {
25585
25875
  const p = this.controlPath(runId);
25586
25876
  let raw;
25587
25877
  try {
25588
- raw = await fsp2.readFile(p, "utf8");
25878
+ raw = await fsp3.readFile(p, "utf8");
25589
25879
  } catch {
25590
25880
  return [];
25591
25881
  }
25592
25882
  try {
25593
- await fsp2.writeFile(p, "", { mode: 384 });
25883
+ await fsp3.writeFile(p, "", { mode: 384 });
25594
25884
  } catch {
25595
25885
  }
25596
25886
  return raw.split("\n").filter((l) => l.trim()).map((l) => {
@@ -25603,9 +25893,9 @@ var SddBoardStore = class {
25603
25893
  }
25604
25894
  async delete(runId) {
25605
25895
  await Promise.allSettled([
25606
- fsp2.unlink(this.snapshotPath(runId)),
25607
- fsp2.unlink(this.eventsPath(runId)),
25608
- fsp2.unlink(this.controlPath(runId))
25896
+ fsp3.unlink(this.snapshotPath(runId)),
25897
+ fsp3.unlink(this.eventsPath(runId)),
25898
+ fsp3.unlink(this.controlPath(runId))
25609
25899
  ]);
25610
25900
  await this.removeFromIndex(runId);
25611
25901
  }
@@ -25615,7 +25905,7 @@ var SddBoardStore = class {
25615
25905
  }
25616
25906
  async readIndex() {
25617
25907
  try {
25618
- const raw = await fsp2.readFile(this.indexPath, "utf8");
25908
+ const raw = await fsp3.readFile(this.indexPath, "utf8");
25619
25909
  const parsed = JSON.parse(raw);
25620
25910
  if (parsed?.version === 1) return parsed;
25621
25911
  } catch {
@@ -26051,6 +26341,7 @@ var SddInterviewDriver = class {
26051
26341
  sessionId: s.id,
26052
26342
  phase: s.phase,
26053
26343
  title: s.title,
26344
+ goal: s.userIntent || s.title,
26054
26345
  questionCount: s.questionCount,
26055
26346
  minQuestions: this.minQuestions,
26056
26347
  maxQuestions: this.maxQuestions,
@@ -26745,14 +27036,14 @@ async function destroySddProject(opts) {
26745
27036
  const deleted = [];
26746
27037
  const rmDir = async (dir, label) => {
26747
27038
  try {
26748
- await fsp2.rm(dir, { recursive: true, force: true });
27039
+ await fsp3.rm(dir, { recursive: true, force: true });
26749
27040
  deleted.push(label);
26750
27041
  } catch {
26751
27042
  }
26752
27043
  };
26753
27044
  const rmFile = async (file, label) => {
26754
27045
  try {
26755
- await fsp2.unlink(file);
27046
+ await fsp3.unlink(file);
26756
27047
  deleted.push(label);
26757
27048
  } catch {
26758
27049
  }
@@ -27108,7 +27399,7 @@ async function startMetricsServer(opts) {
27108
27399
  const tls = opts.tls;
27109
27400
  const useHttps = !!(tls?.cert && tls?.key);
27110
27401
  const host = opts.host ?? "127.0.0.1";
27111
- const path23 = opts.path ?? "/metrics";
27402
+ const path25 = opts.path ?? "/metrics";
27112
27403
  const healthPath = opts.healthPath ?? "/healthz";
27113
27404
  const healthRegistry = opts.healthRegistry;
27114
27405
  const listener = (req, res) => {
@@ -27118,7 +27409,7 @@ async function startMetricsServer(opts) {
27118
27409
  return;
27119
27410
  }
27120
27411
  const url = req.url.split("?")[0];
27121
- if (url === path23) {
27412
+ if (url === path25) {
27122
27413
  let body;
27123
27414
  try {
27124
27415
  body = renderPrometheus(opts.sink.snapshot());
@@ -27182,7 +27473,7 @@ async function startMetricsServer(opts) {
27182
27473
  const protocol = useHttps ? "https" : "http";
27183
27474
  return {
27184
27475
  port: boundPort,
27185
- url: `${protocol}://${host}:${boundPort}${path23}`,
27476
+ url: `${protocol}://${host}:${boundPort}${path25}`,
27186
27477
  close: () => new Promise((resolve8, reject) => {
27187
27478
  server.close((err) => err ? reject(err) : resolve8());
27188
27479
  })
@@ -27856,6 +28147,6 @@ var allServers = () => ({
27856
28147
  ssh: { ...sshManagerServer(), enabled: false }
27857
28148
  });
27858
28149
 
27859
- export { AGENTS_BY_PHASE, AGENT_CATALOG, AISpecBuilder, ALL_AGENT_DEFINITIONS, ALL_FLEET_AGENTS, AUDIT_LOG_AGENT, AutoApprovePermissionPolicy, AutoCompactionMiddleware, AutoExecutor, AutonomousRunner, BUG_HUNTER_AGENT, BudgetExceededError, ConfigMigrationError, DEFAULT_AUTONOMY_CONFIG, DEFAULT_CONFIG_MIGRATIONS, DEFAULT_CONTEXT_CONFIG, DEFAULT_DIRECTOR_PREAMBLE, DEFAULT_DISPATCH_ROLE, DEFAULT_SUBAGENT_BASELINE, DEFAULT_TOOLS_CONFIG, DefaultAttachmentStore, DefaultConfigLoader, DefaultConfigStore, DefaultErrorHandler, DefaultHealthRegistry, DefaultLogger, DefaultMemoryStore, DefaultModeStore, DefaultModelsRegistry, DefaultMultiAgentCoordinator, DefaultPermissionPolicy, DefaultProviderRunner, DefaultRetryPolicy, DefaultSecretScrubber, DefaultSecretVault, DefaultSessionReader, DefaultSessionStore, DefaultSkillLoader, DefaultTaskStore, Director, DirectorStateCheckpoint, DoneConditionChecker, EternalAutonomyEngine, FLEET_ROSTER, FLEET_ROSTER_BUDGETS, FleetBus, FleetSpawnBudgetError, FleetUsageAggregator, HybridCompactor, InMemoryAgentBridge, InMemoryBridgeTransport, InMemoryMetricsSink, IntelligentCompactor, LLMSelector, NULL_FLEET_BUS, NoopMetricsSink, NoopTracer, OTelTracer, PROMETHEUS_CONTENT_TYPE, ParallelEternalEngine, QueueStore, REFACTOR_PLANNER_AGENT, RecoveryLock, SECURITY_SCANNER_AGENT, SPEC_TEMPLATES, SddBoardProjector, SddBoardStore, SddInterviewDriver, SddParallelRun, SddRunRegistry, SddSupervisor, SddTaskDecomposer, SelectiveCompactor, SessionAnalyzer, SpecDrivenDev, SpecParser, SpecStore, SpecVersioning, SubagentBudget, TaskFlow, TaskGenerator, TaskGraphStore, TaskTracker, ToolExecutor, addPlanItem, allServers, analyzeCriticalPath, applyRosterBudget, attachAutoExtend, attachPlanCheckpoint, attachTodosCheckpoint, awsServer, blockServer, braveSearchServer, buildBoardSnapshot, buildBoardTasks, buildGoalPreamble, buildOtlpMetricsRequest, buildOtlpTracesRequest, classifyFamily, cleanupSddWorktrees, clearPlan, composeDirectorPrompt, composeSubagentPrompt, context7Server, contextManagerTool, createAutoExecutor, createContextManagerTool, createDelegateTool, createMessage, createSessionEventBridge, createStrategyCompactor, decryptConfigSecrets, deriveTodosFromPlanItem, describeCatalogModel, destroySddProject, dispatchAgent, emptyPlan, encryptConfigSecrets, everArtServer, extractVerificationCommand, filesystemServer, formatPlan, formatPlanTemplates, getAgentDefinition, getPlanTemplate, getTemplate, githubServer, googleMapsServer, hasConflictMarkers, isExplanatoryText, listPlanTemplates, listTemplates, loadDirectorState, loadPlan, loadProjectModes, loadTodosCheckpoint, loadUserModes, makeAgentSubagentRunner, makeAskTool, makeAssignTool, makeAutonomyPromptContributor, makeAwaitTasksTool, makeCollabDebugTool, makeCommandVerifier, makeDirectorSessionFactory, makeFleetEmitTool, makeFleetHealthTool, makeFleetSessionTool, makeFleetStatusTool, makeFleetUsageTool, makeLLMClassifier, makeLlmConflictResolver, makeLlmSubtaskGenerator, makePreferSideConflictResolver, makeRollUpTool, makeSpawnTool, makeTerminateTool, migratePlaintextSecrets, miniMaxVisionServer, playwrightServer, removePlanItem, renderProgress, renderPrometheus, renderSpecAnalysis, renderTaskGraph, renderTaskList, resolveAuditLevel, resolveConflictText, resolveProviderModelList, resolveSessionLoggingConfig, rewriteConfigEncrypted, rollbackSddRunFromDisk, rosterSummaryFromConfigs, runConfigMigrations, savePlan, saveTodosCheckpoint, scoreAgents, sentinelServer, setPlanItemStatus, shortIdMap, slackServer, sshManagerServer, startMetricsServer, startOtlpMetricsExporter, startOtlpTraceExporter, startSddRun, templateToMarkdown, wireMetricsToEvents, zaiVisionServer };
28150
+ export { AGENTS_BY_PHASE, AGENT_CATALOG, AISpecBuilder, ALL_AGENT_DEFINITIONS, ALL_FLEET_AGENTS, AUDIT_LOG_AGENT, AutoApprovePermissionPolicy, AutoCompactionMiddleware, AutoExecutor, AutonomousRunner, BUG_HUNTER_AGENT, BudgetExceededError, CODEX_MODELS, ConfigMigrationError, DEFAULT_AUTONOMY_CONFIG, DEFAULT_CONFIG_MIGRATIONS, DEFAULT_CONTEXT_CONFIG, DEFAULT_DIRECTOR_PREAMBLE, DEFAULT_DISPATCH_ROLE, DEFAULT_SUBAGENT_BASELINE, DEFAULT_TOOLS_CONFIG, DefaultAttachmentStore, DefaultConfigLoader, DefaultConfigStore, DefaultErrorHandler, DefaultHealthRegistry, DefaultLogger, DefaultMemoryStore, DefaultModeStore, DefaultModelsRegistry, DefaultMultiAgentCoordinator, DefaultPermissionPolicy, DefaultProviderRunner, DefaultRetryPolicy, DefaultSecretScrubber, DefaultSecretVault, DefaultSessionReader, DefaultSessionStore, DefaultSkillLoader, DefaultTaskStore, Director, DirectorStateCheckpoint, DoneConditionChecker, EternalAutonomyEngine, FLEET_ROSTER, FLEET_ROSTER_BUDGETS, FleetBus, FleetSpawnBudgetError, FleetUsageAggregator, HybridCompactor, InMemoryAgentBridge, InMemoryBridgeTransport, InMemoryMetricsSink, IntelligentCompactor, LLMSelector, NULL_FLEET_BUS, NoopMetricsSink, NoopTracer, OTelTracer, PROMETHEUS_CONTENT_TYPE, ParallelEternalEngine, QueueStore, REFACTOR_PLANNER_AGENT, RecoveryLock, SECURITY_SCANNER_AGENT, SPEC_TEMPLATES, SddBoardProjector, SddBoardStore, SddInterviewDriver, SddParallelRun, SddRunRegistry, SddSupervisor, SddTaskDecomposer, SelectiveCompactor, SessionAnalyzer, SpecDrivenDev, SpecParser, SpecStore, SpecVersioning, SubagentBudget, TaskFlow, TaskGenerator, TaskGraphStore, TaskTracker, ToolExecutor, addPlanItem, allServers, analyzeCriticalPath, applyRosterBudget, attachAutoExtend, attachPlanCheckpoint, attachTodosCheckpoint, awsServer, blockServer, braveSearchServer, buildBoardSnapshot, buildBoardTasks, buildGoalPreamble, buildOtlpMetricsRequest, buildOtlpTracesRequest, classifyFamily, cleanupSddWorktrees, clearPlan, codexModelMeta, composeDirectorPrompt, composeSubagentPrompt, context7Server, contextManagerTool, createAutoExecutor, createContextManagerTool, createDelegateTool, createMessage, createSessionEventBridge, createStrategyCompactor, decryptConfigSecrets, deriveTodosFromPlanItem, describeCatalogModel, destroySddProject, dispatchAgent, emptyPlan, encryptConfigSecrets, everArtServer, extractVerificationCommand, filesystemServer, formatPlan, formatPlanTemplates, getAgentDefinition, getPlanTemplate, getTemplate, githubServer, googleMapsServer, hasConflictMarkers, isExplanatoryText, listPlanTemplates, listTemplates, loadDirectorState, loadPlan, loadProjectModes, loadTodosCheckpoint, loadUserModes, makeAgentSubagentRunner, makeAskTool, makeAssignTool, makeAutonomyPromptContributor, makeAwaitTasksTool, makeCollabDebugTool, makeCommandVerifier, makeDirectorSessionFactory, makeFleetEmitTool, makeFleetHealthTool, makeFleetSessionTool, makeFleetStatusTool, makeFleetUsageTool, makeLLMClassifier, makeLlmConflictResolver, makeLlmSubtaskGenerator, makePreferSideConflictResolver, makeRollUpTool, makeSpawnTool, makeTerminateTool, migratePlaintextSecrets, miniMaxVisionServer, playwrightServer, removePlanItem, renderProgress, renderPrometheus, renderSpecAnalysis, renderTaskGraph, renderTaskList, resolveAuditLevel, resolveConflictText, resolveProviderModelList, resolveSessionLoggingConfig, rewriteConfigEncrypted, rollbackSddRunFromDisk, rosterSummaryFromConfigs, runConfigMigrations, savePlan, saveTodosCheckpoint, scoreAgents, sentinelServer, setPlanItemStatus, shortIdMap, slackServer, sshManagerServer, startMetricsServer, startOtlpMetricsExporter, startOtlpTraceExporter, startSddRun, templateToMarkdown, wireMetricsToEvents, zaiVisionServer };
27860
28151
  //# sourceMappingURL=index.js.map
27861
28152
  //# sourceMappingURL=index.js.map