@averyyy/pi-coding-agent 0.82.1-piclient.6 → 0.82.1-piclient.7

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 (58) hide show
  1. package/CHANGELOG.md +0 -12
  2. package/dist/cli/args.d.ts.map +1 -1
  3. package/dist/cli/args.js +1 -2
  4. package/dist/cli/args.js.map +1 -1
  5. package/dist/core/agent-session-runtime.d.ts.map +1 -1
  6. package/dist/core/agent-session-runtime.js +0 -5
  7. package/dist/core/agent-session-runtime.js.map +1 -1
  8. package/dist/core/agent-session.d.ts +1 -12
  9. package/dist/core/agent-session.d.ts.map +1 -1
  10. package/dist/core/agent-session.js +89 -317
  11. package/dist/core/agent-session.js.map +1 -1
  12. package/dist/core/pi-server-client.d.ts +2 -54
  13. package/dist/core/pi-server-client.d.ts.map +1 -1
  14. package/dist/core/pi-server-client.js +272 -2320
  15. package/dist/core/pi-server-client.js.map +1 -1
  16. package/dist/core/pi-server-protocol.d.ts +0 -2
  17. package/dist/core/pi-server-protocol.d.ts.map +1 -1
  18. package/dist/core/pi-server-protocol.js +1 -24
  19. package/dist/core/pi-server-protocol.js.map +1 -1
  20. package/dist/core/pi-server-request.d.ts +0 -28
  21. package/dist/core/pi-server-request.d.ts.map +1 -1
  22. package/dist/core/pi-server-request.js +75 -492
  23. package/dist/core/pi-server-request.js.map +1 -1
  24. package/dist/core/sdk.d.ts.map +1 -1
  25. package/dist/core/sdk.js +53 -69
  26. package/dist/core/sdk.js.map +1 -1
  27. package/dist/core/session-manager.d.ts +2 -86
  28. package/dist/core/session-manager.d.ts.map +1 -1
  29. package/dist/core/session-manager.js +144 -737
  30. package/dist/core/session-manager.js.map +1 -1
  31. package/dist/core/tools/output-accumulator.d.ts +1 -3
  32. package/dist/core/tools/output-accumulator.d.ts.map +1 -1
  33. package/dist/core/tools/output-accumulator.js +26 -46
  34. package/dist/core/tools/output-accumulator.js.map +1 -1
  35. package/dist/main.d.ts.map +1 -1
  36. package/dist/main.js +0 -13
  37. package/dist/main.js.map +1 -1
  38. package/dist/modes/interactive/components/session-selector.d.ts +0 -15
  39. package/dist/modes/interactive/components/session-selector.d.ts.map +1 -1
  40. package/dist/modes/interactive/components/session-selector.js +29 -98
  41. package/dist/modes/interactive/components/session-selector.js.map +1 -1
  42. package/package.json +4 -4
  43. package/dist/cli/tool-effects.d.ts +0 -6
  44. package/dist/cli/tool-effects.d.ts.map +0 -1
  45. package/dist/cli/tool-effects.js +0 -164
  46. package/dist/cli/tool-effects.js.map +0 -1
  47. package/dist/core/pi-server-compact-state.d.ts +0 -81
  48. package/dist/core/pi-server-compact-state.d.ts.map +0 -1
  49. package/dist/core/pi-server-compact-state.js +0 -439
  50. package/dist/core/pi-server-compact-state.js.map +0 -1
  51. package/dist/core/pi-server-run-state.d.ts +0 -42
  52. package/dist/core/pi-server-run-state.d.ts.map +0 -1
  53. package/dist/core/pi-server-run-state.js +0 -320
  54. package/dist/core/pi-server-run-state.js.map +0 -1
  55. package/dist/core/tool-effect-journal.d.ts +0 -103
  56. package/dist/core/tool-effect-journal.d.ts.map +0 -1
  57. package/dist/core/tool-effect-journal.js +0 -1442
  58. package/dist/core/tool-effect-journal.js.map +0 -1
@@ -1,61 +1,14 @@
1
1
  import { uuidv7 } from "@earendil-works/pi-ai";
