@averyyy/pi-coding-agent 0.82.1-piclient.3 → 0.82.1-piclient.4

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 (70) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/dist/cli/args.d.ts.map +1 -1
  3. package/dist/cli/args.js +2 -1
  4. package/dist/cli/args.js.map +1 -1
  5. package/dist/cli/tool-effects.d.ts +6 -0
  6. package/dist/cli/tool-effects.d.ts.map +1 -0
  7. package/dist/cli/tool-effects.js +164 -0
  8. package/dist/cli/tool-effects.js.map +1 -0
  9. package/dist/core/agent-session-runtime.d.ts.map +1 -1
  10. package/dist/core/agent-session-runtime.js +5 -0
  11. package/dist/core/agent-session-runtime.js.map +1 -1
  12. package/dist/core/agent-session.d.ts +13 -1
  13. package/dist/core/agent-session.d.ts.map +1 -1
  14. package/dist/core/agent-session.js +340 -89
  15. package/dist/core/agent-session.js.map +1 -1
  16. package/dist/core/model-runtime.d.ts +7 -0
  17. package/dist/core/model-runtime.d.ts.map +1 -1
  18. package/dist/core/model-runtime.js +66 -0
  19. package/dist/core/model-runtime.js.map +1 -1
  20. package/dist/core/pi-server-client.d.ts +56 -2
  21. package/dist/core/pi-server-client.d.ts.map +1 -1
  22. package/dist/core/pi-server-client.js +2090 -288
  23. package/dist/core/pi-server-client.js.map +1 -1
  24. package/dist/core/pi-server-compact-state.d.ts +81 -0
  25. package/dist/core/pi-server-compact-state.d.ts.map +1 -0
  26. package/dist/core/pi-server-compact-state.js +438 -0
  27. package/dist/core/pi-server-compact-state.js.map +1 -0
  28. package/dist/core/pi-server-protocol.d.ts +2 -0
  29. package/dist/core/pi-server-protocol.d.ts.map +1 -1
  30. package/dist/core/pi-server-protocol.js +24 -1
  31. package/dist/core/pi-server-protocol.js.map +1 -1
  32. package/dist/core/pi-server-provider-execution.d.ts +19 -0
  33. package/dist/core/pi-server-provider-execution.d.ts.map +1 -0
  34. package/dist/core/pi-server-provider-execution.js +43 -0
  35. package/dist/core/pi-server-provider-execution.js.map +1 -0
  36. package/dist/core/pi-server-request.d.ts +28 -0
  37. package/dist/core/pi-server-request.d.ts.map +1 -1
  38. package/dist/core/pi-server-request.js +492 -75
  39. package/dist/core/pi-server-request.js.map +1 -1
  40. package/dist/core/pi-server-run-state.d.ts +41 -0
  41. package/dist/core/pi-server-run-state.d.ts.map +1 -0
  42. package/dist/core/pi-server-run-state.js +318 -0
  43. package/dist/core/pi-server-run-state.js.map +1 -0
  44. package/dist/core/provider-execution-contract.d.ts +36 -0
  45. package/dist/core/provider-execution-contract.d.ts.map +1 -0
  46. package/dist/core/provider-execution-contract.js +30 -0
  47. package/dist/core/provider-execution-contract.js.map +1 -0
  48. package/dist/core/sdk.d.ts.map +1 -1
  49. package/dist/core/sdk.js +74 -53
  50. package/dist/core/sdk.js.map +1 -1
  51. package/dist/core/session-manager.d.ts +86 -2
  52. package/dist/core/session-manager.d.ts.map +1 -1
  53. package/dist/core/session-manager.js +737 -144
  54. package/dist/core/session-manager.js.map +1 -1
  55. package/dist/core/tool-effect-journal.d.ts +103 -0
  56. package/dist/core/tool-effect-journal.d.ts.map +1 -0
  57. package/dist/core/tool-effect-journal.js +1442 -0
  58. package/dist/core/tool-effect-journal.js.map +1 -0
  59. package/dist/core/tools/output-accumulator.d.ts +3 -1
  60. package/dist/core/tools/output-accumulator.d.ts.map +1 -1
  61. package/dist/core/tools/output-accumulator.js +46 -26
  62. package/dist/core/tools/output-accumulator.js.map +1 -1
  63. package/dist/main.d.ts.map +1 -1
  64. package/dist/main.js +13 -0
  65. package/dist/main.js.map +1 -1
  66. package/dist/modes/interactive/components/session-selector.d.ts +15 -0
  67. package/dist/modes/interactive/components/session-selector.d.ts.map +1 -1
  68. package/dist/modes/interactive/components/session-selector.js +98 -29
  69. package/dist/modes/interactive/components/session-selector.js.map +1 -1
  70. package/package.json +4 -4
@@ -1,14 +1,61 @@
1
1
  import { uuidv7 } from "@earendil-works/pi-ai";
2
2
  import { randomUUID } from "crypto";
3
- import { appendFileSync, closeSync, createReadStream, existsSync, mkdirSync, openSync, readdirSync, readSync, statSync, writeFileSync, } from "fs";
3
+ import { closeSync, createReadStream, existsSync, constants as fsConstants, fsyncSync, mkdirSync, openSync, readdirSync, readSync, renameSync, statSync, unlinkSync, writeSync, } from "fs";
4
4
  import { readdir, stat } from "fs/promises";
5
- import { join, resolve } from "path";
5
+ import { basename, dirname, join, resolve } from "path";
6
6
  import { createInterface } from "readline";
7
7
  import { StringDecoder } from "string_decoder";
8
+ import { isDeepStrictEqual } from "util";
9
+ import { serialize as serializeV8 } from "v8";
8
10
  import { getAgentDir as getDefaultAgentDir, getSessionsDir } from "../config.js";
9
11
  import { normalizePath, resolvePath } from "../utils/paths.js";
10
12
  import { createBranchSummaryMessage, createCompactionSummaryMessage, createCustomMessage, } from "./messages.js";
13
+ import { appendPiServerTreeHash, hashPiServerTreeEntry, PI_SERVER_EMPTY_TREE_HASH } from "./pi-server-protocol.js";
11
14
  export const CURRENT_SESSION_VERSION = 3;
15
+ let sessionManagerPersistenceTestHooks;
16
+ export function setSessionManagerPersistenceTestHooks(hooks) {
17
+ sessionManagerPersistenceTestHooks = hooks;
18
+ }
19
+ export const DEFAULT_SESSION_MANAGER_CAPACITY_LIMITS = Object.freeze({
20
+ maxEntries: 250_000,
21
+ maxLogicalBytes: 256 * 1024 * 1024,
22
+ });
23
+ export const ENV_SESSION_MAX_ENTRIES = "PI_SESSION_MAX_ENTRIES";
24
+ export const ENV_SESSION_MAX_LOGICAL_BYTES = "PI_SESSION_MAX_LOGICAL_BYTES";
25
+ export const ENV_SESSION_MAX_FILE_BYTES = "PI_SESSION_MAX_FILE_BYTES";
26
+ export class SessionManagerCapacityError extends Error {
27
+ code = "PI_SESSION_CAPACITY_EXCEEDED";
28
+ retryable = false;
29
+ resource;
30
+ sessionId;
31
+ path;
32
+ current;
33
+ requested;
34
+ limit;
35
+ constructor(input) {
36
+ super(`Session capacity exceeded: resource=${input.resource}, session=${input.sessionId}, path=${input.path ?? "<memory>"}, requested=${input.requested}, limit=${input.limit}`);
37
+ this.name = "SessionManagerCapacityError";
38
+ this.resource = input.resource;
39
+ this.sessionId = input.sessionId;
40
+ this.path = input.path;
41
+ this.current = input.current;
42
+ this.requested = input.requested;
43
+ this.limit = input.limit;
44
+ }
45
+ }
46
+ let sessionManagerCapacityOverrides = {};
47
+ export function configureSessionManagerCapacityLimits(overrides) {
48
+ for (const [name, value] of Object.entries(overrides)) {
49
+ if (!Number.isSafeInteger(value) || (value ?? 0) <= 0) {
50
+ throw new RangeError(`${name} must be a positive safe integer`);
51
+ }
52
+ }
53
+ sessionManagerCapacityOverrides = { ...sessionManagerCapacityOverrides, ...overrides };
54
+ return getSessionManagerCapacityLimits();
55
+ }
56
+ export function resetSessionManagerCapacityLimits() {
57
+ sessionManagerCapacityOverrides = {};
58
+ }
12
59
  function createSessionId() {
13
60
  return uuidv7();
14
61
  }
@@ -121,6 +168,40 @@ function buildEntryIndex(entries, byId) {
121
168
  }
122
169
  return index;
