@oh-my-pi/pi-coding-agent 17.3.4 → 17.3.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 (137) hide show
  1. package/CHANGELOG.md +88 -0
  2. package/dist/{CHANGELOG-trcc215s.md → CHANGELOG-1hmwwt45.md} +88 -0
  3. package/dist/cli.js +3156 -3163
  4. package/dist/docs-index.generated.txt +1 -1
  5. package/dist/types/cli/stats-cli.d.ts +1 -6
  6. package/dist/types/commands/stats.d.ts +4 -0
  7. package/dist/types/config/model-discovery.d.ts +2 -0
  8. package/dist/types/config/model-registry.d.ts +6 -1
  9. package/dist/types/config/settings-schema.d.ts +10 -0
  10. package/dist/types/debug/report-bundle.d.ts +7 -2
  11. package/dist/types/extensibility/extensions/loader.d.ts +2 -0
  12. package/dist/types/extensibility/extensions/runner.d.ts +24 -6
  13. package/dist/types/extensibility/extensions/types.d.ts +60 -0
  14. package/dist/types/extensibility/legacy-pi-ai-shim.d.ts +4 -0
  15. package/dist/types/launch/presence.d.ts +11 -0
  16. package/dist/types/lsp/client.d.ts +22 -0
  17. package/dist/types/lsp/workspace-diagnostics.d.ts +2 -0
  18. package/dist/types/mcp/config.d.ts +3 -1
  19. package/dist/types/modes/components/tool-execution.d.ts +3 -1
  20. package/dist/types/registry/agent-lifecycle.d.ts +11 -0
  21. package/dist/types/registry/agent-registry.d.ts +4 -0
  22. package/dist/types/session/agent-session-types.d.ts +0 -2
  23. package/dist/types/session/agent-session.d.ts +20 -0
  24. package/dist/types/session/date-cwd-reminder.d.ts +22 -0
  25. package/dist/types/session/irc-bridge.d.ts +2 -0
  26. package/dist/types/session/messages.d.ts +4 -0
  27. package/dist/types/session/session-loader.d.ts +18 -11
  28. package/dist/types/session/session-maintenance.d.ts +6 -2
  29. package/dist/types/session/session-manager.d.ts +19 -3
  30. package/dist/types/session/session-tools.d.ts +0 -1
  31. package/dist/types/session/turn-recovery.d.ts +7 -7
  32. package/dist/types/slash-commands/helpers/stats-dashboard.d.ts +1 -0
  33. package/dist/types/tools/browser/attach.d.ts +16 -0
  34. package/dist/types/tools/browser/launch.d.ts +2 -2
  35. package/dist/types/tools/browser.d.ts +1 -1
  36. package/dist/types/tools/builtin-names.d.ts +1 -1
  37. package/dist/types/tools/file-write-fallback.d.ts +124 -0
  38. package/dist/types/tools/hub/jobs.d.ts +6 -0
  39. package/dist/types/tools/hub/types.d.ts +6 -0
  40. package/dist/types/tools/index.d.ts +1 -0
  41. package/dist/types/tools/path-utils.d.ts +23 -0
  42. package/dist/types/vibe/runtime.d.ts +3 -3
  43. package/package.json +13 -13
  44. package/src/auto-thinking/classifier.ts +37 -23
  45. package/src/cli/models-cli.ts +1 -1
  46. package/src/cli/stats-cli.ts +6 -72
  47. package/src/commands/stats.ts +7 -4
  48. package/src/commit/analysis/conventional.ts +15 -9
  49. package/src/commit/analysis/summary.ts +15 -9
  50. package/src/commit/changelog/generate.ts +15 -9
  51. package/src/commit/map-reduce/map-phase.ts +3 -19
  52. package/src/commit/map-reduce/reduce-phase.ts +15 -9
  53. package/src/config/model-discovery.ts +3 -3
  54. package/src/config/model-registry.ts +29 -16
  55. package/src/config/settings-schema.ts +13 -0
  56. package/src/debug/report-bundle.ts +25 -59
  57. package/src/discovery/claude-plugins.ts +2 -1
  58. package/src/discovery/claude.ts +4 -2
  59. package/src/discovery/helpers.ts +5 -0
  60. package/src/edit/hashline/filesystem.ts +9 -3
  61. package/src/edit/modes/patch.ts +31 -5
  62. package/src/edit/renderer.ts +48 -19
  63. package/src/extensibility/extensions/loader.ts +20 -5
  64. package/src/extensibility/extensions/runner.ts +239 -24
  65. package/src/extensibility/extensions/types.ts +62 -0
  66. package/src/extensibility/extensions/wrapper.ts +26 -11
  67. package/src/extensibility/legacy-pi-ai-shim.ts +4 -0
  68. package/src/extensibility/plugins/marketplace/manager.ts +15 -18
  69. package/src/irc/bus.ts +1 -1
  70. package/src/launch/broker.ts +8 -1
  71. package/src/launch/presence.ts +77 -1
  72. package/src/lsp/client.ts +35 -3
  73. package/src/lsp/clients/biome-client.ts +47 -93
  74. package/src/lsp/servers.ts +17 -11
  75. package/src/lsp/tool.ts +2 -4
  76. package/src/lsp/workspace-diagnostics.ts +24 -2
  77. package/src/lsp/writethrough.ts +20 -10
  78. package/src/mcp/config.ts +50 -6
  79. package/src/memories/index.ts +29 -25
  80. package/src/mnemopi/backend.ts +13 -11
  81. package/src/modes/components/ask-dialog.ts +25 -5
  82. package/src/modes/components/tool-execution.ts +10 -6
  83. package/src/modes/components/welcome.ts +4 -1
  84. package/src/modes/controllers/extension-ui-controller.ts +52 -32
  85. package/src/modes/controllers/selector-controller.ts +6 -0
  86. package/src/modes/controllers/tan-command-controller.ts +1 -0
  87. package/src/modes/interactive-mode.ts +49 -10
  88. package/src/modes/theme/defaults/birch.json +2 -2
  89. package/src/prompts/system/checkpoint-active-notice.md +5 -0
  90. package/src/prompts/system/date-cwd-reminder.md +3 -0
  91. package/src/prompts/system/project-prompt.md +0 -1
  92. package/src/prompts/system/rewind-report.md +1 -3
  93. package/src/prompts/tools/browser.md +1 -0
  94. package/src/prompts/tools/rewind.md +1 -13
  95. package/src/registry/agent-lifecycle.ts +34 -0
  96. package/src/registry/agent-registry.ts +27 -2
  97. package/src/registry/persisted-agents.ts +40 -20
  98. package/src/sdk.ts +32 -2
  99. package/src/session/agent-session-types.ts +0 -2
  100. package/src/session/agent-session.ts +178 -46
  101. package/src/session/date-cwd-reminder.ts +77 -0
  102. package/src/session/irc-bridge.ts +5 -0
  103. package/src/session/messages.ts +5 -1
  104. package/src/session/session-loader.ts +106 -64
  105. package/src/session/session-maintenance.ts +189 -115
  106. package/src/session/session-manager.ts +142 -35
  107. package/src/session/session-persistence.ts +4 -4
  108. package/src/session/session-storage.ts +18 -5
  109. package/src/session/session-tools.ts +5 -10
  110. package/src/session/turn-recovery.ts +102 -20
  111. package/src/session/unexpected-stop-classifier.ts +33 -21
  112. package/src/slash-commands/builtin-session.ts +1 -1
  113. package/src/slash-commands/helpers/stats-dashboard.ts +23 -9
  114. package/src/system-prompt.ts +0 -5
  115. package/src/task/executor.ts +4 -15
  116. package/src/task/persisted-revive.ts +3 -6
  117. package/src/tools/ask.ts +10 -3
  118. package/src/tools/browser/attach.ts +80 -25
  119. package/src/tools/browser/launch.ts +44 -15
  120. package/src/tools/browser/relay/daemon.ts +3 -7
  121. package/src/tools/browser/render.ts +1 -1
  122. package/src/tools/browser/shared-daemon.ts +3 -7
  123. package/src/tools/browser.ts +5 -5
  124. package/src/tools/builtin-names.ts +7 -3
  125. package/src/tools/checkpoint.ts +1 -7
  126. package/src/tools/file-write-fallback.ts +467 -0
  127. package/src/tools/hub/index.ts +1 -1
  128. package/src/tools/hub/jobs.ts +20 -3
  129. package/src/tools/hub/types.ts +6 -0
  130. package/src/tools/index.ts +1 -0
  131. package/src/tools/path-utils.ts +79 -0
  132. package/src/tools/render-utils.ts +53 -21
  133. package/src/tools/think.ts +15 -3
  134. package/src/tts/speech-enhancer.ts +19 -14
  135. package/src/utils/commit-message-generator.ts +14 -12
  136. package/src/utils/title-generator.ts +23 -19
  137. package/src/vibe/runtime.ts +32 -17