2
2
  import { randomUUID } from "crypto";
3
- import { closeSync, createReadStream, existsSync, constants as fsConstants, fsyncSync, mkdirSync, openSync, readdirSync, readSync, renameSync, statSync, unlinkSync, writeSync, } from "fs";
3
+ import { appendFileSync, closeSync, createReadStream, existsSync, mkdirSync, openSync, readdirSync, readSync, statSync, writeFileSync, } from "fs";
4
4
  import { readdir, stat } from "fs/promises";
5
- import { basename, dirname, join, resolve } from "path";
5
+ import { 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";
10
8
  import { getAgentDir as getDefaultAgentDir, getSessionsDir } from "../config.js";
11
9
  import { normalizePath, resolvePath } from "../utils/paths.js";
12
10
  import { createBranchSummaryMessage, createCompactionSummaryMessage, createCustomMessage, } from "./messages.js";
13
- import { appendPiServerTreeHash, hashPiServerTreeEntry, PI_SERVER_EMPTY_TREE_HASH } from "./pi-server-protocol.js";
14
11
  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
- }
59
12
  function createSessionId() {
60
13
  return uuidv7();
61
14
  }
@@ -168,40 +121,6 @@ function buildEntryIndex(entries, byId) {
168
121
  }
169
122
  return index;
170
123
  }
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
- }
205
124
  function buildSessionPath(entries, leafId, byId) {
206
125
  const index = buildEntryIndex(entries, byId);
207
126
  let leaf;
@@ -217,12 +136,7 @@ function buildSessionPath(entries, leafId, byId) {
217
136
  }
218
137
  const path = [];
219
138
  let current = leaf;
220
- const seen = new Set();
221
139
  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);
226
140
  path.push(current);
227
141
  current = current.parentId ? index.get(current.parentId) : undefined;
228
142
  }
@@ -269,10 +183,7 @@ export function sessionEntryToContextMessages(entry) {
269
183
  return [createBranchSummaryMessage(entry.summary, entry.fromId, entry.timestamp)];
270
184
  }
271
185
  if (entry.type === "compaction") {
272
- return [
273
- createCompactionSummaryMessage(entry.summary, entry.tokensBefore, entry.timestamp),
274
- ...(entry.retainedTail ?? []),
275
- ];
186
+ return [createCompactionSummaryMessage(entry.summary, entry.tokensBefore, entry.timestamp)];
276
187
  }
277
188
  return [];
278
189
  }
@@ -300,10 +211,6 @@ export function buildContextEntries(entries, leafId, byId) {
300
211
  return path;
301
212
  }
302
213
  const contextEntries = [compaction];
303
- if (compaction.retainedTail !== undefined) {
304
- contextEntries.push(...path.slice(compactionIdx + 1));
305
- return contextEntries;
306
- }
307
214
  let foundFirstKept = false;