123
170
  }
171
+ function validateSessionTreeShape(entries) {
172
+ const index = new Map();
173
+ for (const entry of entries) {
174
+ if (index.has(entry.id))
175
+ throw new Error(`Duplicate session entry id ${entry.id}`);
176
+ index.set(entry.id, entry);
177
+ }
178
+ for (const entry of entries) {
179
+ if (entry.parentId !== null && !index.has(entry.parentId)) {
180
+ throw new Error(`Parent entry ${entry.parentId} does not exist`);
181
+ }
182
+ }
183
+ const visitState = new Map();
184
+ for (const entry of entries) {
185
+ if (visitState.get(entry.id) === "visited")
186
+ continue;
187
+ const path = [];
188
+ let current = entry;
189
+ while (current) {
190
+ const state = visitState.get(current.id);
191
+ if (state === "visiting") {
192
+ throw new Error(`Session tree contains a parent cycle at entry ${current.id}`);
193
+ }
194
+ if (state === "visited")
195
+ break;
196
+ visitState.set(current.id, "visiting");
197
+ path.push(current);
198
+ current = current.parentId === null ? undefined : index.get(current.parentId);
199
+ }
200
+ for (const pathEntry of path)
201
+ visitState.set(pathEntry.id, "visited");
202
+ }
203
+ return index;
204
+ }
124
205
  function buildSessionPath(entries, leafId, byId) {
125
206
  const index = buildEntryIndex(entries, byId);
126
207
  let leaf;
@@ -136,7 +217,12 @@ function buildSessionPath(entries, leafId, byId) {
136
217
  }
137
218
  const path = [];
138
219
  let current = leaf;
220
+ const seen = new Set();
139
221
  while (current) {
222
+ if (seen.has(current.id)) {
223
+ throw new Error(`Session tree contains a parent cycle at entry ${current.id}`);
224
+ }
225
+ seen.add(current.id);
140
226
  path.push(current);
141
227
  current = current.parentId ? index.get(current.parentId) : undefined;
142
228
  }
@@ -183,7 +269,10 @@ export function sessionEntryToContextMessages(entry) {
183
269
  return [createBranchSummaryMessage(entry.summary, entry.fromId, entry.timestamp)];
184
270
  }
185
271
  if (entry.type === "compaction") {
186
- return [createCompactionSummaryMessage(entry.summary, entry.tokensBefore, entry.timestamp)];
272
+ return [
273
+ createCompactionSummaryMessage(entry.summary, entry.tokensBefore, entry.timestamp),
274
+ ...(entry.retainedTail ?? []),
275
+ ];
187
276
  }
188
277
  return [];
189
278
  }
@@ -211,6 +300,10 @@ export function buildContextEntries(entries, leafId, byId) {
211
300
  return path;
212
301
  }
213
302
  const contextEntries = [compaction];
303
+ if (compaction.retainedTail !== undefined) {
304
+ contextEntries.push(...path.slice(compactionIdx + 1));
305
+ return contextEntries;
306
+ }
214
307
  let foundFirstKept = false;
215
308
  for (let i = 0; i < compactionIdx; i++) {
216
309
  const entry = path[i];
@@ -256,21 +349,238 @@ const SESSION_READ_BUFFER_SIZE = 1024 * 1024;
256
349
  const SESSION_HEADER_READ_BUFFER_SIZE = 4096;
257
350
  /** Bound synchronous header discovery while allowing large cwd and custom metadata fields. */
258
351
  const MAX_SESSION_HEADER_SCAN_BYTES = 1024 * 1024;
352
+ const SESSION_FILE_ENVELOPE_OVERHEAD_BYTES = 1024 * 1024;
353
+ const WINDOWS_SESSION_RENAME_RETRIES = 4;
354
+ const MAX_WINDOWS_SESSION_RENAME_DELAY_MS = 80;
355
+ function readPositiveSafeIntegerEnvironment(name) {
356
+ const raw = process.env[name];
357
+ if (raw === undefined)
358
+ return undefined;
359
+ if (!/^[1-9][0-9]*$/u.test(raw)) {
360
+ throw new Error(`${name} must be a positive safe integer`);
361
+ }
362
+ const value = Number(raw);
363
+ if (!Number.isSafeInteger(value)) {
364
+ throw new Error(`${name} must be a positive safe integer`);
365
+ }
366
+ return value;
367
+ }
368
+ export function getSessionManagerCapacityLimits() {
369
+ const maxEntries = sessionManagerCapacityOverrides.maxEntries ??
370
+ readPositiveSafeIntegerEnvironment(ENV_SESSION_MAX_ENTRIES) ??
371
+ DEFAULT_SESSION_MANAGER_CAPACITY_LIMITS.maxEntries;
372
+ const maxLogicalBytes = sessionManagerCapacityOverrides.maxLogicalBytes ??
373
+ readPositiveSafeIntegerEnvironment(ENV_SESSION_MAX_LOGICAL_BYTES) ??
374
+ DEFAULT_SESSION_MANAGER_CAPACITY_LIMITS.maxLogicalBytes;
375
+ const derivedMaxFileBytes = maxLogicalBytes + maxEntries + SESSION_FILE_ENVELOPE_OVERHEAD_BYTES;
376
+ if (!Number.isSafeInteger(derivedMaxFileBytes)) {
377
+ throw new Error("Session capacity limits are too large to derive a safe session file limit");
378
+ }
379
+ const maxFileBytes = sessionManagerCapacityOverrides.maxFileBytes ??
380
+ readPositiveSafeIntegerEnvironment(ENV_SESSION_MAX_FILE_BYTES) ??
381
+ derivedMaxFileBytes;
382
+ return { maxEntries, maxLogicalBytes, maxFileBytes };
383
+ }
384
+ function sessionCapacityError(resource, sessionId, path, current, requested, limit) {
385
+ return new SessionManagerCapacityError({ resource, sessionId, path, current, requested, limit });
386
+ }
387
+ function serializeEntryJson(entry) {
388
+ const serialized = JSON.stringify(entry);
389
+ if (serialized === undefined) {
390
+ throw new Error(`Session entry ${entry.type} could not be serialized`);
391
+ }
392
+ return serialized;
393
+ }
394
+ function measureSessionEntryLogicalBytes(entry) {
395
+ try {
396
+ return Buffer.byteLength(serializeEntryJson(entry), "utf8");
397
+ }
398
+ catch (error) {
399
+ if (entry.type !== "custom")
400
+ throw error;
401
+ // Native in-memory custom entries intentionally support cyclic structured
402
+ // data. They have no JSON representation, so account their exact V8
403
+ // structured-clone bytes; all transportable entries retain JSON byte parity.
404
+ return serializeV8(entry).byteLength;
405
+ }
406
+ }
407
+ function measureSessionEntriesLogicalBytes(entries) {
408
+ let logicalBytes = 0;
409
+ for (const entry of entries)
410
+ logicalBytes += measureSessionEntryLogicalBytes(entry);
411
+ return logicalBytes;
412
+ }
413
+ /**
414
+ * Measure durable tree content as the UTF-8 bytes of each independently
415
+ * serialized session entry. File headers and JSONL newlines are envelope bytes.
416
+ */
417
+ export function calculateSessionManagerLogicalBytes(entries) {
418
+ return measureSessionEntriesLogicalBytes(entries);
419
+ }
420
+ function assertSessionTreeCapacity(sessionId, path, currentEntryCount, currentLogicalBytes, nextEntryCount, nextLogicalBytes) {
421
+ const limits = getSessionManagerCapacityLimits();
422
+ if (nextEntryCount > limits.maxEntries) {
423
+ throw sessionCapacityError("entries", sessionId, path, currentEntryCount, nextEntryCount, limits.maxEntries);
424
+ }
425
+ if (nextLogicalBytes > limits.maxLogicalBytes) {
426
+ throw sessionCapacityError("logical_bytes", sessionId, path, currentLogicalBytes, nextLogicalBytes, limits.maxLogicalBytes);
427
+ }
428
+ }
429
+ function measureSessionFileBytes(entries) {
430
+ let fileBytes = 0;
431
+ for (const entry of entries) {
432
+ fileBytes += Buffer.byteLength(serializeEntryJson(entry), "utf8") + 1;
433
+ }
434
+ return fileBytes;
435
+ }
436
+ function assertSessionFileSize(sessionId, path, current, requested) {
437
+ const limit = getSessionManagerCapacityLimits().maxFileBytes;
438
+ if (requested > limit) {
439
+ throw sessionCapacityError("file_bytes", sessionId, path, current, requested, limit);
440
+ }
441
+ }
442
+ function getFilesystemErrorCode(error) {
443
+ if (typeof error !== "object" || error === null || !("code" in error))
444
+ return undefined;
445
+ return typeof error.code === "string" ? error.code : undefined;
446
+ }
447
+ function waitForWindowsSessionRenameRetry(attempt) {
448
+ const delay = Math.min(2 ** attempt * 5, MAX_WINDOWS_SESSION_RENAME_DELAY_MS);
449
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, delay);
450
+ }
451
+ function replaceSessionFile(tempPath, targetPath) {
452
+ for (let attempt = 0;; attempt++) {
453
+ try {
454
+ sessionManagerPersistenceTestHooks?.beforeRenameAttempt?.(attempt);
455
+ renameSync(tempPath, targetPath);
456
+ return;
457
+ }
458
+ catch (error) {
459
+ const useWindowsRetries = process.platform === "win32" || sessionManagerPersistenceTestHooks?.simulateWindowsRenameRetries === true;
460
+ if (!useWindowsRetries ||
461
+ getFilesystemErrorCode(error) !== "EPERM" ||
462
+ attempt >= WINDOWS_SESSION_RENAME_RETRIES) {
463
+ throw error;
464
+ }
465
+ waitForWindowsSessionRenameRetry(attempt);
466
+ }
467
+ }
468
+ }
469
+ function fsyncSessionDirectory(targetPath) {
470
+ if (process.platform === "win32") {
471
+ // libuv cannot open a Windows directory for fsync. Re-open the replaced
472
+ // file and fsync it after rename so the replacement reaches stable storage.
473
+ const handle = openSync(targetPath, "r+");
474
+ try {
475
+ fsyncSync(handle);
476
+ }
477
+ finally {
478
+ closeSync(handle);
479
+ }
480
+ return;
481
+ }
482
+ const handle = openSync(dirname(targetPath), "r");
483
+ try {
484
+ fsyncSync(handle);
485
+ }
486
+ finally {
487
+ closeSync(handle);
488
+ }
489
+ }
490
+ function serializeFileEntry(entry) {
491
+ return Buffer.from(`${serializeEntryJson(entry)}\n`);
492
+ }
493
+ function writeBufferFully(handle, buffer) {
494
+ let written = 0;
495
+ while (written < buffer.byteLength) {
496
+ const bytesWritten = writeSync(handle, buffer, written, buffer.byteLength - written);
497
+ if (bytesWritten === 0) {
498
+ throw new Error("Session file write made no progress");
499
+ }
500
+ written += bytesWritten;
501
+ }
502
+ }
503
+ function writeSessionFileAtomically(targetPath, entries) {
504
+ const header = entries.find((entry) => entry.type === "session");
505
+ const currentBytes = existsSync(targetPath) ? statSync(targetPath).size : 0;
506
+ assertSessionFileSize(header?.id ?? "<unresolved>", targetPath, currentBytes, measureSessionFileBytes(entries));
507
+ const tempPath = join(dirname(targetPath), `.${basename(targetPath)}.rewrite-${process.pid}-${randomUUID()}.tmp`);
508
+ let tempHandle;
509
+ let replaced = false;
510
+ let primaryError;
511
+ const cleanupErrors = [];
512
+ try {
513
+ tempHandle = openSync(tempPath, "wx", 0o600);
514
+ sessionManagerPersistenceTestHooks?.onAtomicRewriteStage?.("after_temp_open");
515
+ for (const entry of entries) {
516
+ writeBufferFully(tempHandle, serializeFileEntry(entry));
517
+ }
518
+ sessionManagerPersistenceTestHooks?.onAtomicRewriteStage?.("after_temp_write");
519
+ fsyncSync(tempHandle);
520
+ sessionManagerPersistenceTestHooks?.onAtomicRewriteStage?.("after_temp_fsync");
521
+ closeSync(tempHandle);
522
+ tempHandle = undefined;
523
+ replaceSessionFile(tempPath, targetPath);
524
+ replaced = true;
525
+ sessionManagerPersistenceTestHooks?.onAtomicRewriteStage?.("after_replace");
526
+ fsyncSessionDirectory(targetPath);
527
+ sessionManagerPersistenceTestHooks?.onAtomicRewriteStage?.("after_directory_fsync");
528
+ }
529
+ catch (error) {
530
+ primaryError = error;
531
+ }
532
+ finally {
533
+ if (tempHandle !== undefined) {
534
+ try {
535
+ closeSync(tempHandle);
536
+ }
537
+ catch (error) {
538
+ cleanupErrors.push(error);
539
+ }
540
+ }
541
+ if (!replaced && existsSync(tempPath)) {
542
+ try {
543
+ unlinkSync(tempPath);
544
+ }
545
+ catch (error) {
546
+ cleanupErrors.push(error);
547
+ }
548
+ }
549
+ }
550
+ if (primaryError !== undefined && cleanupErrors.length === 0) {
551
+ throw primaryError;
552
+ }
553
+ if (primaryError !== undefined || cleanupErrors.length > 0) {
554
+ throw new AggregateError(primaryError === undefined ? cleanupErrors : [primaryError, ...cleanupErrors], `Failed to atomically rewrite session file ${targetPath}`);
555
+ }
556
+ }
557
+ function persistedEntriesEqual(left, right) {
558
+ return isDeepStrictEqual(JSON.parse(JSON.stringify(left)), JSON.parse(JSON.stringify(right)));
559
+ }
259
560
  class SessionHeaderScanLimitError extends Error {
260
561
  constructor(filePath) {
261
562
  super(`Session header exceeds ${MAX_SESSION_HEADER_SCAN_BYTES}-byte scan limit: ${filePath}`);
262
563
  this.name = "SessionHeaderScanLimitError";
263
564
  }
264
565
  }