@@ -59,7 +59,13 @@ import {
59
59
  type UsageStatistics,
60
60
  } from "./session-entries";
61
61
  import { findMostRecentSession, listAllSessions, listSessions, type SessionInfo } from "./session-listing";
62
- import { loadEntriesFromFile, readTitleSlotFromFile, resolveBlobRefsInEntries } from "./session-loader";
62
+ import {
63
+ loadEntriesFromFile,
64
+ loadSessionFile,
65
+ resolveBlobRefsInEntries,
66
+ type SessionLoadResult,
67
+ visitEntriesFromFile,
68
+ } from "./session-loader";
63
69
  import { generateId, migrateToCurrentVersion } from "./session-migrations";
64
70
  import {
65
71
  computeDefaultSessionDir,
@@ -83,6 +89,7 @@ import {
83
89
 
84
90
  const JSONL_SUFFIX_LENGTH = ".jsonl".length;
85
91
  const DRAFT_ONLY_SESSION_MARKER = ".draft-only-session";
92
+ const DISCARDED_ENTRY_BRANCH_MARKER = "discarded-entry-branch";
86
93
 
87
94
  function mintSessionId(): string {
88
95
  return Bun.randomUUIDv7();
@@ -97,7 +104,31 @@ function fileSafeTimestamp(iso: string): string {
97
104
  }
98
105
 
99
106
  function artifactsDirectoryFor(sessionFile: string | undefined): string | null {
100
- return sessionFile ? sessionFile.slice(0, -JSONL_SUFFIX_LENGTH) : null;
107
+ if (!sessionFile?.endsWith(".jsonl")) return null;
108
+ return sessionFile.slice(0, -JSONL_SUFFIX_LENGTH);
109
+ }
110
+
111
+ /** Copy a session's artifact directory to another session, matching interactive `/fork`. */
112
+ export async function copySessionArtifacts(sourceSessionFile: string, destinationSessionFile: string): Promise<void> {
113
+ const sourceArtifactsDir = artifactsDirectoryFor(sourceSessionFile);
114
+ const destinationArtifactsDir = artifactsDirectoryFor(destinationSessionFile);
115
+ if (!sourceArtifactsDir || !destinationArtifactsDir) return;
116
+ if (path.resolve(sourceArtifactsDir) === path.resolve(destinationArtifactsDir)) return;
117
+
118
+ try {
119
+ const sourceStat = await fs.promises.stat(sourceArtifactsDir);
120
+ if (sourceStat.isDirectory()) {
121
+ await fs.promises.cp(sourceArtifactsDir, destinationArtifactsDir, { recursive: true });
122
+ }
123
+ } catch (error) {
124
+ if (!isEnoent(error)) {
125
+ logger.warn("Failed to copy artifacts during fork", {
126
+ sourceArtifactsDir,
127
+ destinationArtifactsDir,
128
+ error: error instanceof Error ? error.message : String(error),
129
+ });
130
+ }
131
+ }
101
132
  }
102
133
 
103
134
  /**
@@ -516,6 +547,7 @@ export class SessionManager {
516
547
  */
517
548
  #breadcrumbFresh = false;
518
549
  #sessionNameChangedCallbacks = new Set<() => void>();
550
+ #persistenceErrorCallbacks = new Set<(error: Error) => void>();
519
551
 
520
552
  private constructor(cwd: string, sessionDir: string, persist: boolean, storage: SessionStorage) {
521
553
  this.#cwd = cwd;
@@ -557,6 +589,15 @@ export class SessionManager {
557
589
  error: error.message,
558
590
  stack: error.stack,
559
591
  });
592
+ for (const callback of this.#persistenceErrorCallbacks) {
593
+ try {
594
+ callback(error);
595
+ } catch (callbackError) {
596
+ logger.warn("Session persistence error observer failed", {
597
+ error: toError(callbackError).message,
598
+ });
599
+ }
600
+ }
560
601
  }
561
602
 
562
603
  return this.#diskFailure;
@@ -814,6 +855,7 @@ export class SessionManager {
814
855
  this.#diskTail = Promise.resolve();
815
856
  this.#closeWriterEventually();
816
857
  this.#storage.writeTextSync(targetPath, body);
858
+ this.#clearDiskError();
817
859
  // Only mark the manager current when writing the active session path.
818
860
  // Mid-move writes update the live relocation path; `#sessionFile` is
819
861
  // still the pre-repoint source until moveTo repoints it.
@@ -906,7 +948,13 @@ export class SessionManager {
906
948
  this.#atomicRewriteDirty = true;
907
949
  return;
908
950
  }
909
- if (this.#diskFailure) throw this.#diskFailure;
951
+ if (this.#diskFailure) {
952
+ // The failed entry and any later entries remain in memory. A full
953
+ // replacement is the writability probe and restores all of them once
954
+ // transient storage pressure clears.
955
+ this.#fileIsCurrent = false;
956
+ this.#rewriteRequired = true;
957
+ }
910
958
 
911
959
  // Lazy gate: a brand-new session is not written until it has an assistant
912
960
  // message (or someone forced creation), so sessions that never produce
@@ -944,9 +992,9 @@ export class SessionManager {
944
992
  // chain. Prefer appendSync so write failures latch `#diskFailure` before
945
993
  // this call returns (not via a discarded rejected Promise after a later
946
994
  // microtask). Callers stay non-throwing here — the core turn loop invokes
947
- // appendMessage/appendCustomEntry without try/catch; flushSync/close and
948
- // subsequent appends still throw the latched error. File writers apply
949
- // each line to the OS page cache before return.
995
+ // appendMessage/appendCustomEntry without try/catch. A later entry retries
996
+ // all in-memory state through a full rewrite. File writers apply each line
997
+ // to the OS page cache before return.
950
998
  // A mid-close writer leaves `#writer` undefined, so `#appendWriter` simply
951
999
  // opens a fresh append handle and the entry still lands.
952
1000
  try {
@@ -955,16 +1003,28 @@ export class SessionManager {
955
1003
  if (writer.appendSync) {
956
1004
  writer.appendSync(line);
957
1005
  } else {
958
- void writer.append(line).catch(err => this.#noteDiskFailure(err));
1006
+ void writer.append(line).catch(err => {
1007
+ this.#fileIsCurrent = false;
1008
+ this.#rewriteRequired = true;
1009
+ this.#noteDiskFailure(err);
1010
+ });
959
1011
  }
960
1012
  } catch (err) {
1013
+ this.#fileIsCurrent = false;
1014
+ this.#rewriteRequired = true;
961
1015
  this.#noteDiskFailure(err);
962
1016
  }
963
1017
  }
964
1018
 
965
1019
  async #persistTitleChangeEntry(entry: TitleChangeEntry, update: SessionTitleUpdate): Promise<void> {
966
1020
  if (!this.#persist || !this.#sessionFile) return;
967
- if (this.#diskFailure) throw this.#diskFailure;
1021
+ if (this.#diskFailure) {
1022
+ this.#fileIsCurrent = false;
1023
+ this.#rewriteRequired = true;
1024
+ this.#rewriteSynchronously();
1025
+ if (this.#diskFailure) throw this.#diskFailure;
1026
+ return;
1027
+ }
968
1028
 
969
1029
  if (!this.#shouldHaveSessionFile()) {
970
1030
  this.#fileIsCurrent = false;
@@ -1288,7 +1348,7 @@ export class SessionManager {
1288
1348
  await this.#setSessionFile(sessionFile);
1289
1349
  }
1290
1350
 
1291
- async #setSessionFile(sessionFile: string, loadedEntries?: FileEntry[]): Promise<void> {
1351
+ async #setSessionFile(sessionFile: string, loadedSession?: SessionLoadResult): Promise<void> {
1292
1352
  await this.#drainAndCloseWriter();
1293
1353
  this.#clearDiskError();
1294
1354
  this.#draftOnlySessionCleanupArmed = false;
@@ -1297,8 +1357,8 @@ export class SessionManager {
1297
1357
  this.#sessionFile = resolvedSessionFile;
1298
1358
  this.#rememberBreadcrumb(this.#cwd, resolvedSessionFile);
1299
1359
 
1300
- const titleSlot = await readTitleSlotFromFile(resolvedSessionFile, this.#storage);
1301
- const fileEntries = loadedEntries ?? (await loadEntriesFromFile(resolvedSessionFile, this.#storage));
1360
+ const loaded = loadedSession ?? (await loadSessionFile(resolvedSessionFile, this.#storage));
1361
+ const { entries: fileEntries, titleSlot } = loaded;
1302
1362
  if (fileEntries.length === 0) {
1303
1363
  // Explicit but empty/missing path (e.g. --session flag): start fresh but
1304
1364
  // keep the requested path and materialize the header immediately.
@@ -1332,7 +1392,7 @@ export class SessionManager {
1332
1392
  this.#titleUpdatedAt = titleSlot?.updatedAt ?? header.timestamp;
1333
1393
  this.#hasTitleSlot = titleSlot !== undefined;
1334
1394
  this.#fileIsCurrent = true;
1335
- this.#rewriteRequired = migrated;
1395
+ this.#rewriteRequired = migrated || loaded.malformedRecords > 0;
1336
1396
  this.#forceFileCreation = true;
1337
1397
  this.#artifactManager = null;
1338
1398
  this.#artifactManagerSessionFile = null;
@@ -1440,10 +1500,13 @@ export class SessionManager {
1440
1500
 
1441
1501
  const oldSessionFile = this.#sessionFile;
1442
1502
  const newSessionFile = path.join(nextSessionDir, path.basename(oldSessionFile));
1443
- const oldArtifactsDir = artifactsDirectoryFor(oldSessionFile)!;
1444
- const newArtifactsDir = artifactsDirectoryFor(newSessionFile)!;
1503
+ const oldArtifactsDir = artifactsDirectoryFor(oldSessionFile);
1504
+ const newArtifactsDir = artifactsDirectoryFor(newSessionFile);
1445
1505
  const sessionPathChanged = path.resolve(oldSessionFile) !== path.resolve(newSessionFile);
1446
- const artifactPathChanged = path.resolve(oldArtifactsDir) !== path.resolve(newArtifactsDir);
1506
+ const artifactPathChanged =
1507
+ oldArtifactsDir !== null &&
1508
+ newArtifactsDir !== null &&
1509
+ path.resolve(oldArtifactsDir) !== path.resolve(newArtifactsDir);
1447
1510
  sessionFileExisted = this.#storage.existsSync(oldSessionFile);
1448
1511
 
1449
1512
  let sessionMoved = false;
@@ -1467,7 +1530,7 @@ export class SessionManager {
1467
1530
  }
1468
1531
  }
1469
1532
  } catch (err) {
1470
- if (artifactsMoved) {
1533
+ if (artifactsMoved && oldArtifactsDir && newArtifactsDir) {
1471
1534
  try {
1472
1535
  await fs.promises.rename(newArtifactsDir, oldArtifactsDir);
1473
1536
  } catch (rollbackErr) {
@@ -1985,6 +2048,14 @@ export class SessionManager {
1985
2048
  };
1986
2049
  }
1987
2050
 
2051
+ /** Subscribe to persistence failures so hosts can surface lost-durability state. */
2052
+ onPersistenceError(cb: (error: Error) => void): () => void {
2053
+ this.#persistenceErrorCallbacks.add(cb);
2054
+ return () => {
2055
+ this.#persistenceErrorCallbacks.delete(cb);
2056
+ };
2057
+ }
2058
+
1988
2059
  /**
1989
2060
  * Set the session display name.
1990
2061
  * @param source "user" for explicit renames; "auto" for generated titles.
@@ -2394,6 +2465,37 @@ export class SessionManager {
2394
2465
  this.#setLeaf(null);
2395
2466
  }
2396
2467
 
2468
+ /**
2469
+ * Durably move the active branch past a discarded entry.
2470
+ *
2471
+ * The loader reconstructs the active branch from the last physical journal
2472
+ * entry, so changing the in-memory leaf alone is lost on reload. Known
2473
+ * metadata children are chained onto the discarded entry's parent before the
2474
+ * entry is removed. If any child may carry content, the subtree is preserved
2475
+ * off-branch instead. Both paths append a metadata-only branch marker and
2476
+ * rewrite the journal, making the selected path durable.
2477
+ */
2478
+ async discardEntryDurably(entryId: string): Promise<void> {
2479
+ const entry = this.#index.get(entryId);
2480
+ if (!entry) return;
2481
+ const children = this.#index.childrenOf(entryId);
2482
+ const canReparentChildren = children.every(child => child.type === "service_tier_change");
2483
+ let leafId = entry.parentId;
2484
+ if (canReparentChildren) {
2485
+ for (const child of children) {
2486
+ child.parentId = leafId;
2487
+ leafId = child.id;
2488
+ }
2489
+ this.#entries = this.#entries.filter(candidate => candidate.id !== entryId);
2490
+ this.#index.rebuild(this.#entries);
2491
+ }
2492
+ this.branchWithSummary(leafId, "", {
2493
+ kind: DISCARDED_ENTRY_BRANCH_MARKER,
2494
+ discardedEntryId: entryId,
2495
+ });
2496
+ await this.rewriteEntries();
2497
+ }
2498
+
2397
2499
  /** Like branch(), but also records a branch_summary of the abandoned path. */
2398
2500
  branchWithSummary(branchFromId: string | null, summary: string, details?: unknown, fromExtension?: boolean): string {
2399
2501
  if (branchFromId !== null && !this.#index.has(branchFromId)) throw new Error(`Entry ${branchFromId} not found`);
@@ -2533,16 +2635,16 @@ export class SessionManager {
2533
2635
  * session file while creating a fresh session file in this sessionDir.
2534
2636
  *
2535
2637
  * `options.sessionFile` pins the new session's file path (default: an
2536
- * auto-named `<timestamp>_<id>.jsonl` in `sessionDir`). Callers that register
2537
- * the fork as a named agent (e.g. `/tan`) pass `<agentId>.jsonl` so the
2538
- * persisted-subagent scan keys the agent by the same id the live ref uses.
2638
+ * auto-named `<timestamp>_<id>.jsonl` in `sessionDir`). Artifacts are copied
2639
+ * recursively by default; nested agents that deliberately share their parent's
2640
+ * artifact root may disable this with `copyArtifacts: false`.
2539
2641
  */
2540
2642
  static async forkFrom(
2541
2643
  sourcePath: string,
2542
2644
  cwd: string,
2543
2645
  sessionDir?: string,
2544
2646
  storage: SessionStorage = new FileSessionStorage(),
2545
- options?: { suppressBreadcrumb?: boolean; sessionFile?: string },
2647
+ options?: { copyArtifacts?: boolean; suppressBreadcrumb?: boolean; sessionFile?: string },
2546
2648
  ): Promise<SessionManager> {
2547
2649
  const dir = sessionDir ?? SessionManager.getDefaultSessionDir(cwd, undefined, storage);
2548
2650
  const manager = new SessionManager(cwd, dir, true, storage);
@@ -2575,6 +2677,9 @@ export class SessionManager {
2575
2677
  manager.sanitizeLoadedOpenAIResponsesReplayMetadata();
2576
2678
  manager.#forceFileCreation = true;
2577
2679
  await manager.#rewriteAtomically();
2680
+ if (options?.copyArtifacts !== false) {
2681
+ await copySessionArtifacts(sourcePath, manager.#sessionFile!);
2682
+ }
2578
2683
  return manager;
2579
2684
  }
2580
2685
 
@@ -2589,8 +2694,8 @@ export class SessionManager {
2589
2694
  storage: SessionStorage = new FileSessionStorage(),
2590
2695
  options?: { initialCwd?: string; suppressBreadcrumb?: boolean },
2591
2696
  ): Promise<SessionManager> {
2592
- const loaded = await loadEntriesFromFile(filePath, storage);
2593
- const header = loaded.find(entry => entry.type === "session") as SessionHeader | undefined;
2697
+ const loaded = await loadSessionFile(filePath, storage);
2698
+ const header = loaded.entries.find(entry => entry.type === "session") as SessionHeader | undefined;
2594
2699
  // Resume into the session's recorded cwd only when that directory still
2595
2700
  // exists. A deleted project dir would make the constructor's #cwd — and the
2596
2701
  // `setProjectDir` chdir interactive mode runs next — point at (and fail on)
@@ -2638,15 +2743,7 @@ export class SessionManager {
2638
2743
  advisor?: string;
2639
2744
  } | null;
2640
2745
  } | null> {
2641
- let loaded: FileEntry[];
2642
- try {
2643
- loaded = await loadEntriesFromFile(filePath, storage);
2644
- } catch {
2645
- return null;
2646
- }
2647
- // A missing/empty file has no usable session — nothing to revive from.
2648
- if (loaded.length === 0) return null;
2649
- const header = loaded.find(entry => entry.type === "session") as SessionHeader | undefined;
2746
+ let header: SessionHeader | undefined;
2650
2747
  let init: {
2651
2748
  systemPrompt: string;
2652
2749
  task: string;
@@ -2661,8 +2758,11 @@ export class SessionManager {
2661
2758
  readSummarize?: boolean;
2662
2759
  advisor?: string;
2663
2760
  } | null = null;
2664
- for (let index = loaded.length - 1; index >= 0; index--) {
2665
- const entry = loaded[index];
2761
+ const visit = (entry: FileEntry): void => {
2762
+ if (entry.type === "session") {
2763
+ header ??= entry;
2764
+ return;
2765
+ }
2666
2766
  if (entry.type === "session_init") {
2667
2767
  init = {
2668
2768
  systemPrompt: entry.systemPrompt,
@@ -2678,10 +2778,17 @@ export class SessionManager {
2678
2778
  spawns: entry.spawns,
2679
2779
  advisor: entry.advisor,
2680
2780
  };
2681
- break;
2682
2781
  }
2782
+ };
2783
+
2784
+ try {
2785
+ await visitEntriesFromFile(filePath, visit, storage);
2786
+ } catch {
2787
+ return null;
2683
2788
  }
2684
- return { cwd: header?.cwd ?? getProjectDir(), init };
2789
+ // A missing, empty, or invalid file has no usable session.
2790
+ if (!header) return null;
2791
+ return { cwd: header.cwd ?? getProjectDir(), init };
2685
2792
  }
2686
2793
 
2687
2794
  /** Continue the most recent session, or create a new one if none exists. */
@@ -1,4 +1,4 @@
1
- import { isAnthropicWebSearchHistoryBlock } from "@oh-my-pi/pi-ai/providers/anthropic-wire";
1
+ import { isAnthropicServerToolHistoryBlock } from "@oh-my-pi/pi-ai/providers/anthropic-wire";
2
2
  import {
3
3
  type BlobStore,
4
4
  externalizeImageDataSync,
@@ -100,8 +100,8 @@ function truncateForPersistence(obj: unknown, blobStore: BlobStore, key?: string
100
100
  // Persist signed blocks verbatim — never truncate, externalize, or descend.
101
101
  // Unsigned blocks (e.g. an interrupted stream) have no such binding and stay
102
102
  // truncatable for size control.
103
- // Anthropic validates native web-search history byte-for-byte on replay.
104
- // Keep the complete typed block atomic, including nested encrypted_content.
103
+ // Anthropic validates native web-search and tool-search history byte-for-byte
104
+ // on replay. Keep the complete typed block atomic, including opaque content.
105
105
  if (typeof obj === "object" && "type" in obj && obj.type === "anthropicServerTool" && "block" in obj) {
106
106
  const block = obj.block;
107
107
  if (typeof block === "object" && block !== null && "type" in block && typeof block.type === "string") {
@@ -112,7 +112,7 @@ function truncateForPersistence(obj: unknown, blobStore: BlobStore, key?: string
112
112
  ...("tool_use_id" in block ? { tool_use_id: block.tool_use_id } : {}),
113
113
  ...("content" in block ? { content: block.content } : {}),
114
114
  };
115
- if (isAnthropicWebSearchHistoryBlock(validationView)) return obj;
115
+ if (isAnthropicServerToolHistoryBlock(validationView)) return obj;
116
116
  }
117
117
  }
118
118
  if (typeof obj === "object" && "type" in obj) {
@@ -128,14 +128,27 @@ class FileSessionStorageWriter implements SessionStorageWriter {
128
128
  }
129
129
 
130
130
  #writeNow(line: string): void {
131
+ const originalSize = fs.fstatSync(this.#fd).size;
131
132
  const buf = Buffer.from(line, "utf-8");
132
133
  let offset = 0;
133
- while (offset < buf.length) {
134
- const written = fs.writeSync(this.#fd, buf, offset, buf.length - offset);
135
- if (written === 0) {
136
- throw new Error("Short write");
134
+ try {
135
+ while (offset < buf.length) {
136
+ const written = fs.writeSync(this.#fd, buf, offset, buf.length - offset);
137
+ if (written === 0) {
138
+ throw new Error("Short write");
139
+ }
140
+ offset += written;
141
+ }
142
+ } catch (writeError) {
143
+ try {
144
+ fs.ftruncateSync(this.#fd, originalSize);
145
+ } catch (rollbackError) {
146
+ throw new AggregateError(
147
+ [toError(writeError), toError(rollbackError)],
148
+ "Session append failed and its partial bytes could not be rolled back",
149
+ );
137
150
  }
138
- offset += written;
151
+ throw writeError;
139
152
  }
140
153
  }
141
154
 
@@ -26,7 +26,6 @@ import { ToolAbortError, ToolError } from "../tools/tool-errors";
26
26
  import { isMountableUnderXdev, listXdevTools, type XdevState, xdevDocsFor, xdevEntries } from "../tools/xdev";
27
27
  import { type EditMode, resolveEditMode } from "../utils/edit-mode";
28
28
  import { type InspectImageMode, isInspectImageToolActive } from "../utils/inspect-image-mode";
29
- import { formatLocalCalendarDate } from "../utils/local-date";
30
29
  import {
31
30
  extractPermissionLocations,
32
31
  getPermissionIntent,
@@ -82,7 +81,6 @@ interface SessionToolsOptions {
82
81
  toolNames: string[],
83
82
  tools: Map<string, AgentTool>,
84
83
  ) => Promise<{ systemPrompt: string[]; xdevCatalogNames?: readonly string[] }>;
85
- getLocalCalendarDate?: () => string;
86
84
  getMcpServerInstructions?: () => Map<string, string> | undefined;
87
85
  xdev?: XdevState;
88
86
  setActiveToolNames?: (names: Iterable<string>) => void;
@@ -229,7 +227,6 @@ export class SessionTools {
229
227
  #toolRegistryMutationTail: Promise<void> = Promise.resolve();
230
228
  #promptModelKey: string | undefined;
231
229
  #rebuildSystemPrompt: SessionToolsOptions["rebuildSystemPrompt"];
232
- #getLocalCalendarDate: () => string;
233
230
  #getMcpServerInstructions: SessionToolsOptions["getMcpServerInstructions"];
234
231
  #setActiveToolNames: SessionToolsOptions["setActiveToolNames"];
235
232
  #ensureWriteRegistered: SessionToolsOptions["ensureWriteRegistered"];
@@ -262,7 +259,6 @@ export class SessionTools {
262
259
  this.#presentationPinnedToolNames = options.presentationPinnedToolNames;
263
260
  this.#ensureWriteRegistered = options.ensureWriteRegistered;
264
261
  this.#rebuildSystemPrompt = options.rebuildSystemPrompt;
265
- this.#getLocalCalendarDate = options.getLocalCalendarDate ?? formatLocalCalendarDate;
266
262
  this.#getMcpServerInstructions = options.getMcpServerInstructions;
267
263
  this.#xdev = options.xdev;
268
264
  if (this.#xdev && this.#xdev.tools !== this.#toolRegistry) {
@@ -1434,10 +1430,10 @@ export class SessionTools {
1434
1430
  * For everything else, callers must explicitly call {@link refreshBaseSystemPrompt}
1435
1431
  * after side-effecting changes; see the memory hooks and {@link syncAfterModelChange}.
1436
1432
  *
1437
- * The current calendar date IS covered (appended as a segment) because
1438
- * `buildSystemPrompt` injects it into the prompt body (`Today is '{{date}}'`).
1439
- * Without this, a session spanning midnight with only tool-stable MCP
1440
- * reconnects would keep yesterday's date indefinitely.
1433
+ * The calendar date is deliberately NOT part of the signature: the date/cwd
1434
+ * reminder rides on the first user turn at request time (`date-cwd-reminder`),
1435
+ * so a session spanning midnight must NOT rebuild a prompt that no longer
1436
+ * embeds the date the reminder picks up the new day on its own.
1441
1437
  */
1442
1438
  #computeAppliedToolSignature(toolNames: string[], tools: AgentTool[]): string {
1443
1439
  // Order-preserving join: any reorder must produce a different signature so
@@ -1471,8 +1467,7 @@ export class SessionTools {
1471
1467
  // the provider cache prefix byte-stable. Mounted MCP routes are the narrow
1472
1468
  // exception above, bounded to the exact projection rendered in the global
1473
1469
  // route guidance so churn wholly behind its fallback does not rebuild.
1474
- const date = this.#getLocalCalendarDate();
1475
- return `${nameSegment}\u0003${descriptionSegment}\u0007${instructionsSegment}\u0008${mountedMCPRouteSegment}|${date}`;
1470
+ return `${nameSegment}\u0003${descriptionSegment}\u0007${instructionsSegment}\u0008${mountedMCPRouteSegment}`;
1476
1471
  }
1477
1472
 
1478
1473
  /**