308
215
  for (let i = 0; i < compactionIdx; i++) {
309
216
  const entry = path[i];
@@ -349,238 +256,21 @@ const SESSION_READ_BUFFER_SIZE = 1024 * 1024;
349
256
  const SESSION_HEADER_READ_BUFFER_SIZE = 4096;
350
257
  /** Bound synchronous header discovery while allowing large cwd and custom metadata fields. */
351
258
  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
- }
560
259
  class SessionHeaderScanLimitError extends Error {
561
260
  constructor(filePath) {
562
261
  super(`Session header exceeds ${MAX_SESSION_HEADER_SCAN_BYTES}-byte scan limit: ${filePath}`);
563
262
  this.name = "SessionHeaderScanLimitError";
564
263
  }
565
264
  }
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) {
265
+ function parseSessionEntryLine(line) {
577
266
  if (!line.trim())
578
267
  return null;
579
268
  try {
580
269
  return JSON.parse(line);
581
270
  }
582
- catch (error) {
583
- throw new SessionFileCorruptionError(filePath, lineNumber, error);
271
+ catch {
272
+ // Skip malformed lines
273
+ return null;
584
274
  }
585
275
  }
586
276
  /** Exported for testing */
@@ -588,17 +278,12 @@ export function loadEntriesFromFile(filePath) {
588
278
  const resolvedFilePath = normalizePath(filePath);
589
279
  if (!existsSync(resolvedFilePath))
590
280
  return [];
591
- const fileBytes = statSync(resolvedFilePath).size;
592
- assertSessionFileSize("<unresolved>", resolvedFilePath, 0, fileBytes);
593
281
  const entries = [];
594
- let treeEntryCount = 0;
595
- let treeLogicalBytes = 0;
596
282
  const fd = openSync(resolvedFilePath, "r");
597
283
  try {
598
284
  const decoder = new StringDecoder("utf8");
599
285
  const buffer = Buffer.allocUnsafe(SESSION_READ_BUFFER_SIZE);
600
286
  let pending = "";
601
- let lineNumber = 1;
602
287
  while (true) {
603
288
  const bytesRead = readSync(fd, buffer, 0, buffer.length, null);
604
289
  if (bytesRead === 0)
@@ -607,33 +292,18 @@ export function loadEntriesFromFile(filePath) {
607
292
  let lineStart = 0;
608
293
  let newlineIndex = pending.indexOf("\n", lineStart);
609
294
  while (newlineIndex !== -1) {
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
- }
295
+ const entry = parseSessionEntryLine(pending.slice(lineStart, newlineIndex));
296
+ if (entry)
618
297
  entries.push(entry);
619
- }
620
298
  lineStart = newlineIndex + 1;
621
- lineNumber++;
622
299
  newlineIndex = pending.indexOf("\n", lineStart);
623
300
  }
624
301
  pending = pending.slice(lineStart);
625
302
  }
626
303
  pending += decoder.end();
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
- }
304
+ const finalEntry = parseSessionEntryLine(pending);
305
+ if (finalEntry)
635
306
  entries.push(finalEntry);
636
- }
637
307
  }