265
- function parseSessionEntryLine(line) {
566
+ export class SessionFileCorruptionError extends Error {
567
+ path;
568
+ line;
569
+ constructor(filePath, line, cause) {
570
+ super(`Malformed session JSONL at ${filePath}:${line}`, { cause });
571
+ this.name = "SessionFileCorruptionError";
572
+ this.path = filePath;
573
+ this.line = line;
574
+ }
575
+ }
576
+ function parseSessionEntryLineStrict(line, filePath, lineNumber) {
266
577
  if (!line.trim())
267
578
  return null;
268
579
  try {
269
580
  return JSON.parse(line);
270
581
  }
271
- catch {
272
- // Skip malformed lines
273
- return null;
582
+ catch (error) {
583
+ throw new SessionFileCorruptionError(filePath, lineNumber, error);
274
584
  }
275
585
  }
276
586
  /** Exported for testing */
@@ -278,12 +588,17 @@ export function loadEntriesFromFile(filePath) {
278
588
  const resolvedFilePath = normalizePath(filePath);
279
589
  if (!existsSync(resolvedFilePath))
280
590
  return [];
591
+ const fileBytes = statSync(resolvedFilePath).size;
592
+ assertSessionFileSize("<unresolved>", resolvedFilePath, 0, fileBytes);
281
593
  const entries = [];
594
+ let treeEntryCount = 0;
595
+ let treeLogicalBytes = 0;
282
596
  const fd = openSync(resolvedFilePath, "r");
283
597
  try {
284
598
  const decoder = new StringDecoder("utf8");
285
599
  const buffer = Buffer.allocUnsafe(SESSION_READ_BUFFER_SIZE);
286
600
  let pending = "";
601
+ let lineNumber = 1;
287
602
  while (true) {
288
603
  const bytesRead = readSync(fd, buffer, 0, buffer.length, null);
289
604
  if (bytesRead === 0)
@@ -292,18 +607,33 @@ export function loadEntriesFromFile(filePath) {
292
607
  let lineStart = 0;
293
608
  let newlineIndex = pending.indexOf("\n", lineStart);
294
609
  while (newlineIndex !== -1) {
295
- const entry = parseSessionEntryLine(pending.slice(lineStart, newlineIndex));
296
- if (entry)
610
+ const entry = parseSessionEntryLineStrict(pending.slice(lineStart, newlineIndex), resolvedFilePath, lineNumber);
611
+ if (entry) {
612
+ if (entry.type !== "session") {
613
+ const nextLogicalBytes = treeLogicalBytes + measureSessionEntryLogicalBytes(entry);
614
+ assertSessionTreeCapacity(entries[0]?.type === "session" ? entries[0].id : "<unresolved>", resolvedFilePath, treeEntryCount, treeLogicalBytes, treeEntryCount + 1, nextLogicalBytes);
615
+ treeEntryCount++;
616
+ treeLogicalBytes = nextLogicalBytes;
617
+ }
297
618
  entries.push(entry);
619
+ }
298
620
  lineStart = newlineIndex + 1;
621
+ lineNumber++;
299
622
  newlineIndex = pending.indexOf("\n", lineStart);
300
623
  }
301
624
  pending = pending.slice(lineStart);
302
625
  }
303
626
  pending += decoder.end();
304
- const finalEntry = parseSessionEntryLine(pending);
305
- if (finalEntry)
627
+ const finalEntry = parseSessionEntryLineStrict(pending, resolvedFilePath, lineNumber);
628
+ if (finalEntry) {
629
+ if (finalEntry.type !== "session") {
630
+ const nextLogicalBytes = treeLogicalBytes + measureSessionEntryLogicalBytes(finalEntry);
631
+ assertSessionTreeCapacity(entries[0]?.type === "session" ? entries[0].id : "<unresolved>", resolvedFilePath, treeEntryCount, treeLogicalBytes, treeEntryCount + 1, nextLogicalBytes);
632
+ treeEntryCount++;
633
+ treeLogicalBytes = nextLogicalBytes;
634
+ }
306
635
  entries.push(finalEntry);
636
+ }
307
637
  }
308
638
  finally {
309
639
  closeSync(fd);
@@ -319,13 +649,13 @@ export function loadEntriesFromFile(filePath) {
319
649
  }
320
650
  /**
321
651
  * Inspect a physical line while searching for the first parsed session entry.
322
- * Blank and malformed lines are skipped to match loadEntriesFromFile().
652
+ * Blank lines are skipped; malformed JSON fails closed.
323
653
  * Returns undefined to keep scanning, null for a parsed non-header entry, or the header.
324
654
  */
325
- function parseSessionHeaderCandidate(line) {
655
+ function parseSessionHeaderCandidate(line, filePath, lineNumber) {
326
656
  if (!line.trim())
327
657
  return undefined;
328
- const entry = parseSessionEntryLine(line);
658
+ const entry = parseSessionEntryLineStrict(line, filePath, lineNumber);
329
659
  if (!entry)
330
660
  return undefined;
331
661
  if (entry.type !== "session" || typeof entry.id !== "string")
@@ -333,18 +663,21 @@ function parseSessionHeaderCandidate(line) {
333
663
  return entry;
334
664
  }
335
665
  function readSessionHeader(filePath) {
666
+ const fileBytes = statSync(filePath).size;
667
+ assertSessionFileSize("<unresolved>", filePath, 0, fileBytes);
336
668
  const fd = openSync(filePath, "r");
337
669
  try {
338
670
  const decoder = new StringDecoder("utf8");
339
671
  const buffer = Buffer.allocUnsafe(SESSION_HEADER_READ_BUFFER_SIZE);
340
672
  const lineChunks = [];
341
673
  let scannedBytes = 0;
674
+ let lineNumber = 1;
342
675
  while (scannedBytes < MAX_SESSION_HEADER_SCAN_BYTES) {
343
676
  const readLength = Math.min(buffer.length, MAX_SESSION_HEADER_SCAN_BYTES - scannedBytes);
344
677
  const bytesRead = readSync(fd, buffer, 0, readLength, null);
345
678
  if (bytesRead === 0) {
346
679
  lineChunks.push(decoder.end());
347
- return parseSessionHeaderCandidate(lineChunks.join("")) ?? null;
680
+ return parseSessionHeaderCandidate(lineChunks.join(""), filePath, lineNumber) ?? null;
348
681
  }
349
682
  scannedBytes += bytesRead;
350
683
  const chunk = decoder.write(buffer.subarray(0, bytesRead));
@@ -352,11 +685,12 @@ function readSessionHeader(filePath) {
352
685
  let newlineIndex = chunk.indexOf("\n", lineStart);
353
686
  while (newlineIndex !== -1) {
354
687
  lineChunks.push(chunk.slice(lineStart, newlineIndex));
355
- const header = parseSessionHeaderCandidate(lineChunks.join(""));
688
+ const header = parseSessionHeaderCandidate(lineChunks.join(""), filePath, lineNumber);
356
689
  if (header !== undefined)
357
690
  return header;
358
691
  lineChunks.length = 0;
359
692
  lineStart = newlineIndex + 1;
693
+ lineNumber++;
360
694
  newlineIndex = chunk.indexOf("\n", lineStart);
361
695
  }
362
696
  lineChunks.push(chunk.slice(lineStart));
@@ -366,7 +700,7 @@ function readSessionHeader(filePath) {
366
700
  const probe = Buffer.allocUnsafe(1);
367
701
  if (readSync(fd, probe, 0, probe.length, null) === 0) {
368
702
  lineChunks.push(decoder.end());
369
- return parseSessionHeaderCandidate(lineChunks.join("")) ?? null;
703
+ return parseSessionHeaderCandidate(lineChunks.join(""), filePath, lineNumber) ?? null;
370
704
  }
371
705
  throw new SessionHeaderScanLimitError(filePath);
372
706
  }
@@ -378,9 +712,10 @@ function readSessionHeaderForDiscovery(filePath) {
378
712
  try {
379
713
  return readSessionHeader(filePath);
380
714
  }
381
- catch {
382
- // Discovery is best-effort: unreadable or oversized files are not sessions,
383
- // and one corrupt file must not prevent other sessions from being found.
715
+ catch (error) {
716
+ if (error instanceof SessionManagerCapacityError || error instanceof SessionFileCorruptionError)
717
+ throw error;
718
+ // Discovery remains best-effort for transient filesystem failures.
384
719
  return null;
385
720
  }
386
721
  }
@@ -406,7 +741,9 @@ export function findMostRecentSession(sessionDir, cwd) {
406
741
  .sort((a, b) => b.mtime.getTime() - a.mtime.getTime());
407
742
  return files[0]?.path || null;
408
743
  }
409
- catch {
744
+ catch (error) {
745
+ if (error instanceof SessionManagerCapacityError || error instanceof SessionFileCorruptionError)
746
+ throw error;
410
747
  // Directory access and stat races make recent-session discovery unavailable.
411
748
  return null;
412
749
  }
@@ -440,18 +777,23 @@ function getMessageActivityTime(entry) {
440
777
  async function buildSessionInfo(filePath) {
441
778
  try {
442
779
  const stats = await stat(filePath);
780
+ assertSessionFileSize("<unresolved>", filePath, 0, stats.size);
443
781
  let header = null;
444
782
  let messageCount = 0;
445
783
  let firstMessage = "";
446
784
  const allMessages = [];
447
785
  let name;
448
786
  let lastActivityTime;
787
+ let treeEntryCount = 0;
788
+ let treeLogicalBytes = 0;
789
+ let lineNumber = 0;
449
790
  const rl = createInterface({
450
791
  input: createReadStream(filePath, { encoding: "utf8" }),
451
792
  crlfDelay: Infinity,
452
793
  });
453
794
  for await (const line of rl) {
454
- const entry = parseSessionEntryLine(line);
795
+ lineNumber++;
796
+ const entry = parseSessionEntryLineStrict(line, filePath, lineNumber);
455
797
  if (!entry)
456
798
  continue;
457
799
  if (!header) {
@@ -460,6 +802,12 @@ async function buildSessionInfo(filePath) {
460
802
  header = entry;
461
803
  continue;
462
804
  }
805
+ if (entry.type !== "session") {
806
+ const nextLogicalBytes = treeLogicalBytes + measureSessionEntryLogicalBytes(entry);
807
+ assertSessionTreeCapacity(header.id, filePath, treeEntryCount, treeLogicalBytes, treeEntryCount + 1, nextLogicalBytes);
808
+ treeEntryCount++;
809
+ treeLogicalBytes = nextLogicalBytes;
810
+ }
463
811
  // Extract session name (use latest, including explicit clears)
464
812
  if (entry.type === "session_info") {
465
813
  name = entry.name?.trim() || undefined;
@@ -507,7 +855,9 @@ async function buildSessionInfo(filePath) {
507
855
  allMessagesText: allMessages.join(" "),
508
856
  };
509
857
  }
510
- catch {
858
+ catch (error) {
859
+ if (error instanceof SessionManagerCapacityError || error instanceof SessionFileCorruptionError)
860
+ throw error;
511
861
  return null;
512
862
  }
513
863
  }
@@ -526,7 +876,9 @@ async function buildSessionInfosWithConcurrency(files, onLoaded) {
526
876
  .then((info) => {
527
877
  results[index] = info;
528
878
  })
529
- .catch(() => {
879
+ .catch((error) => {
880
+ if (error instanceof SessionManagerCapacityError || error instanceof SessionFileCorruptionError)
881
+ throw error;
530
882
  results[index] = null;
531
883
  })
532
884
  .finally(() => {
@@ -540,7 +892,13 @@ async function buildSessionInfosWithConcurrency(files, onLoaded) {
540
892
  startNext();
541
893
  }
542
894
  if (inFlight.size > 0) {
543
- await Promise.race(inFlight);
895
+ try {
896
+ await Promise.race(inFlight);
897
+ }
898
+ catch (error) {
899
+ await Promise.allSettled([...inFlight]);
900
+ throw error;
901
+ }
544
902
  }
545
903
  }
546
904
  return results;
@@ -565,7 +923,9 @@ async function listSessionsFromDir(dir, onProgress, progressOffset = 0, progress
565
923
  }
566
924
  }
567
925
  }
568
- catch {
926
+ catch (error) {
927
+ if (error instanceof SessionManagerCapacityError || error instanceof SessionFileCorruptionError)
928
+ throw error;
569
929
  // Return empty list on error
570
930
  }
571
931
  return sessions;
@@ -593,6 +953,11 @@ export class SessionManager {
593
953
  labelsById = new Map();
594
954
  labelTimestampsById = new Map();
595
955
  leafId = null;
956
+ treeEntryCount = 0;
957
+ treeLogicalBytes = 0;
958
+ treeHash = PI_SERVER_EMPTY_TREE_HASH;
959
+ treeHashError;
960
+ appendPersistenceFailure;
596
961
  constructor(cwd, sessionDir, sessionFile, persist, newSessionOptions, preloadedFileEntries) {
597
962
  this.cwd = resolvePath(cwd);
598
963
  this.sessionDir = normalizePath(sessionDir);
@@ -612,34 +977,51 @@ export class SessionManager {
612
977
  this._setSessionFile(sessionFile);
613
978
  }
614
979
  _setSessionFile(sessionFile, preloadedFileEntries) {
615
- this.sessionFile = resolvePath(sessionFile);
616
- if (existsSync(this.sessionFile)) {
617
- this.fileEntries = preloadedFileEntries ?? loadEntriesFromFile(this.sessionFile);
980
+ const resolvedSessionFile = resolvePath(sessionFile);
981
+ if (existsSync(resolvedSessionFile)) {
982
+ const nextFileEntries = structuredClone(preloadedFileEntries ?? loadEntriesFromFile(resolvedSessionFile));
618
983
  // If file was empty, initialize it with a valid session header. If it was
619
984
  // non-empty but did not parse as a pi session, fail without modifying it.
620
- if (this.fileEntries.length === 0) {
621
- const explicitPath = this.sessionFile;
622
- if (statSync(explicitPath).size > 0) {
623
- throw new Error(`Session file is not a valid pi session: ${explicitPath}`);
985
+ if (nextFileEntries.length === 0) {
986
+ if (statSync(resolvedSessionFile).size > 0) {
987
+ throw new Error(`Session file is not a valid pi session: ${resolvedSessionFile}`);
624
988
  }
625
- this.newSession();
626
- this.sessionFile = explicitPath;
627
- this._rewriteFile();
989
+ const nextSessionId = createSessionId();
990
+ const header = {
991
+ type: "session",
992
+ version: CURRENT_SESSION_VERSION,
993
+ id: nextSessionId,
994
+ timestamp: new Date().toISOString(),
995
+ cwd: this.cwd,
996
+ };
997
+ writeSessionFileAtomically(resolvedSessionFile, [header]);
998
+ this.sessionId = nextSessionId;
999
+ this.sessionFile = resolvedSessionFile;
1000
+ this.fileEntries = [header];
1001
+ this._buildIndex();
1002
+ this.appendPersistenceFailure = undefined;
628
1003
  this.flushed = true;
629
1004
  return;
630
1005
  }
631
- const header = this.fileEntries.find((e) => e.type === "session");
632
- this.sessionId = header?.id ?? createSessionId();
633
- if (migrateToCurrentVersion(this.fileEntries)) {
634
- this._rewriteFile();
1006
+ const header = nextFileEntries.find((e) => e.type === "session");
1007
+ const nextSessionId = header?.id ?? createSessionId();
1008
+ const migrated = migrateToCurrentVersion(nextFileEntries);
1009
+ const treeEntries = nextFileEntries.filter((entry) => entry.type !== "session");
1010
+ validateSessionTreeShape(treeEntries);
1011
+ assertSessionTreeCapacity(nextSessionId, resolvedSessionFile, 0, 0, treeEntries.length, measureSessionEntriesLogicalBytes(treeEntries));
1012
+ if (migrated) {
1013
+ writeSessionFileAtomically(resolvedSessionFile, nextFileEntries);
635
1014
  }
1015
+ this.sessionId = nextSessionId;
1016
+ this.sessionFile = resolvedSessionFile;
1017
+ this.fileEntries = nextFileEntries;
636
1018
  this._buildIndex();
1019
+ this.appendPersistenceFailure = undefined;
637
1020
  this.flushed = true;
638
1021
  }
639
1022
  else {
640
- const explicitPath = this.sessionFile;
641
1023
  this.newSession();
642
- this.sessionFile = explicitPath; // preserve explicit path from --session flag
1024
+ this.sessionFile = resolvedSessionFile; // preserve explicit path from --session flag
643
1025
  }
644
1026
  }
645
1027
  newSession(options) {
@@ -661,6 +1043,11 @@ export class SessionManager {
661
1043
  this.labelsById.clear();
662
1044
  this.labelTimestampsById.clear();
663
1045
  this.leafId = null;
1046
+ this.treeEntryCount = 0;
1047
+ this.treeLogicalBytes = 0;
1048
+ this.treeHash = PI_SERVER_EMPTY_TREE_HASH;
1049
+ this.treeHashError = undefined;
1050
+ this.appendPersistenceFailure = undefined;
664
1051
  this.flushed = false;
665
1052
  if (this.persist) {
666
1053
  const fileTimestamp = timestamp.replace(/[:.]/g, "-");
@@ -669,15 +1056,26 @@ export class SessionManager {
669
1056
  return this.sessionFile;
670
1057
  }
671
1058
  _buildIndex() {
1059
+ const entries = this.fileEntries.filter((entry) => entry.type !== "session");
1060
+ validateSessionTreeShape(entries);
1061
+ const logicalBytes = measureSessionEntriesLogicalBytes(entries);
1062
+ assertSessionTreeCapacity(this.sessionId, this.sessionFile, 0, 0, entries.length, logicalBytes);
672
1063
  this.byId.clear();
673
1064
  this.labelsById.clear();
674
1065
  this.labelTimestampsById.clear();
675
1066
  this.leafId = null;
1067
+ this.treeEntryCount = 0;
1068
+ this.treeLogicalBytes = 0;
1069
+ this.treeHash = PI_SERVER_EMPTY_TREE_HASH;
1070
+ this.treeHashError = undefined;
676
1071
  for (const entry of this.fileEntries) {
677
1072
  if (entry.type === "session")
678
1073
  continue;
1074
+ this.treeEntryCount++;
1075
+ this.treeLogicalBytes += measureSessionEntryLogicalBytes(entry);
679
1076
  this.byId.set(entry.id, entry);
680
1077
  this.leafId = entry.id;
1078
+ this._updateTreeHash(entry);
681
1079
  if (entry.type === "label") {
682
1080
  if (entry.label) {
683
1081
  this.labelsById.set(entry.targetId, entry.label);
@@ -690,17 +1088,32 @@ export class SessionManager {
690
1088
  }
691
1089
  }
692
1090
  }
693
- _rewriteFile() {
1091
+ _updateTreeHash(entry) {
1092
+ if (this.treeHash === undefined)
1093
+ return;
1094
+ try {
1095
+ this.treeHash = appendPiServerTreeHash(this.treeHash, hashPiServerTreeEntry(entry));
1096
+ }
1097
+ catch (error) {
1098
+ this.treeHash = undefined;
1099
+ this.treeHashError = error;
1100
+ }
1101
+ }
1102
+ _rewriteFile(entries = this.fileEntries) {
694
1103
  if (!this.persist || !this.sessionFile)
695
1104
  return;
696
- const fd = openSync(this.sessionFile, "w");
1105
+ if (this.appendPersistenceFailure !== undefined) {
1106
+ throw new Error("Session persistence has an indeterminate prior failure; reopen the session before retrying", {
1107
+ cause: this.appendPersistenceFailure,
1108
+ });
1109
+ }
697
1110
  try {
698
- for (const entry of this.fileEntries) {
699
- writeFileSync(fd, `${JSON.stringify(entry)}\n`);
700
- }
1111
+ writeSessionFileAtomically(this.sessionFile, entries);
701
1112
  }
702
- finally {
703
- closeSync(fd);
1113
+ catch (error) {
1114
+ this.appendPersistenceFailure = error;
1115
+ const details = error instanceof Error ? error.message : String(error);
1116
+ throw new Error(`Session file rewrite may have replaced ${this.sessionFile} without a durable in-memory commit; reopen the session before retrying: ${details}`, { cause: error });
704
1117
  }
705
1118
  }
706
1119
  isPersisted() {
@@ -721,41 +1134,118 @@ export class SessionManager {
721
1134
  getSessionFile() {
722
1135
  return this.sessionFile;
723
1136
  }
724
- _persist(entry) {
725
- if (!this.persist || !this.sessionFile)
1137
+ /**
1138
+ * Durably flush the persisted session file before an external acknowledgement.
1139
+ * In-memory sessions and sessions without a materialized file have nothing to flush.
1140
+ */
1141
+ flushSessionFile() {
1142
+ if (!this.persist || !this.sessionFile || !existsSync(this.sessionFile))
726
1143
  return;
727
- const hasAssistant = this.fileEntries.some((e) => e.type === "message" && e.message.role === "assistant");
1144
+ const handle = openSync(this.sessionFile, "r+");
1145
+ try {
1146
+ fsyncSync(handle);
1147
+ }
1148
+ finally {
1149
+ closeSync(handle);
1150
+ }
1151
+ }
1152
+ _appendMaterializedEntry(entry) {
1153
+ if (!this.sessionFile)
1154
+ throw new Error("Cannot append without a session file");
1155
+ const handle = openSync(this.sessionFile, fsConstants.O_WRONLY | fsConstants.O_APPEND);
1156
+ let appendError;
1157
+ try {
1158
+ writeBufferFully(handle, serializeFileEntry(entry));
1159
+ sessionManagerPersistenceTestHooks?.onAppendStage?.("after_append");
1160
+ fsyncSync(handle);
1161
+ sessionManagerPersistenceTestHooks?.onAppendStage?.("after_fsync");
1162
+ }
1163
+ catch (error) {
1164
+ appendError = error;
1165
+ }
1166
+ try {
1167
+ closeSync(handle);
1168
+ }
1169
+ catch (error) {
1170
+ appendError =
1171
+ appendError === undefined
1172
+ ? error
1173
+ : new AggregateError([appendError, error], `Failed to close session file ${this.sessionFile}`);
1174
+ }
1175
+ if (appendError !== undefined) {
1176
+ this.appendPersistenceFailure = appendError;
1177
+ throw new Error(`Session entry ${entry.id} may have reached disk but was not committed in memory; reopen the session before retrying`, { cause: appendError });
1178
+ }
1179
+ }
1180
+ _persistBeforeCommit(entry, nextFileEntries) {
1181
+ if (!this.persist || !this.sessionFile)
1182
+ return this.flushed;
1183
+ const hasAssistant = nextFileEntries.some((candidate) => {
1184
+ return candidate.type === "message" && candidate.message.role === "assistant";
1185
+ });
728
1186
  if (!hasAssistant) {
729
- if (this.flushed) {
730
- appendFileSync(this.sessionFile, `${JSON.stringify(entry)}\n`);
731
- }
732
- else {
733
- // Mark as not flushed so when assistant arrives, all entries get written
734
- this.flushed = false;
735
- }
736
- return;
1187
+ if (this.flushed)
1188
+ this._appendMaterializedEntry(entry);
1189
+ return this.flushed;
1190
+ }
1191
+ if (this.flushed) {
1192
+ this._appendMaterializedEntry(entry);
1193
+ return true;
737
1194
  }
1195
+ try {
1196
+ writeSessionFileAtomically(this.sessionFile, nextFileEntries);
1197
+ }
1198
+ catch (error) {
1199
+ this.appendPersistenceFailure = error;
1200
+ throw new Error(`Session entry ${entry.id} persistence failed before the in-memory commit; reopen the session before retrying`, { cause: error });
1201
+ }
1202
+ return true;
1203
+ }
1204
+ _preflightPersistEntry(entry) {
1205
+ if (!this.persist || !this.sessionFile)
1206
+ return;
1207
+ const entryBytes = serializeFileEntry(entry).byteLength;
738
1208
  if (!this.flushed) {
739
- const fd = openSync(this.sessionFile, "wx");
1209
+ assertSessionFileSize(this.sessionId, this.sessionFile, existsSync(this.sessionFile) ? statSync(this.sessionFile).size : 0, measureSessionFileBytes([...this.fileEntries, entry]));
1210
+ return;
1211
+ }
1212
+ const currentBytes = existsSync(this.sessionFile) ? statSync(this.sessionFile).size : 0;
1213
+ assertSessionFileSize(this.sessionId, this.sessionFile, currentBytes, currentBytes + entryBytes);
1214
+ }
1215
+ _preflightAppendEntry(entry) {
1216
+ const nextLogicalBytes = this.treeLogicalBytes + measureSessionEntryLogicalBytes(entry);
1217
+ assertSessionTreeCapacity(this.sessionId, this.sessionFile, this.treeEntryCount, this.treeLogicalBytes, this.treeEntryCount + 1, nextLogicalBytes);
1218
+ this._preflightPersistEntry(entry);
1219
+ return nextLogicalBytes;
1220
+ }
1221
+ _appendEntry(entry) {
1222
+ if (this.appendPersistenceFailure !== undefined) {
1223
+ throw new Error("Session append persistence has an indeterminate prior failure; reopen the session before retrying", {
1224
+ cause: this.appendPersistenceFailure,
1225
+ });
1226
+ }
1227
+ const nextLogicalBytes = this._preflightAppendEntry(entry);
1228
+ const nextFileEntries = [...this.fileEntries, entry];
1229
+ let nextTreeHash = this.treeHash;
1230
+ let nextTreeHashError = this.treeHashError;
1231
+ if (nextTreeHash !== undefined) {
740
1232
  try {
741
- for (const e of this.fileEntries) {
742
- writeFileSync(fd, `${JSON.stringify(e)}\n`);
743
- }
1233
+ nextTreeHash = appendPiServerTreeHash(nextTreeHash, hashPiServerTreeEntry(entry));
744
1234
  }
745
- finally {
746
- closeSync(fd);
1235
+ catch (error) {
1236
+ nextTreeHash = undefined;
1237
+ nextTreeHashError = error;
747
1238
  }
748
- this.flushed = true;
749
- }
750
- else {
751
- appendFileSync(this.sessionFile, `${JSON.stringify(entry)}\n`);
752
1239
  }
753
- }
754
- _appendEntry(entry) {
1240
+ const nextFlushed = this._persistBeforeCommit(entry, nextFileEntries);
755
1241
  this.fileEntries.push(entry);
1242
+ this.treeEntryCount++;
1243
+ this.treeLogicalBytes = nextLogicalBytes;
756
1244
  this.byId.set(entry.id, entry);
757
1245
  this.leafId = entry.id;
758
- this._persist(entry);
1246
+ this.treeHash = nextTreeHash;
1247
+ this.treeHashError = nextTreeHashError;
1248
+ this.flushed = nextFlushed;
759
1249
  }
760
1250
  /** Append a message as child of current leaf, then advance leaf. Returns entry id.
761
1251
  * Does not allow writing CompactionSummaryMessage and BranchSummaryMessage directly.
@@ -794,9 +1284,12 @@ export class SessionManager {
794
1284
  parentId = entry.id;
795
1285
  return entry;
796
1286
  });
797
- this.fileEntries = [...retainedEntries, ...messageEntries];
1287
+ const nextFileEntries = [...retainedEntries, ...messageEntries];
1288
+ const nextTreeEntries = nextFileEntries.filter((entry) => entry.type !== "session");
1289
+ assertSessionTreeCapacity(this.sessionId, this.sessionFile, this.treeEntryCount, this.treeLogicalBytes, nextTreeEntries.length, measureSessionEntriesLogicalBytes(nextTreeEntries));
1290
+ this._rewriteFile(nextFileEntries);
1291
+ this.fileEntries = nextFileEntries;
798
1292
  this._buildIndex();
799
- this._rewriteFile();
800
1293
  this.flushed = this.persist;
801
1294
  }
802
1295
  replaceTree(entries, leafId) {
@@ -804,27 +1297,145 @@ export class SessionManager {
804
1297
  if (!header) {
805
1298
  throw new Error("Session header is missing");
806
1299
  }
807
- const ids = new Set();
808
- for (const entry of entries) {
809
- if (ids.has(entry.id)) {
810
- throw new Error(`Duplicate session entry id ${entry.id}`);
811
- }
812
- ids.add(entry.id);
813
- }
814
- for (const entry of entries) {
815
- if (entry.parentId !== null && !ids.has(entry.parentId)) {
816
- throw new Error(`Parent entry ${entry.parentId} does not exist`);
817
- }
818
- }
819
- if (leafId !== null && !ids.has(leafId)) {
1300
+ const savedEntries = structuredClone(entries);
1301
+ const nextEntryById = validateSessionTreeShape(savedEntries);
1302
+ if (leafId !== null && !nextEntryById.has(leafId)) {
820
1303
  throw new Error(`Leaf entry ${leafId} does not exist`);
821
1304
  }
822
- this.fileEntries = [header, ...entries.map((entry) => ({ ...entry }))];
1305
+ const nextLogicalBytes = measureSessionEntriesLogicalBytes(savedEntries);
1306
+ assertSessionTreeCapacity(this.sessionId, this.sessionFile, this.treeEntryCount, this.treeLogicalBytes, savedEntries.length, nextLogicalBytes);
1307
+ const nextFileEntries = [header, ...savedEntries];
1308
+ this._rewriteFile(nextFileEntries);
1309
+ this.fileEntries = nextFileEntries;
823
1310
  this._buildIndex();
824
1311
  this.leafId = leafId;
825
- this._rewriteFile();
826
1312
  this.flushed = this.persist;
827
1313
  }
1314
+ /**
1315
+ * Durably append one authoritative compaction entry without rewriting the
1316
+ * existing session history. The caller must provide the exact local base
1317
+ * identity used by the compaction request.
1318
+ *
1319
+ * Re-applying the exact already-durable entry is an idempotent no-op. Any
1320
+ * other base, leaf, parent, or entry-id divergence fails closed.
1321
+ */
1322
+ appendCompactionEntry(entry, base) {
1323
+ if (!Number.isSafeInteger(base.baseEntryCount) ||
1324
+ base.baseEntryCount < 0 ||
1325
+ (base.baseLeafId !== null && (typeof base.baseLeafId !== "string" || base.baseLeafId.length === 0)) ||
1326
+ typeof base.baseTreeHash !== "string" ||
1327
+ !/^[a-f0-9]{64}$/u.test(base.baseTreeHash)) {
1328
+ throw new Error("Compaction append base identity is invalid");
1329
+ }
1330
+ if (entry.type !== "compaction" ||
1331
+ typeof entry.id !== "string" ||
1332
+ entry.id.length === 0 ||
1333
+ typeof entry.timestamp !== "string" ||
1334
+ entry.timestamp.length === 0 ||
1335
+ typeof entry.summary !== "string" ||
1336
+ typeof entry.firstKeptEntryId !== "string" ||
1337
+ entry.firstKeptEntryId.length === 0 ||
1338
+ !Number.isFinite(entry.tokensBefore) ||
1339
+ entry.tokensBefore < 0 ||
1340
+ (entry.retainedTail !== undefined && !Array.isArray(entry.retainedTail))) {
1341
+ throw new Error("Compaction entry is invalid");
1342
+ }
1343
+ let entryHash;
1344
+ try {
1345
+ entryHash = hashPiServerTreeEntry(entry);
1346
+ }
1347
+ catch (error) {
1348
+ throw new Error(`Compaction entry ${entry.id} is not serializable`, { cause: error });
1349
+ }
1350
+ const currentTreeHash = this.treeHash;
1351
+ if (currentTreeHash === undefined) {
1352
+ throw new Error("Cannot validate compaction base because the local tree contains a non-serializable entry", {
1353
+ cause: this.treeHashError,
1354
+ });
1355
+ }
1356
+ const currentEntryCount = this.treeEntryCount;
1357
+ if (currentEntryCount === base.baseEntryCount + 1) {
1358
+ const existing = this.fileEntries.at(-1);
1359
+ if (existing?.type === "compaction" &&
1360
+ existing.id === entry.id &&
1361
+ this.leafId === entry.id &&
1362
+ currentTreeHash === appendPiServerTreeHash(base.baseTreeHash, entryHash) &&
1363
+ persistedEntriesEqual(existing, entry)) {
1364
+ if (this.persist && (!this.sessionFile || !this.flushed || !existsSync(this.sessionFile))) {
1365
+ throw new Error("Cannot verify an already-applied compaction without its materialized session file");
1366
+ }
1367
+ this.flushSessionFile();
1368
+ return entry.id;
1369
+ }
1370
+ throw new Error(`Compaction entry ${entry.id} cannot be replayed because the local tree has a divergent entry after the requested base`);
1371
+ }
1372
+ if (currentEntryCount !== base.baseEntryCount) {
1373
+ throw new Error(`Compaction append base entry count mismatch: expected ${base.baseEntryCount}, current ${currentEntryCount}`);
1374
+ }
1375
+ if (currentTreeHash !== base.baseTreeHash) {
1376
+ throw new Error(`Compaction append base tree hash mismatch`);
1377
+ }
1378
+ if (this.leafId !== base.baseLeafId) {
1379
+ throw new Error(`Compaction append base leaf mismatch: expected ${String(base.baseLeafId)}, current ${String(this.leafId)}`);
1380
+ }
1381
+ if (entry.parentId !== base.baseLeafId) {
1382
+ throw new Error(`Compaction entry parent mismatch: expected ${String(base.baseLeafId)}, received ${String(entry.parentId)}`);
1383
+ }
1384
+ if (base.baseLeafId !== null && !this.byId.has(base.baseLeafId)) {
1385
+ throw new Error(`Compaction append base leaf ${base.baseLeafId} does not exist`);
1386
+ }
1387
+ if (!this.byId.has(entry.firstKeptEntryId)) {
1388
+ throw new Error(`Compaction first-kept entry ${entry.firstKeptEntryId} does not exist`);
1389
+ }
1390
+ if (this.byId.has(entry.id)) {
1391
+ throw new Error(`Compaction entry id ${entry.id} already exists`);
1392
+ }
1393
+ if (this.appendPersistenceFailure !== undefined) {
1394
+ throw new Error("Compaction append persistence has an indeterminate prior failure; reopen the session before retrying", { cause: this.appendPersistenceFailure });
1395
+ }
1396
+ const savedEntry = { ...entry };
1397
+ const nextLogicalBytes = this.treeLogicalBytes + measureSessionEntryLogicalBytes(savedEntry);
1398
+ assertSessionTreeCapacity(this.sessionId, this.sessionFile, this.treeEntryCount, this.treeLogicalBytes, this.treeEntryCount + 1, nextLogicalBytes);
1399
+ const serializedEntry = serializeFileEntry(savedEntry);
1400
+ if (this.persist) {
1401
+ if (!this.sessionFile || !this.flushed || !existsSync(this.sessionFile)) {
1402
+ throw new Error("Cannot durably append compaction before the session file is materialized");
1403
+ }
1404
+ const currentFileBytes = statSync(this.sessionFile).size;
1405
+ assertSessionFileSize(this.sessionId, this.sessionFile, currentFileBytes, currentFileBytes + serializedEntry.byteLength);
1406
+ const handle = openSync(this.sessionFile, fsConstants.O_WRONLY | fsConstants.O_APPEND);
1407
+ let appendError;
1408
+ try {
1409
+ writeBufferFully(handle, serializedEntry);
1410
+ sessionManagerPersistenceTestHooks?.onCompactionAppendStage?.("after_append");
1411
+ fsyncSync(handle);
1412
+ sessionManagerPersistenceTestHooks?.onCompactionAppendStage?.("after_fsync");
1413
+ }
1414
+ catch (error) {
1415
+ appendError = error;
1416
+ }
1417
+ try {
1418
+ closeSync(handle);
1419
+ }
1420
+ catch (error) {
1421
+ appendError =
1422
+ appendError === undefined
1423
+ ? error
1424
+ : new AggregateError([appendError, error], `Failed to close session file ${this.sessionFile}`);
1425
+ }
1426
+ if (appendError !== undefined) {
1427
+ this.appendPersistenceFailure = appendError;
1428
+ throw new Error(`Compaction entry ${entry.id} may have reached disk but was not committed in memory; reopen the session before retrying`, { cause: appendError });
1429
+ }
1430
+ }
1431
+ this.fileEntries.push(savedEntry);
1432
+ this.treeEntryCount++;
1433
+ this.treeLogicalBytes = nextLogicalBytes;
1434
+ this.byId.set(savedEntry.id, savedEntry);
1435
+ this.leafId = savedEntry.id;
1436
+ this.treeHash = appendPiServerTreeHash(currentTreeHash, entryHash);
1437
+ return savedEntry.id;
1438
+ }
828
1439
  /** Append a thinking level change as child of current leaf, then advance leaf. Returns entry id. */
829
1440
  appendThinkingLevelChange(thinkingLevel) {
830
1441
  const entry = {
@@ -851,7 +1462,7 @@ export class SessionManager {
851
1462
  return entry.id;
852
1463
  }
853
1464
  /** Append a compaction summary as child of current leaf, then advance leaf. Returns entry id. */
854
- appendCompaction(summary, firstKeptEntryId, tokensBefore, details, fromHook, usage) {
1465
+ appendCompaction(summary, firstKeptEntryId, tokensBefore, details, fromHook, usage, retainedTail) {
855
1466
  const entry = {
856
1467
  type: "compaction",
857
1468
  id: generateId(this.byId),
@@ -860,6 +1471,7 @@ export class SessionManager {
860
1471
  summary,
861
1472
  firstKeptEntryId,
862
1473
  tokensBefore,
1474
+ retainedTail,
863
1475
  details,
864
1476
  usage,
865
1477
  fromHook,
@@ -1105,7 +1717,6 @@ export class SessionManager {
1105
1717
  if (branchFromId !== null && !this.byId.has(branchFromId)) {
1106
1718
  throw new Error(`Entry ${branchFromId} not found`);
1107
1719
  }
1108
- this.leafId = branchFromId;
1109
1720
  const entry = {
1110
1721
  type: "branch_summary",
1111
1722
  id: generateId(this.byId),
@@ -1162,62 +1773,42 @@ export class SessionManager {
1162
1773
  labelsToWrite.push({ targetId, label, timestamp: this.labelTimestampsById.get(targetId) });
1163
1774
  }
1164
1775
  }
1165
- if (this.persist) {
1166
- // Build label entries
1167
- const lastEntryId = pathWithoutLabels[pathWithoutLabels.length - 1]?.id || null;
1168
- let parentId = lastEntryId;
1169
- const labelEntries = [];
1170
- for (const { targetId, label, timestamp: labelTimestamp } of labelsToWrite) {
1171
- const labelEntry = {
1172
- type: "label",
1173
- id: generateId(new Set(pathEntryIds)),
1174
- parentId,
1175
- timestamp: labelTimestamp,
1176
- targetId,
1177
- label,
1178
- };
1179
- pathEntryIds.add(labelEntry.id);
1180
- labelEntries.push(labelEntry);
1181
- parentId = labelEntry.id;
1182
- }
1183
- this.fileEntries = [header, ...pathWithoutLabels, ...labelEntries];
1184
- this.sessionId = newSessionId;
1185
- this.sessionFile = newSessionFile;
1186
- this._buildIndex();
1187
- // Only write the file now if it contains an assistant message.
1188
- // Otherwise defer to _persist(), which creates the file on the
1189
- // first assistant response, matching the newSession() contract
1190
- // and avoiding the duplicate-header bug when _persist()'s
1191
- // no-assistant guard later resets flushed to false.
1192
- const hasAssistant = this.fileEntries.some((e) => e.type === "message" && e.message.role === "assistant");
1193
- if (hasAssistant) {
1194
- this._rewriteFile();
1195
- this.flushed = true;
1196
- }
1197
- else {
1198
- this.flushed = false;
1199
- }
1200
- return newSessionFile;
1201
- }
1202
- // In-memory mode: replace current session with the path + labels
1203
1776
  const labelEntries = [];
1777
+ const labelEntryIds = new Set(pathEntryIds);
1204
1778
  let parentId = pathWithoutLabels[pathWithoutLabels.length - 1]?.id || null;
1205
1779
  for (const { targetId, label, timestamp: labelTimestamp } of labelsToWrite) {
1206
1780
  const labelEntry = {
1207
1781
  type: "label",
1208
- id: generateId(new Set([...pathEntryIds, ...labelEntries.map((e) => e.id)])),
1782
+ id: generateId(labelEntryIds),
1209
1783
  parentId,
1210
1784
  timestamp: labelTimestamp,
1211
1785
  targetId,
1212
1786
  label,
1213
1787
  };
1788
+ labelEntryIds.add(labelEntry.id);
1214
1789
  labelEntries.push(labelEntry);
1215
1790
  parentId = labelEntry.id;
1216
1791
  }
1217
- this.fileEntries = [header, ...pathWithoutLabels, ...labelEntries];
1792
+ const nextTreeEntries = [...pathWithoutLabels, ...labelEntries];
1793
+ const nextFileEntries = [header, ...nextTreeEntries];
1794
+ assertSessionTreeCapacity(newSessionId, this.persist ? newSessionFile : undefined, this.treeEntryCount, this.treeLogicalBytes, nextTreeEntries.length, measureSessionEntriesLogicalBytes(nextTreeEntries));
1795
+ const hasAssistant = nextFileEntries.some((entry) => {
1796
+ return entry.type === "message" && entry.message.role === "assistant";
1797
+ });
1798
+ if (this.persist) {
1799
+ assertSessionFileSize(newSessionId, newSessionFile, existsSync(newSessionFile) ? statSync(newSessionFile).size : 0, measureSessionFileBytes(nextFileEntries));
1800
+ if (hasAssistant) {
1801
+ writeSessionFileAtomically(newSessionFile, nextFileEntries);
1802
+ }
1803
+ }
1804
+ this.fileEntries = nextFileEntries;
1218
1805
  this.sessionId = newSessionId;
1806
+ if (this.persist)
1807
+ this.sessionFile = newSessionFile;
1219
1808
  this._buildIndex();
1220
- return undefined;
1809
+ this.appendPersistenceFailure = undefined;
1810
+ this.flushed = this.persist && hasAssistant;
1811
+ return this.persist ? newSessionFile : undefined;
1221
1812
  }
1222
1813
  /**
1223
1814
  * Create a new session.
@@ -1293,10 +1884,6 @@ export class SessionManager {
1293
1884
  if (!sourceHeader) {
1294
1885
  throw new Error(`Cannot fork: source session has no header: ${resolvedSourcePath}`);
1295
1886
  }
1296
- const dir = sessionDir ? normalizePath(sessionDir) : getDefaultSessionDir(resolvedTargetCwd);
1297
- if (!existsSync(dir)) {
1298
- mkdirSync(dir, { recursive: true });
1299
- }
1300
1887
  // Create new session file with new ID but forked content
1301
1888
  if (options?.id !== undefined) {
1302
1889
  assertValidSessionId(options.id);
@@ -1304,6 +1891,7 @@ export class SessionManager {
1304
1891
  const newSessionId = options?.id ?? createSessionId();
1305
1892
  const timestamp = new Date().toISOString();
1306
1893
  const fileTimestamp = timestamp.replace(/[:.]/g, "-");
1894
+ const dir = sessionDir ? normalizePath(sessionDir) : getDefaultSessionDirPath(resolvedTargetCwd);
1307
1895
  const newSessionFile = join(dir, `${fileTimestamp}_${newSessionId}.jsonl`);
1308
1896
  // Write new header pointing to source as parent, with updated cwd
1309
1897
  const newHeader = {
@@ -1314,13 +1902,16 @@ export class SessionManager {
1314
1902
  cwd: resolvedTargetCwd,
1315
1903
  parentSession: resolvedSourcePath,
1316
1904
  };
1317
- writeFileSync(newSessionFile, `${JSON.stringify(newHeader)}\n`, { flag: "wx" });
1318
- // Copy all non-header entries from source
1319
- for (const entry of sourceEntries) {
1320
- if (entry.type !== "session") {
1321
- appendFileSync(newSessionFile, `${JSON.stringify(entry)}\n`);
1322
- }
1905
+ const migratedSourceEntries = structuredClone(sourceEntries);
1906
+ migrateToCurrentVersion(migratedSourceEntries);
1907
+ const copiedEntries = migratedSourceEntries.filter((entry) => entry.type !== "session");
1908
+ assertSessionTreeCapacity(newSessionId, newSessionFile, 0, 0, copiedEntries.length, measureSessionEntriesLogicalBytes(copiedEntries));
1909
+ const nextFileEntries = [newHeader, ...copiedEntries];
1910
+ assertSessionFileSize(newSessionId, newSessionFile, 0, measureSessionFileBytes(nextFileEntries));
1911
+ if (!existsSync(dir)) {
1912
+ mkdirSync(dir, { recursive: true });
1323
1913
  }
1914
+ writeSessionFileAtomically(newSessionFile, nextFileEntries);
1324
1915
  return new SessionManager(resolvedTargetCwd, dir, newSessionFile, true);
1325
1916
  }
1326
1917
  /**
@@ -1381,7 +1972,9 @@ export class SessionManager {
1381
1972
  sessions.sort((a, b) => b.modified.getTime() - a.modified.getTime());
1382
1973
  return sessions;
1383
1974
  }
1384
- catch {
1975
+ catch (error) {
1976
+ if (error instanceof SessionManagerCapacityError || error instanceof SessionFileCorruptionError)
1977
+ throw error;
1385
1978
  return [];
1386
1979
  }
1387
1980
  }