638
308
  finally {
639
309
  closeSync(fd);
@@ -649,13 +319,13 @@ export function loadEntriesFromFile(filePath) {
649
319
  }
650
320
  /**
651
321
  * Inspect a physical line while searching for the first parsed session entry.
652
- * Blank lines are skipped; malformed JSON fails closed.
322
+ * Blank and malformed lines are skipped to match loadEntriesFromFile().
653
323
  * Returns undefined to keep scanning, null for a parsed non-header entry, or the header.
654
324
  */
655
- function parseSessionHeaderCandidate(line, filePath, lineNumber) {
325
+ function parseSessionHeaderCandidate(line) {
656
326
  if (!line.trim())
657
327
  return undefined;
658
- const entry = parseSessionEntryLineStrict(line, filePath, lineNumber);
328
+ const entry = parseSessionEntryLine(line);
659
329
  if (!entry)
660
330
  return undefined;
661
331
  if (entry.type !== "session" || typeof entry.id !== "string")
@@ -663,21 +333,18 @@ function parseSessionHeaderCandidate(line, filePath, lineNumber) {
663
333
  return entry;
664
334
  }
665
335
  function readSessionHeader(filePath) {
666
- const fileBytes = statSync(filePath).size;
667
- assertSessionFileSize("<unresolved>", filePath, 0, fileBytes);
668
336
  const fd = openSync(filePath, "r");
669
337
  try {
670
338
  const decoder = new StringDecoder("utf8");
671
339
  const buffer = Buffer.allocUnsafe(SESSION_HEADER_READ_BUFFER_SIZE);
672
340
  const lineChunks = [];
673
341
  let scannedBytes = 0;
674
- let lineNumber = 1;
675
342
  while (scannedBytes < MAX_SESSION_HEADER_SCAN_BYTES) {
676
343
  const readLength = Math.min(buffer.length, MAX_SESSION_HEADER_SCAN_BYTES - scannedBytes);
677
344
  const bytesRead = readSync(fd, buffer, 0, readLength, null);
678
345
  if (bytesRead === 0) {
679
346
  lineChunks.push(decoder.end());
680
- return parseSessionHeaderCandidate(lineChunks.join(""), filePath, lineNumber) ?? null;
347
+ return parseSessionHeaderCandidate(lineChunks.join("")) ?? null;
681
348
  }
682
349
  scannedBytes += bytesRead;
683
350
  const chunk = decoder.write(buffer.subarray(0, bytesRead));
@@ -685,12 +352,11 @@ function readSessionHeader(filePath) {
685
352
  let newlineIndex = chunk.indexOf("\n", lineStart);
686
353
  while (newlineIndex !== -1) {
687
354
  lineChunks.push(chunk.slice(lineStart, newlineIndex));
688
- const header = parseSessionHeaderCandidate(lineChunks.join(""), filePath, lineNumber);
355
+ const header = parseSessionHeaderCandidate(lineChunks.join(""));
689
356
  if (header !== undefined)
690
357
  return header;
691
358
  lineChunks.length = 0;
692
359
  lineStart = newlineIndex + 1;
693
- lineNumber++;
694
360
  newlineIndex = chunk.indexOf("\n", lineStart);
695
361
  }
696
362
  lineChunks.push(chunk.slice(lineStart));
@@ -700,7 +366,7 @@ function readSessionHeader(filePath) {
700
366
  const probe = Buffer.allocUnsafe(1);
701
367
  if (readSync(fd, probe, 0, probe.length, null) === 0) {
702
368
  lineChunks.push(decoder.end());
703
- return parseSessionHeaderCandidate(lineChunks.join(""), filePath, lineNumber) ?? null;
369
+ return parseSessionHeaderCandidate(lineChunks.join("")) ?? null;
704
370
  }
705
371
  throw new SessionHeaderScanLimitError(filePath);
706
372
  }
@@ -712,10 +378,9 @@ function readSessionHeaderForDiscovery(filePath) {
712
378
  try {
713
379
  return readSessionHeader(filePath);
714
380
  }
715
- catch (error) {
716
- if (error instanceof SessionManagerCapacityError || error instanceof SessionFileCorruptionError)
717
- throw error;
718
- // Discovery remains best-effort for transient filesystem failures.
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.
719
384
  return null;
720
385
  }
721
386
  }
@@ -741,9 +406,7 @@ export function findMostRecentSession(sessionDir, cwd) {
741
406
  .sort((a, b) => b.mtime.getTime() - a.mtime.getTime());
742
407
  return files[0]?.path || null;
743
408
  }
744
- catch (error) {
745
- if (error instanceof SessionManagerCapacityError || error instanceof SessionFileCorruptionError)
746
- throw error;
409
+ catch {
747
410
  // Directory access and stat races make recent-session discovery unavailable.
748
411
  return null;
749
412
  }
@@ -777,23 +440,18 @@ function getMessageActivityTime(entry) {
777
440
  async function buildSessionInfo(filePath) {
778
441
  try {
779
442
  const stats = await stat(filePath);
780
- assertSessionFileSize("<unresolved>", filePath, 0, stats.size);
781
443
  let header = null;
782
444
  let messageCount = 0;
783
445
  let firstMessage = "";
784
446
  const allMessages = [];
785
447
  let name;
786
448
  let lastActivityTime;
787
- let treeEntryCount = 0;
788
- let treeLogicalBytes = 0;
789
- let lineNumber = 0;
790
449
  const rl = createInterface({
791
450
  input: createReadStream(filePath, { encoding: "utf8" }),
792
451
  crlfDelay: Infinity,
793
452
  });
794
453
  for await (const line of rl) {
795
- lineNumber++;
796
- const entry = parseSessionEntryLineStrict(line, filePath, lineNumber);
454
+ const entry = parseSessionEntryLine(line);
797
455
  if (!entry)
798
456
  continue;
799
457
  if (!header) {
@@ -802,12 +460,6 @@ async function buildSessionInfo(filePath) {
802
460
  header = entry;
803
461
  continue;
804
462
  }
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
- }
811
463
  // Extract session name (use latest, including explicit clears)
812
464
  if (entry.type === "session_info") {
813
465
  name = entry.name?.trim() || undefined;
@@ -855,9 +507,7 @@ async function buildSessionInfo(filePath) {
855
507
  allMessagesText: allMessages.join(" "),
856
508
  };
857
509
  }
858
- catch (error) {
859
- if (error instanceof SessionManagerCapacityError || error instanceof SessionFileCorruptionError)
860
- throw error;
510
+ catch {
861
511
  return null;
862
512
  }
863
513
  }
@@ -876,9 +526,7 @@ async function buildSessionInfosWithConcurrency(files, onLoaded) {
876
526
  .then((info) => {
877
527
  results[index] = info;
878
528
  })
879
- .catch((error) => {
880
- if (error instanceof SessionManagerCapacityError || error instanceof SessionFileCorruptionError)
881
- throw error;
529
+ .catch(() => {
882
530
  results[index] = null;
883
531
  })
884
532
  .finally(() => {
@@ -892,13 +540,7 @@ async function buildSessionInfosWithConcurrency(files, onLoaded) {
892
540
  startNext();
893
541
  }
894
542
  if (inFlight.size > 0) {
895
- try {
896
- await Promise.race(inFlight);
897
- }
898
- catch (error) {
899
- await Promise.allSettled([...inFlight]);
900
- throw error;
901
- }
543
+ await Promise.race(inFlight);
902
544
  }
903
545
  }
904
546
  return results;
@@ -923,9 +565,7 @@ async function listSessionsFromDir(dir, onProgress, progressOffset = 0, progress
923
565
  }
924
566
  }
925
567
  }
926
- catch (error) {
927
- if (error instanceof SessionManagerCapacityError || error instanceof SessionFileCorruptionError)
928
- throw error;
568
+ catch {
929
569
  // Return empty list on error
930
570
  }
931
571
  return sessions;
@@ -953,11 +593,6 @@ export class SessionManager {
953
593
  labelsById = new Map();
954
594
  labelTimestampsById = new Map();
955
595
  leafId = null;
956
- treeEntryCount = 0;
957
- treeLogicalBytes = 0;
958
- treeHash = PI_SERVER_EMPTY_TREE_HASH;
959
- treeHashError;
960
- appendPersistenceFailure;
961
596
  constructor(cwd, sessionDir, sessionFile, persist, newSessionOptions, preloadedFileEntries) {
962
597
  this.cwd = resolvePath(cwd);
963
598
  this.sessionDir = normalizePath(sessionDir);
@@ -977,51 +612,34 @@ export class SessionManager {
977
612
  this._setSessionFile(sessionFile);
978
613
  }
979
614
  _setSessionFile(sessionFile, preloadedFileEntries) {
980
- const resolvedSessionFile = resolvePath(sessionFile);
981
- if (existsSync(resolvedSessionFile)) {
982
- const nextFileEntries = structuredClone(preloadedFileEntries ?? loadEntriesFromFile(resolvedSessionFile));
615
+ this.sessionFile = resolvePath(sessionFile);
616
+ if (existsSync(this.sessionFile)) {
617
+ this.fileEntries = preloadedFileEntries ?? loadEntriesFromFile(this.sessionFile);
983
618
  // If file was empty, initialize it with a valid session header. If it was
984
619
  // non-empty but did not parse as a pi session, fail without modifying it.
985
- if (nextFileEntries.length === 0) {
986
- if (statSync(resolvedSessionFile).size > 0) {
987
- throw new Error(`Session file is not a valid pi session: ${resolvedSessionFile}`);
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}`);
988
624
  }
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;
625
+ this.newSession();
626
+ this.sessionFile = explicitPath;
627
+ this._rewriteFile();
1003
628
  this.flushed = true;
1004
629
  return;
1005
630
  }
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);
631
+ const header = this.fileEntries.find((e) => e.type === "session");
632
+ this.sessionId = header?.id ?? createSessionId();
633
+ if (migrateToCurrentVersion(this.fileEntries)) {
634
+ this._rewriteFile();
1014
635
  }
1015
- this.sessionId = nextSessionId;
1016
- this.sessionFile = resolvedSessionFile;
1017
- this.fileEntries = nextFileEntries;
1018
636
  this._buildIndex();
1019
- this.appendPersistenceFailure = undefined;
1020
637
  this.flushed = true;
1021
638
  }
1022
639
  else {
640
+ const explicitPath = this.sessionFile;
1023
641
  this.newSession();
1024
- this.sessionFile = resolvedSessionFile; // preserve explicit path from --session flag
642
+ this.sessionFile = explicitPath; // preserve explicit path from --session flag
1025
643
  }
1026
644
  }
1027
645
  newSession(options) {
@@ -1043,11 +661,6 @@ export class SessionManager {
1043
661
  this.labelsById.clear();
1044
662
  this.labelTimestampsById.clear();
1045
663
  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;
1051
664
  this.flushed = false;
1052
665
  if (this.persist) {
1053
666
  const fileTimestamp = timestamp.replace(/[:.]/g, "-");
@@ -1056,26 +669,15 @@ export class SessionManager {
1056
669
  return this.sessionFile;
1057
670
  }
1058
671
  _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);
1063
672
  this.byId.clear();
1064
673
  this.labelsById.clear();
1065
674
  this.labelTimestampsById.clear();
1066
675
  this.leafId = null;
1067
- this.treeEntryCount = 0;
1068
- this.treeLogicalBytes = 0;
1069
- this.treeHash = PI_SERVER_EMPTY_TREE_HASH;
1070
- this.treeHashError = undefined;
1071
676
  for (const entry of this.fileEntries) {
1072
677
  if (entry.type === "session")
1073
678
  continue;
1074
- this.treeEntryCount++;
1075
- this.treeLogicalBytes += measureSessionEntryLogicalBytes(entry);
1076
679
  this.byId.set(entry.id, entry);
1077
680
  this.leafId = entry.id;
1078
- this._updateTreeHash(entry);
1079
681
  if (entry.type === "label") {
1080
682
  if (entry.label) {
1081
683
  this.labelsById.set(entry.targetId, entry.label);
@@ -1088,32 +690,17 @@ export class SessionManager {
1088
690
  }
1089
691
  }
1090
692
  }
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) {
693
+ _rewriteFile() {
1103
694
  if (!this.persist || !this.sessionFile)
1104
695
  return;
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
- }
696
+ const fd = openSync(this.sessionFile, "w");
1110
697
  try {
1111
- writeSessionFileAtomically(this.sessionFile, entries);
698
+ for (const entry of this.fileEntries) {
699
+ writeFileSync(fd, `${JSON.stringify(entry)}\n`);
700
+ }
1112
701
  }
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 });
702
+ finally {
703
+ closeSync(fd);
1117
704
  }
1118
705
  }
1119
706
  isPersisted() {
@@ -1134,118 +721,41 @@ export class SessionManager {
1134
721
  getSessionFile() {
1135
722
  return this.sessionFile;
1136
723
  }
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))
1143
- return;
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
- });
1186
- if (!hasAssistant) {
1187
- if (this.flushed)
1188
- this._appendMaterializedEntry(entry);
1189
- return this.flushed;
1190
- }
1191
- if (this.flushed) {
1192
- this._appendMaterializedEntry(entry);
1193
- return true;
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) {
724
+ _persist(entry) {
1205
725
  if (!this.persist || !this.sessionFile)
1206
726
  return;
1207
- const entryBytes = serializeFileEntry(entry).byteLength;
1208
- if (!this.flushed) {
1209
- assertSessionFileSize(this.sessionId, this.sessionFile, existsSync(this.sessionFile) ? statSync(this.sessionFile).size : 0, measureSessionFileBytes([...this.fileEntries, entry]));
727
+ const hasAssistant = this.fileEntries.some((e) => e.type === "message" && e.message.role === "assistant");
728
+ 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
+ }
1210
736
  return;
1211
737
  }
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) {
738
+ if (!this.flushed) {
739
+ const fd = openSync(this.sessionFile, "wx");
1232
740
  try {
1233
- nextTreeHash = appendPiServerTreeHash(nextTreeHash, hashPiServerTreeEntry(entry));
741
+ for (const e of this.fileEntries) {
742
+ writeFileSync(fd, `${JSON.stringify(e)}\n`);
743
+ }
1234
744
  }
1235
- catch (error) {
1236
- nextTreeHash = undefined;
1237
- nextTreeHashError = error;
745
+ finally {
746
+ closeSync(fd);
1238
747
  }
748
+ this.flushed = true;
749
+ }
750
+ else {
751
+ appendFileSync(this.sessionFile, `${JSON.stringify(entry)}\n`);
1239
752
  }
1240
- const nextFlushed = this._persistBeforeCommit(entry, nextFileEntries);
753
+ }
754
+ _appendEntry(entry) {
1241
755
  this.fileEntries.push(entry);
1242
- this.treeEntryCount++;
1243
- this.treeLogicalBytes = nextLogicalBytes;
1244
756
  this.byId.set(entry.id, entry);
1245
757
  this.leafId = entry.id;
1246
- this.treeHash = nextTreeHash;
1247
- this.treeHashError = nextTreeHashError;
1248
- this.flushed = nextFlushed;
758
+ this._persist(entry);
1249
759
  }
1250
760
  /** Append a message as child of current leaf, then advance leaf. Returns entry id.
1251
761
  * Does not allow writing CompactionSummaryMessage and BranchSummaryMessage directly.
@@ -1284,12 +794,9 @@ export class SessionManager {
1284
794
  parentId = entry.id;
1285
795
  return entry;
1286
796
  });
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;
797
+ this.fileEntries = [...retainedEntries, ...messageEntries];
1292
798
  this._buildIndex();
799
+ this._rewriteFile();
1293
800
  this.flushed = this.persist;
1294
801
  }
1295
802
  replaceTree(entries, leafId) {
@@ -1297,145 +804,27 @@ export class SessionManager {
1297
804
  if (!header) {
1298
805
  throw new Error("Session header is missing");
1299
806
  }
1300
- const savedEntries = structuredClone(entries);
1301
- const nextEntryById = validateSessionTreeShape(savedEntries);
1302
- if (leafId !== null && !nextEntryById.has(leafId)) {
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)) {
1303
820
  throw new Error(`Leaf entry ${leafId} does not exist`);
1304
821
  }
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;
822
+ this.fileEntries = [header, ...entries.map((entry) => ({ ...entry }))];
1310
823
  this._buildIndex();
1311
824
  this.leafId = leafId;
825
+ this._rewriteFile();
1312
826
  this.flushed = this.persist;
1313
827
  }
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
- }
1439
828
  /** Append a thinking level change as child of current leaf, then advance leaf. Returns entry id. */
1440
829
  appendThinkingLevelChange(thinkingLevel) {
1441
830
  const entry = {
@@ -1462,7 +851,7 @@ export class SessionManager {
1462
851
  return entry.id;
1463
852
  }
1464
853
  /** Append a compaction summary as child of current leaf, then advance leaf. Returns entry id. */
1465
- appendCompaction(summary, firstKeptEntryId, tokensBefore, details, fromHook, usage, retainedTail) {
854
+ appendCompaction(summary, firstKeptEntryId, tokensBefore, details, fromHook, usage) {
1466
855
  const entry = {
1467
856
  type: "compaction",
1468
857
  id: generateId(this.byId),
@@ -1471,7 +860,6 @@ export class SessionManager {
1471
860
  summary,
1472
861
  firstKeptEntryId,
1473
862
  tokensBefore,
1474
- retainedTail,
1475
863
  details,
1476
864
  usage,
1477
865
  fromHook,
@@ -1717,6 +1105,7 @@ export class SessionManager {
1717
1105
  if (branchFromId !== null && !this.byId.has(branchFromId)) {
1718
1106
  throw new Error(`Entry ${branchFromId} not found`);
1719
1107
  }
1108
+ this.leafId = branchFromId;
1720
1109
  const entry = {
1721
1110
  type: "branch_summary",
1722
1111
  id: generateId(this.byId),
@@ -1773,42 +1162,62 @@ export class SessionManager {
1773
1162
  labelsToWrite.push({ targetId, label, timestamp: this.labelTimestampsById.get(targetId) });
1774
1163
  }
1775
1164
  }
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
1776
1203
  const labelEntries = [];
1777
- const labelEntryIds = new Set(pathEntryIds);
1778
1204
  let parentId = pathWithoutLabels[pathWithoutLabels.length - 1]?.id || null;
1779
1205
  for (const { targetId, label, timestamp: labelTimestamp } of labelsToWrite) {
1780
1206
  const labelEntry = {
1781
1207
  type: "label",
1782
- id: generateId(labelEntryIds),
1208
+ id: generateId(new Set([...pathEntryIds, ...labelEntries.map((e) => e.id)])),
1783
1209
  parentId,
1784
1210
  timestamp: labelTimestamp,
1785
1211
  targetId,
1786
1212
  label,
1787
1213
  };
1788
- labelEntryIds.add(labelEntry.id);
1789
1214
  labelEntries.push(labelEntry);
1790
1215
  parentId = labelEntry.id;
1791
1216
  }
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;
1217
+ this.fileEntries = [header, ...pathWithoutLabels, ...labelEntries];
1805
1218
  this.sessionId = newSessionId;
1806
- if (this.persist)
1807
- this.sessionFile = newSessionFile;
1808
1219
  this._buildIndex();
1809
- this.appendPersistenceFailure = undefined;
1810
- this.flushed = this.persist && hasAssistant;
1811
- return this.persist ? newSessionFile : undefined;
1220
+ return undefined;
1812
1221
  }
1813
1222
  /**
1814
1223
  * Create a new session.
@@ -1884,6 +1293,10 @@ export class SessionManager {
1884
1293
  if (!sourceHeader) {
1885
1294
  throw new Error(`Cannot fork: source session has no header: ${resolvedSourcePath}`);
1886
1295
  }
1296
+ const dir = sessionDir ? normalizePath(sessionDir) : getDefaultSessionDir(resolvedTargetCwd);
1297
+ if (!existsSync(dir)) {
1298
+ mkdirSync(dir, { recursive: true });
1299
+ }
1887
1300
  // Create new session file with new ID but forked content
1888
1301
  if (options?.id !== undefined) {
1889
1302
  assertValidSessionId(options.id);
@@ -1891,7 +1304,6 @@ export class SessionManager {
1891
1304
  const newSessionId = options?.id ?? createSessionId();
1892
1305
  const timestamp = new Date().toISOString();
1893
1306
  const fileTimestamp = timestamp.replace(/[:.]/g, "-");
1894
- const dir = sessionDir ? normalizePath(sessionDir) : getDefaultSessionDirPath(resolvedTargetCwd);
1895
1307
  const newSessionFile = join(dir, `${fileTimestamp}_${newSessionId}.jsonl`);
1896
1308
  // Write new header pointing to source as parent, with updated cwd
1897
1309
  const newHeader = {
@@ -1902,16 +1314,13 @@ export class SessionManager {
1902
1314
  cwd: resolvedTargetCwd,
1903
1315
  parentSession: resolvedSourcePath,
1904
1316
  };
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 });
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
+ }
1913
1323
  }
1914
- writeSessionFileAtomically(newSessionFile, nextFileEntries);
1915
1324
  return new SessionManager(resolvedTargetCwd, dir, newSessionFile, true);
1916
1325
  }
1917
1326
  /**
@@ -1972,9 +1381,7 @@ export class SessionManager {
1972
1381
  sessions.sort((a, b) => b.modified.getTime() - a.modified.getTime());
1973
1382
  return sessions;
1974
1383
  }
1975
- catch (error) {
1976
- if (error instanceof SessionManagerCapacityError || error instanceof SessionFileCorruptionError)
1977
- throw error;
1384
+ catch {
1978
1385
  return [];
1979
1386
  }
1980
1387
  }