@oh-my-pi/pi-coding-agent 3.14.0 → 3.15.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (148) hide show
  1. package/CHANGELOG.md +79 -0
  2. package/docs/theme.md +38 -5
  3. package/examples/sdk/11-sessions.ts +2 -2
  4. package/package.json +7 -4
  5. package/src/cli/file-processor.ts +51 -2
  6. package/src/cli/plugin-cli.ts +25 -19
  7. package/src/cli/update-cli.ts +4 -3
  8. package/src/core/agent-session.ts +31 -4
  9. package/src/core/compaction/branch-summarization.ts +4 -32
  10. package/src/core/compaction/compaction.ts +6 -84
  11. package/src/core/compaction/utils.ts +2 -3
  12. package/src/core/custom-tools/types.ts +2 -0
  13. package/src/core/export-html/index.ts +1 -1
  14. package/src/core/hooks/tool-wrapper.ts +0 -1
  15. package/src/core/hooks/types.ts +2 -2
  16. package/src/core/plugins/doctor.ts +9 -1
  17. package/src/core/sdk.ts +2 -1
  18. package/src/core/session-manager.ts +518 -40
  19. package/src/core/settings-manager.ts +174 -0
  20. package/src/core/system-prompt.ts +9 -14
  21. package/src/core/title-generator.ts +2 -8
  22. package/src/core/tools/ask.ts +19 -37
  23. package/src/core/tools/bash.ts +2 -37
  24. package/src/core/tools/edit.ts +2 -9
  25. package/src/core/tools/exa/render.ts +52 -48
  26. package/src/core/tools/find.ts +10 -8
  27. package/src/core/tools/grep.ts +45 -17
  28. package/src/core/tools/ls.ts +22 -2
  29. package/src/core/tools/lsp/clients/biome-client.ts +207 -0
  30. package/src/core/tools/lsp/clients/index.ts +49 -0
  31. package/src/core/tools/lsp/clients/lsp-linter-client.ts +98 -0
  32. package/src/core/tools/lsp/config.ts +3 -0
  33. package/src/core/tools/lsp/index.ts +107 -55
  34. package/src/core/tools/lsp/render.ts +192 -79
  35. package/src/core/tools/lsp/types.ts +27 -0
  36. package/src/core/tools/lsp/utils.ts +62 -22
  37. package/src/core/tools/notebook.ts +9 -1
  38. package/src/core/tools/output.ts +37 -14
  39. package/src/core/tools/read.ts +349 -34
  40. package/src/core/tools/renderers.ts +290 -89
  41. package/src/core/tools/review.ts +12 -5
  42. package/src/core/tools/task/agents.ts +5 -5
  43. package/src/core/tools/task/commands.ts +3 -3
  44. package/src/core/tools/task/executor.ts +33 -1
  45. package/src/core/tools/task/index.ts +93 -6
  46. package/src/core/tools/task/render.ts +147 -66
  47. package/src/core/tools/task/types.ts +14 -9
  48. package/src/core/tools/web-fetch.ts +242 -103
  49. package/src/core/tools/web-search/index.ts +64 -20
  50. package/src/core/tools/web-search/providers/exa.ts +68 -172
  51. package/src/core/tools/web-search/render.ts +264 -74
  52. package/src/core/tools/write.ts +2 -8
  53. package/src/main.ts +10 -6
  54. package/src/modes/cleanup.ts +23 -0
  55. package/src/modes/index.ts +9 -4
  56. package/src/modes/interactive/components/bash-execution.ts +6 -3
  57. package/src/modes/interactive/components/branch-summary-message.ts +1 -1
  58. package/src/modes/interactive/components/compaction-summary-message.ts +1 -1
  59. package/src/modes/interactive/components/dynamic-border.ts +1 -1
  60. package/src/modes/interactive/components/extensions/extension-dashboard.ts +4 -5
  61. package/src/modes/interactive/components/extensions/extension-list.ts +18 -16
  62. package/src/modes/interactive/components/extensions/inspector-panel.ts +8 -8
  63. package/src/modes/interactive/components/hook-message.ts +2 -2
  64. package/src/modes/interactive/components/hook-selector.ts +1 -1
  65. package/src/modes/interactive/components/model-selector.ts +22 -9
  66. package/src/modes/interactive/components/oauth-selector.ts +20 -4
  67. package/src/modes/interactive/components/plugin-settings.ts +4 -2
  68. package/src/modes/interactive/components/session-selector.ts +9 -6
  69. package/src/modes/interactive/components/settings-defs.ts +285 -1
  70. package/src/modes/interactive/components/settings-selector.ts +176 -3
  71. package/src/modes/interactive/components/status-line/index.ts +4 -0
  72. package/src/modes/interactive/components/status-line/presets.ts +94 -0
  73. package/src/modes/interactive/components/status-line/segments.ts +350 -0
  74. package/src/modes/interactive/components/status-line/separators.ts +55 -0
  75. package/src/modes/interactive/components/status-line/types.ts +81 -0
  76. package/src/modes/interactive/components/status-line-segment-editor.ts +357 -0
  77. package/src/modes/interactive/components/status-line.ts +170 -223
  78. package/src/modes/interactive/components/tool-execution.ts +446 -211
  79. package/src/modes/interactive/components/tree-selector.ts +17 -6
  80. package/src/modes/interactive/components/ttsr-notification.ts +4 -4
  81. package/src/modes/interactive/components/welcome.ts +27 -19
  82. package/src/modes/interactive/interactive-mode.ts +98 -13
  83. package/src/modes/interactive/theme/dark.json +3 -2
  84. package/src/modes/interactive/theme/defaults/dark-arctic.json +111 -0
  85. package/src/modes/interactive/theme/defaults/dark-catppuccin.json +106 -0
  86. package/src/modes/interactive/theme/defaults/dark-cyberpunk.json +109 -0
  87. package/src/modes/interactive/theme/defaults/dark-dracula.json +105 -0
  88. package/src/modes/interactive/theme/defaults/dark-forest.json +103 -0
  89. package/src/modes/interactive/theme/defaults/dark-github.json +112 -0
  90. package/src/modes/interactive/theme/defaults/dark-gruvbox.json +119 -0
  91. package/src/modes/interactive/theme/defaults/dark-monochrome.json +101 -0
  92. package/src/modes/interactive/theme/defaults/dark-monokai.json +105 -0
  93. package/src/modes/interactive/theme/defaults/dark-nord.json +104 -0
  94. package/src/modes/interactive/theme/defaults/dark-ocean.json +108 -0
  95. package/src/modes/interactive/theme/defaults/dark-one.json +107 -0
  96. package/src/modes/interactive/theme/defaults/dark-retro.json +99 -0
  97. package/src/modes/interactive/theme/defaults/dark-rose-pine.json +95 -0
  98. package/src/modes/interactive/theme/defaults/dark-solarized.json +96 -0
  99. package/src/modes/interactive/theme/defaults/dark-sunset.json +106 -0
  100. package/src/modes/interactive/theme/defaults/dark-synthwave.json +102 -0
  101. package/src/modes/interactive/theme/defaults/dark-tokyo-night.json +108 -0
  102. package/src/modes/interactive/theme/defaults/index.ts +67 -0
  103. package/src/modes/interactive/theme/defaults/light-arctic.json +106 -0
  104. package/src/modes/interactive/theme/defaults/light-catppuccin.json +105 -0
  105. package/src/modes/interactive/theme/defaults/light-cyberpunk.json +103 -0
  106. package/src/modes/interactive/theme/defaults/light-forest.json +107 -0
  107. package/src/modes/interactive/theme/defaults/light-github.json +114 -0
  108. package/src/modes/interactive/theme/defaults/light-gruvbox.json +115 -0
  109. package/src/modes/interactive/theme/defaults/light-monochrome.json +100 -0
  110. package/src/modes/interactive/theme/defaults/light-ocean.json +106 -0
  111. package/src/modes/interactive/theme/defaults/light-one.json +105 -0
  112. package/src/modes/interactive/theme/defaults/light-retro.json +105 -0
  113. package/src/modes/interactive/theme/defaults/light-solarized.json +101 -0
  114. package/src/modes/interactive/theme/defaults/light-sunset.json +106 -0
  115. package/src/modes/interactive/theme/defaults/light-synthwave.json +105 -0
  116. package/src/modes/interactive/theme/defaults/light-tokyo-night.json +118 -0
  117. package/src/modes/interactive/theme/light.json +3 -2
  118. package/src/modes/interactive/theme/theme-schema.json +120 -4
  119. package/src/modes/interactive/theme/theme.ts +1228 -14
  120. package/src/prompts/branch-summary-preamble.md +3 -0
  121. package/src/prompts/branch-summary.md +28 -0
  122. package/src/prompts/compaction-summary.md +34 -0
  123. package/src/prompts/compaction-turn-prefix.md +16 -0
  124. package/src/prompts/compaction-update-summary.md +41 -0
  125. package/src/prompts/init.md +30 -0
  126. package/src/{core/tools/task/bundled-agents → prompts}/reviewer.md +6 -0
  127. package/src/prompts/summarization-system.md +3 -0
  128. package/src/prompts/system-prompt.md +27 -0
  129. package/src/{core/tools/task/bundled-agents → prompts}/task.md +2 -0
  130. package/src/prompts/title-system.md +8 -0
  131. package/src/prompts/tools/ask.md +24 -0
  132. package/src/prompts/tools/bash.md +23 -0
  133. package/src/prompts/tools/edit.md +9 -0
  134. package/src/prompts/tools/find.md +6 -0
  135. package/src/prompts/tools/grep.md +12 -0
  136. package/src/prompts/tools/lsp.md +14 -0
  137. package/src/prompts/tools/output.md +23 -0
  138. package/src/prompts/tools/read.md +25 -0
  139. package/src/prompts/tools/web-fetch.md +8 -0
  140. package/src/prompts/tools/web-search.md +10 -0
  141. package/src/prompts/tools/write.md +10 -0
  142. package/src/commands/init.md +0 -20
  143. /package/src/{core/tools/task/bundled-commands → prompts}/architect-plan.md +0 -0
  144. /package/src/{core/tools/task/bundled-agents → prompts}/browser.md +0 -0
  145. /package/src/{core/tools/task/bundled-agents → prompts}/explore.md +0 -0
  146. /package/src/{core/tools/task/bundled-commands → prompts}/implement-with-critic.md +0 -0
  147. /package/src/{core/tools/task/bundled-commands → prompts}/implement.md +0 -0
  148. /package/src/{core/tools/task/bundled-agents → prompts}/plan.md +0 -0
@@ -1,19 +1,25 @@
1
1
  import {
2
2
  appendFileSync,
3
3
  closeSync,
4
+ createWriteStream,
4
5
  existsSync,
6
+ fsyncSync,
5
7
  mkdirSync,
6
8
  openSync,
7
9
  readdirSync,
8
10
  readFileSync,
9
11
  readSync,
12
+ renameSync,
10
13
  statSync,
14
+ unlinkSync,
15
+ type WriteStream,
11
16
  writeFileSync,
12
17
  } from "node:fs";
13
- import { join, resolve } from "node:path";
18
+ import { basename, join, resolve } from "node:path";
14
19
  import type { AgentMessage } from "@oh-my-pi/pi-agent-core";
15
- import type { ImageContent, Message, TextContent } from "@oh-my-pi/pi-ai";
20
+ import type { ImageContent, Message, TextContent, Usage } from "@oh-my-pi/pi-ai";
16
21
  import { nanoid } from "nanoid";
22
+ import sharp from "sharp";
17
23
  import { getAgentDir as getDefaultAgentDir } from "../config";
18
24
  import {
19
25
  type BashExecutionMessage,
@@ -284,6 +290,10 @@ export function getLatestCompactionEntry(entries: SessionEntry[]): CompactionEnt
284
290
  return null;
285
291
  }
286
292
 
293
+ function toError(value: unknown): Error {
294
+ return value instanceof Error ? value : new Error(String(value));
295
+ }
296
+
287
297
  /**
288
298
  * Build the session context from entries using tree traversal.
289
299
  * If leafId is provided, walks from that entry to root.
@@ -571,6 +581,290 @@ function formatTimeAgo(date: Date): string {
571
581
  return date.toLocaleDateString();
572
582
  }
573
583
 
584
+ const MAX_PERSIST_CHARS = 500_000;
585
+ const TRUNCATION_NOTICE = "\n\n[Session persistence truncated large content]";
586
+ const PLACEHOLDER_IMAGE_DATA =
587
+ "/9j/4AAQSkZJRgABAQEASABIAAD/2wBDAP//////////////////////////////////////////////////////////////////////////////////////2wBDAf//////////////////////////////////////////////////////////////////////////////////////wAARCAAQABADASIAAhEBAxEB/8QAFQABAQAAAAAAAAAAAAAAAAAAAAf/xAAUEAEAAAAAAAAAAAAAAAAAAAAA/8QAFQEBAQAAAAAAAAAAAAAAAAAAAgP/xAAUEQEAAAAAAAAAAAAAAAAAAAAA/9oADAMBAAIRAxEAPwCkA//Z";
588
+
589
+ const TEXT_CONTENT_KEY = "content";
590
+
591
+ function fsyncDirSync(dir: string): void {
592
+ try {
593
+ const fd = openSync(dir, "r");
594
+ try {
595
+ fsyncSync(fd);
596
+ } finally {
597
+ closeSync(fd);
598
+ }
599
+ } catch {
600
+ // Best-effort: some platforms/filesystems don't support fsync on directories.
601
+ }
602
+ }
603
+
604
+ /**
605
+ * Recursively truncate large strings in an object for session persistence.
606
+ * - Truncates any oversized string fields (key-agnostic)
607
+ * - Replaces oversized image blocks with text notices
608
+ * - Updates lineCount when content is truncated
609
+ * - Returns original object if no changes needed (structural sharing)
610
+ */
611
+ function truncateString(value: string, maxLength: number): string {
612
+ if (value.length <= maxLength) return value;
613
+ let truncated = value.slice(0, maxLength);
614
+ if (truncated.length > 0) {
615
+ const last = truncated.charCodeAt(truncated.length - 1);
616
+ if (last >= 0xd800 && last <= 0xdbff) {
617
+ truncated = truncated.slice(0, -1);
618
+ }
619
+ }
620
+ return truncated;
621
+ }
622
+
623
+ function isImageBlock(value: unknown): value is { type: "image"; data: string; mimeType?: string } {
624
+ return (
625
+ typeof value === "object" &&
626
+ value !== null &&
627
+ "type" in value &&
628
+ (value as { type?: string }).type === "image" &&
629
+ "data" in value &&
630
+ typeof (value as { data?: string }).data === "string"
631
+ );
632
+ }
633
+
634
+ async function compressImageForPersistence(image: ImageContent): Promise<ImageContent> {
635
+ try {
636
+ const buffer = Buffer.from(image.data, "base64");
637
+ const pipeline = sharp(buffer, { failOnError: false });
638
+ const metadata = await pipeline.metadata();
639
+ const width = metadata.width ?? 0;
640
+ const height = metadata.height ?? 0;
641
+ const hasDims = width > 0 && height > 0;
642
+ const targetWidth = hasDims && width >= height ? 512 : undefined;
643
+ const targetHeight = hasDims && height > width ? 512 : undefined;
644
+ const resized = await pipeline
645
+ .resize({
646
+ width: hasDims ? targetWidth : 512,
647
+ height: hasDims ? targetHeight : 512,
648
+ fit: "inside",
649
+ withoutEnlargement: true,
650
+ })
651
+ .jpeg({ quality: 70 })
652
+ .toBuffer();
653
+ const base64 = resized.toString("base64");
654
+ if (base64.length > MAX_PERSIST_CHARS) {
655
+ return { type: "image", data: PLACEHOLDER_IMAGE_DATA, mimeType: "image/jpeg" };
656
+ }
657
+ return { type: "image", data: base64, mimeType: "image/jpeg" };
658
+ } catch {
659
+ return { type: "image", data: PLACEHOLDER_IMAGE_DATA, mimeType: "image/jpeg" };
660
+ }
661
+ }
662
+
663
+ async function truncateForPersistence<T>(obj: T, key?: string): Promise<T> {
664
+ if (obj === null || obj === undefined) return obj;
665
+
666
+ if (typeof obj === "string") {
667
+ if (obj.length > MAX_PERSIST_CHARS) {
668
+ const limit = Math.max(0, MAX_PERSIST_CHARS - TRUNCATION_NOTICE.length);
669
+ return `${truncateString(obj, limit)}${TRUNCATION_NOTICE}` as T;
670
+ }
671
+ return obj;
672
+ }
673
+
674
+ if (Array.isArray(obj)) {
675
+ let changed = false;
676
+ const result = await Promise.all(
677
+ obj.map(async (item) => {
678
+ // Special handling: compress oversized images while preserving shape
679
+ if (key === TEXT_CONTENT_KEY && isImageBlock(item)) {
680
+ if (item.data.length > MAX_PERSIST_CHARS) {
681
+ changed = true;
682
+ return compressImageForPersistence({
683
+ type: "image",
684
+ data: item.data,
685
+ mimeType: item.mimeType ?? "image/jpeg",
686
+ });
687
+ }
688
+ }
689
+ const newItem = await truncateForPersistence(item, key);
690
+ if (newItem !== item) changed = true;
691
+ return newItem;
692
+ }),
693
+ );
694
+ return changed ? (result as T) : obj;
695
+ }
696
+
697
+ if (typeof obj === "object") {
698
+ let changed = false;
699
+ const result: Record<string, unknown> = {};
700
+ for (const [k, v] of Object.entries(obj as Record<string, unknown>)) {
701
+ const newV = await truncateForPersistence(v, k);
702
+ result[k] = newV;
703
+ if (newV !== v) changed = true;
704
+ }
705
+ // Update lineCount if content was truncated (for FileMentionFile)
706
+ if (changed && "lineCount" in result && "content" in result && typeof result.content === "string") {
707
+ result.lineCount = result.content.split("\n").length;
708
+ }
709
+ return changed ? (result as T) : obj;
710
+ }
711
+
712
+ return obj;
713
+ }
714
+
715
+ async function prepareEntryForPersistence(entry: FileEntry): Promise<FileEntry> {
716
+ return truncateForPersistence(entry);
717
+ }
718
+
719
+ class NdjsonFileWriter {
720
+ private writeStream: WriteStream;
721
+ private closed = false;
722
+ private closing = false;
723
+ private error: Error | undefined;
724
+ private pendingWrites: Promise<void> = Promise.resolve();
725
+ private ready: Promise<void>;
726
+ private fd: number | null = null;
727
+ private onError: ((err: Error) => void) | undefined;
728
+
729
+ constructor(path: string, options?: { flags?: string; onError?: (err: Error) => void }) {
730
+ this.onError = options?.onError;
731
+ this.writeStream = createWriteStream(path, { flags: options?.flags ?? "a" });
732
+ this.ready = new Promise<void>((resolve, reject) => {
733
+ const onOpen = (fd: number) => {
734
+ this.fd = fd;
735
+ this.writeStream.off("error", onError);
736
+ resolve();
737
+ };
738
+ const onError = (err: Error) => {
739
+ this.writeStream.off("open", onOpen);
740
+ reject(err);
741
+ };
742
+ this.writeStream.once("open", onOpen);
743
+ this.writeStream.once("error", onError);
744
+ });
745
+ this.writeStream.on("error", (err: Error) => {
746
+ const writeErr = toError(err);
747
+ if (!this.error) this.error = writeErr;
748
+ this.onError?.(writeErr);
749
+ });
750
+ }
751
+
752
+ private enqueue(task: () => Promise<void>): Promise<void> {
753
+ const run = async () => {
754
+ if (this.error) throw this.error;
755
+ await task();
756
+ };
757
+ const next = this.pendingWrites.then(run);
758
+ this.pendingWrites = next.catch((err) => {
759
+ if (!this.error) this.error = toError(err);
760
+ });
761
+ return next;
762
+ }
763
+
764
+ private async writeLine(line: string): Promise<void> {
765
+ if (this.error) throw this.error;
766
+ await new Promise<void>((resolve, reject) => {
767
+ let settled = false;
768
+ const onError = (err: Error) => {
769
+ if (settled) return;
770
+ settled = true;
771
+ const writeErr = toError(err);
772
+ if (!this.error) this.error = writeErr;
773
+ this.writeStream.off("error", onError);
774
+ reject(writeErr);
775
+ };
776
+ this.writeStream.once("error", onError);
777
+ this.writeStream.write(line, (err) => {
778
+ if (settled) return;
779
+ settled = true;
780
+ this.writeStream.off("error", onError);
781
+ if (err) {
782
+ const writeErr = toError(err);
783
+ if (!this.error) this.error = writeErr;
784
+ reject(writeErr);
785
+ } else {
786
+ resolve();
787
+ }
788
+ });
789
+ if (this.error && !settled) {
790
+ settled = true;
791
+ this.writeStream.off("error", onError);
792
+ reject(this.error);
793
+ }
794
+ });
795
+ }
796
+
797
+ /** Queue a write. Returns a promise so callers can await if needed. */
798
+ write(entry: FileEntry): Promise<void> {
799
+ if (this.closed || this.closing) throw new Error("Writer closed");
800
+ if (this.error) throw this.error;
801
+ const line = `${JSON.stringify(entry)}\n`;
802
+ return this.enqueue(() => this.writeLine(line));
803
+ }
804
+
805
+ /** Flush all buffered data to disk. Waits for all queued writes and fsync. */
806
+ async flush(): Promise<void> {
807
+ if (this.closed) return;
808
+ if (this.error) throw this.error;
809
+
810
+ await this.enqueue(async () => {});
811
+
812
+ if (this.error) throw this.error;
813
+
814
+ await this.ready;
815
+ const fd = this.fd;
816
+ if (typeof fd === "number") {
817
+ try {
818
+ fsyncSync(fd);
819
+ } catch (err) {
820
+ const fsyncErr = toError(err);
821
+ if (!this.error) this.error = fsyncErr;
822
+ throw fsyncErr;
823
+ }
824
+ }
825
+
826
+ if (this.error) throw this.error;
827
+ }
828
+
829
+ /** Close the writer, flushing all data. */
830
+ async close(): Promise<void> {
831
+ if (this.closed || this.closing) return;
832
+ this.closing = true;
833
+
834
+ let closeError: Error | undefined;
835
+ try {
836
+ await this.flush();
837
+ } catch (err) {
838
+ closeError = toError(err);
839
+ }
840
+
841
+ await this.pendingWrites;
842
+
843
+ await new Promise<void>((resolve, reject) => {
844
+ this.writeStream.end((err?: Error | null) => {
845
+ if (err) {
846
+ const endErr = toError(err);
847
+ if (!this.error) this.error = endErr;
848
+ reject(endErr);
849
+ } else {
850
+ resolve();
851
+ }
852
+ });
853
+ });
854
+
855
+ this.closed = true;
856
+ this.writeStream.removeAllListeners();
857
+
858
+ if (closeError) throw closeError;
859
+ if (this.error) throw this.error;
860
+ }
861
+
862
+ /** Check if there's a stored error. */
863
+ getError(): Error | undefined {
864
+ return this.error;
865
+ }
866
+ }
867
+
574
868
  /** Get recent sessions for display in welcome screen */
575
869
  export function getRecentSessions(sessionDir: string, limit = 3): RecentSessionInfo[] {
576
870
  return getSortedSessions(sessionDir).slice(0, limit);
@@ -595,6 +889,14 @@ export interface UsageStatistics {
595
889
  cost: number;
596
890
  }
597
891
 
892
+ function getTaskToolUsage(details: unknown): Usage | undefined {
893
+ if (!details || typeof details !== "object") return undefined;
894
+ const record = details as Record<string, unknown>;
895
+ const usage = record.usage;
896
+ if (!usage || typeof usage !== "object") return undefined;
897
+ return usage as Usage;
898
+ }
899
+
598
900
  export class SessionManager {
599
901
  private sessionId: string = "";
600
902
  private sessionTitle: string | undefined;
@@ -608,24 +910,37 @@ export class SessionManager {
608
910
  private labelsById: Map<string, string> = new Map();
609
911
  private leafId: string | null = null;
610
912
  private usageStatistics: UsageStatistics = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0 };
913
+ private persistWriter: NdjsonFileWriter | undefined;
914
+ private persistWriterPath: string | undefined;
915
+ private persistChain: Promise<void> = Promise.resolve();
916
+ private persistError: Error | undefined;
917
+ private persistErrorReported = false;
611
918
 
612
- private constructor(cwd: string, sessionDir: string, sessionFile: string | undefined, persist: boolean) {
919
+ private constructor(cwd: string, sessionDir: string, persist: boolean) {
613
920
  this.cwd = cwd;
614
921
  this.sessionDir = sessionDir;
615
922
  this.persist = persist;
616
923
  if (persist && sessionDir && !existsSync(sessionDir)) {
617
924
  mkdirSync(sessionDir, { recursive: true });
618
925
  }
926
+ // Note: call _initSession() or _initSessionFile() after construction
927
+ }
619
928
 
620
- if (sessionFile) {
621
- this.setSessionFile(sessionFile);
622
- } else {
623
- this.newSession();
624
- }
929
+ /** Initialize with a specific session file (used by factory methods) */
930
+ private async _initSessionFile(sessionFile: string): Promise<void> {
931
+ await this.setSessionFile(sessionFile);
932
+ }
933
+
934
+ /** Initialize with a new session (used by factory methods) */
935
+ private _initNewSession(): void {
936
+ this._newSessionSync();
625
937
  }
626
938
 
627
939
  /** Switch to a different session file (used for resume and branching) */
628
- setSessionFile(sessionFile: string): void {
940
+ async setSessionFile(sessionFile: string): Promise<void> {
941
+ await this._closePersistWriter();
942
+ this.persistError = undefined;
943
+ this.persistErrorReported = false;
629
944
  this.sessionFile = resolve(sessionFile);
630
945
  if (existsSync(this.sessionFile)) {
631
946
  this.fileEntries = loadEntriesFromFile(this.sessionFile);
@@ -634,17 +949,27 @@ export class SessionManager {
634
949
  this.sessionTitle = header?.title;
635
950
 
636
951
  if (migrateToCurrentVersion(this.fileEntries)) {
637
- this._rewriteFile();
952
+ await this._rewriteFile();
638
953
  }
639
954
 
640
955
  this._buildIndex();
641
956
  this.flushed = true;
642
957
  } else {
643
- this.newSession();
958
+ this._newSessionSync();
644
959
  }
645
960
  }
646
961
 
647
- newSession(options?: NewSessionOptions): string | undefined {
962
+ /** Start a new session. Closes any existing writer first. */
963
+ async newSession(options?: NewSessionOptions): Promise<string | undefined> {
964
+ await this._closePersistWriter();
965
+ return this._newSessionSync(options);
966
+ }
967
+
968
+ /** Sync version for initial creation (no existing writer to close) */
969
+ private _newSessionSync(options?: NewSessionOptions): string | undefined {
970
+ this.persistChain = Promise.resolve();
971
+ this.persistError = undefined;
972
+ this.persistErrorReported = false;
648
973
  this.sessionId = nanoid();
649
974
  const timestamp = new Date().toISOString();
650
975
  const header: SessionHeader = {
@@ -693,19 +1018,118 @@ export class SessionManager {
693
1018
  this.usageStatistics.cacheWrite += usage.cacheWrite;
694
1019
  this.usageStatistics.cost += usage.cost.total;
695
1020
  }
1021
+
1022
+ if (entry.type === "message" && entry.message.role === "toolResult" && entry.message.toolName === "task") {
1023
+ const usage = getTaskToolUsage(entry.message.details);
1024
+ if (usage) {
1025
+ this.usageStatistics.input += usage.input;
1026
+ this.usageStatistics.output += usage.output;
1027
+ this.usageStatistics.cacheRead += usage.cacheRead;
1028
+ this.usageStatistics.cacheWrite += usage.cacheWrite;
1029
+ this.usageStatistics.cost += usage.cost.total;
1030
+ }
1031
+ }
1032
+ }
1033
+ }
1034
+
1035
+ private _recordPersistError(err: unknown): Error {
1036
+ const normalized = toError(err);
1037
+ if (!this.persistError) this.persistError = normalized;
1038
+ if (!this.persistErrorReported) {
1039
+ this.persistErrorReported = true;
1040
+ console.error("Session persistence error:", normalized);
1041
+ }
1042
+ return normalized;
1043
+ }
1044
+
1045
+ private _queuePersistTask(task: () => Promise<void>, options?: { ignoreError?: boolean }): Promise<void> {
1046
+ const next = this.persistChain.then(async () => {
1047
+ if (this.persistError && !options?.ignoreError) throw this.persistError;
1048
+ await task();
1049
+ });
1050
+ this.persistChain = next.catch((err) => {
1051
+ this._recordPersistError(err);
1052
+ });
1053
+ return next;
1054
+ }
1055
+
1056
+ private _ensurePersistWriter(): NdjsonFileWriter | undefined {
1057
+ if (!this.persist || !this.sessionFile) return undefined;
1058
+ if (this.persistError) throw this.persistError;
1059
+ if (this.persistWriter && this.persistWriterPath === this.sessionFile) return this.persistWriter;
1060
+ // Note: caller must await _closePersistWriter() before calling this if switching files
1061
+ this.persistWriter = new NdjsonFileWriter(this.sessionFile, {
1062
+ onError: (err) => {
1063
+ this._recordPersistError(err);
1064
+ },
1065
+ });
1066
+ this.persistWriterPath = this.sessionFile;
1067
+ return this.persistWriter;
1068
+ }
1069
+
1070
+ private async _closePersistWriterInternal(): Promise<void> {
1071
+ if (this.persistWriter) {
1072
+ await this.persistWriter.close();
1073
+ this.persistWriter = undefined;
696
1074
  }
1075
+ this.persistWriterPath = undefined;
697
1076
  }
698
1077
 
699
- private _rewriteFile(): void {
1078
+ private async _closePersistWriter(): Promise<void> {
1079
+ await this._queuePersistTask(
1080
+ async () => {
1081
+ await this._closePersistWriterInternal();
1082
+ },
1083
+ { ignoreError: true },
1084
+ );
1085
+ }
1086
+
1087
+ private async _writeEntriesAtomically(entries: FileEntry[]): Promise<void> {
1088
+ if (!this.sessionFile) return;
1089
+ const dir = resolve(this.sessionFile, "..");
1090
+ const tempPath = join(dir, `.${basename(this.sessionFile)}.${nanoid(6)}.tmp`);
1091
+ const writer = new NdjsonFileWriter(tempPath, { flags: "w" });
1092
+ for (const entry of entries) {
1093
+ await writer.write(entry);
1094
+ }
1095
+ await writer.flush();
1096
+ await writer.close();
1097
+ try {
1098
+ renameSync(tempPath, this.sessionFile);
1099
+ fsyncDirSync(dir);
1100
+ } catch (err) {
1101
+ try {
1102
+ unlinkSync(tempPath);
1103
+ } catch {
1104
+ // Ignore cleanup errors
1105
+ }
1106
+ throw toError(err);
1107
+ }
1108
+ }
1109
+
1110
+ private async _rewriteFile(): Promise<void> {
700
1111
  if (!this.persist || !this.sessionFile) return;
701
- const content = `${this.fileEntries.map((e) => JSON.stringify(e)).join("\n")}\n`;
702
- writeFileSync(this.sessionFile, content);
1112
+ await this._queuePersistTask(async () => {
1113
+ await this._closePersistWriterInternal();
1114
+ const entries = await Promise.all(this.fileEntries.map((entry) => prepareEntryForPersistence(entry)));
1115
+ await this._writeEntriesAtomically(entries);
1116
+ this.flushed = true;
1117
+ });
703
1118
  }
704
1119
 
705
1120
  isPersisted(): boolean {
706
1121
  return this.persist;
707
1122
  }
708
1123
 
1124
+ /** Flush pending writes to disk. Call before switching sessions or on shutdown. */
1125
+ async flush(): Promise<void> {
1126
+ if (!this.persistWriter) return;
1127
+ await this._queuePersistTask(async () => {
1128
+ if (this.persistWriter) await this.persistWriter.flush();
1129
+ });
1130
+ if (this.persistError) throw this.persistError;
1131
+ }
1132
+
709
1133
  getCwd(): string {
710
1134
  return this.cwd;
711
1135
  }
@@ -731,7 +1155,7 @@ export class SessionManager {
731
1155
  return this.sessionTitle;
732
1156
  }
733
1157
 
734
- setSessionTitle(title: string): void {
1158
+ async setSessionTitle(title: string): Promise<void> {
735
1159
  this.sessionTitle = title;
736
1160
 
737
1161
  // Update the in-memory header (so first flush includes title)
@@ -741,37 +1165,71 @@ export class SessionManager {
741
1165
  }
742
1166
 
743
1167
  // Update the session file header with the title (if already flushed)
744
- if (this.persist && this.sessionFile && existsSync(this.sessionFile)) {
745
- try {
746
- const content = readFileSync(this.sessionFile, "utf-8");
747
- const lines = content.split("\n");
748
- if (lines.length > 0) {
749
- const fileHeader = JSON.parse(lines[0]) as SessionHeader;
750
- if (fileHeader.type === "session") {
751
- fileHeader.title = title;
752
- lines[0] = JSON.stringify(fileHeader);
753
- writeFileSync(this.sessionFile, lines.join("\n"));
1168
+ const sessionFile = this.sessionFile;
1169
+ if (this.persist && sessionFile && existsSync(sessionFile)) {
1170
+ await this._queuePersistTask(async () => {
1171
+ await this._closePersistWriterInternal();
1172
+ try {
1173
+ const content = readFileSync(sessionFile, "utf-8");
1174
+ const lines = content.split("\n");
1175
+ if (lines.length > 0) {
1176
+ const fileHeader = JSON.parse(lines[0]) as SessionHeader;
1177
+ if (fileHeader.type === "session") {
1178
+ fileHeader.title = title;
1179
+ lines[0] = JSON.stringify(fileHeader);
1180
+ const tempPath = join(resolve(sessionFile, ".."), `.${basename(sessionFile)}.${nanoid(6)}.tmp`);
1181
+ writeFileSync(tempPath, lines.join("\n"));
1182
+ const fd = openSync(tempPath, "r");
1183
+ try {
1184
+ fsyncSync(fd);
1185
+ } finally {
1186
+ closeSync(fd);
1187
+ }
1188
+ try {
1189
+ renameSync(tempPath, sessionFile);
1190
+ fsyncDirSync(resolve(sessionFile, ".."));
1191
+ } catch (err) {
1192
+ try {
1193
+ unlinkSync(tempPath);
1194
+ } catch {
1195
+ // Ignore cleanup errors
1196
+ }
1197
+ throw err;
1198
+ }
1199
+ }
754
1200
  }
1201
+ } catch (err) {
1202
+ this._recordPersistError(err);
1203
+ throw err;
755
1204
  }
756
- } catch {
757
- // Ignore errors updating title
758
- }
1205
+ });
759
1206
  }
760
1207
  }
761
1208
 
762
1209
  _persist(entry: SessionEntry): void {
763
1210
  if (!this.persist || !this.sessionFile) return;
1211
+ if (this.persistError) throw this.persistError;
764
1212
 
765
1213
  const hasAssistant = this.fileEntries.some((e) => e.type === "message" && e.message.role === "assistant");
766
1214
  if (!hasAssistant) return;
767
1215
 
768
1216
  if (!this.flushed) {
769
- for (const e of this.fileEntries) {
770
- appendFileSync(this.sessionFile, `${JSON.stringify(e)}\n`);
771
- }
772
1217
  this.flushed = true;
1218
+ void this._queuePersistTask(async () => {
1219
+ const writer = this._ensurePersistWriter();
1220
+ if (!writer) return;
1221
+ const entries = await Promise.all(this.fileEntries.map((e) => prepareEntryForPersistence(e)));
1222
+ for (const persistedEntry of entries) {
1223
+ await writer.write(persistedEntry);
1224
+ }
1225
+ });
773
1226
  } else {
774
- appendFileSync(this.sessionFile, `${JSON.stringify(entry)}\n`);
1227
+ void this._queuePersistTask(async () => {
1228
+ const writer = this._ensurePersistWriter();
1229
+ if (!writer) return;
1230
+ const persistedEntry = await prepareEntryForPersistence(entry);
1231
+ await writer.write(persistedEntry);
1232
+ });
775
1233
  }
776
1234
  }
777
1235
 
@@ -788,6 +1246,17 @@ export class SessionManager {
788
1246
  this.usageStatistics.cacheWrite += usage.cacheWrite;
789
1247
  this.usageStatistics.cost += usage.cost.total;
790
1248
  }
1249
+
1250
+ if (entry.type === "message" && entry.message.role === "toolResult" && entry.message.toolName === "task") {
1251
+ const usage = getTaskToolUsage(entry.message.details);
1252
+ if (usage) {
1253
+ this.usageStatistics.input += usage.input;
1254
+ this.usageStatistics.output += usage.output;
1255
+ this.usageStatistics.cacheRead += usage.cacheRead;
1256
+ this.usageStatistics.cacheWrite += usage.cacheWrite;
1257
+ this.usageStatistics.cost += usage.cost.total;
1258
+ }
1259
+ }
791
1260
  }
792
1261
 
793
1262
  /** Append a message as child of current leaf, then advance leaf. Returns entry id.
@@ -1233,7 +1702,9 @@ export class SessionManager {
1233
1702
  */
1234
1703
  static create(cwd: string, sessionDir?: string): SessionManager {
1235
1704
  const dir = sessionDir ?? getDefaultSessionDir(cwd);
1236
- return new SessionManager(cwd, dir, undefined, true);
1705
+ const manager = new SessionManager(cwd, dir, true);
1706
+ manager._initNewSession();
1707
+ return manager;
1237
1708
  }
1238
1709
 
1239
1710
  /**
@@ -1241,14 +1712,16 @@ export class SessionManager {
1241
1712
  * @param path Path to session file
1242
1713
  * @param sessionDir Optional session directory for /new or /branch. If omitted, derives from file's parent.
1243
1714
  */
1244
- static open(path: string, sessionDir?: string): SessionManager {
1715
+ static async open(path: string, sessionDir?: string): Promise<SessionManager> {
1245
1716
  // Extract cwd from session header if possible, otherwise use process.cwd()
1246
1717
  const entries = loadEntriesFromFile(path);
1247
1718
  const header = entries.find((e) => e.type === "session") as SessionHeader | undefined;
1248
1719
  const cwd = header?.cwd ?? process.cwd();
1249
1720
  // If no sessionDir provided, derive from file's parent directory
1250
1721
  const dir = sessionDir ?? resolve(path, "..");
1251
- return new SessionManager(cwd, dir, path, true);
1722
+ const manager = new SessionManager(cwd, dir, true);
1723
+ await manager._initSessionFile(path);
1724
+ return manager;
1252
1725
  }
1253
1726
 
1254
1727
  /**
@@ -1256,18 +1729,23 @@ export class SessionManager {
1256
1729
  * @param cwd Working directory
1257
1730
  * @param sessionDir Optional session directory. If omitted, uses default (~/.omp/agent/sessions/<encoded-cwd>/).
1258
1731
  */
1259
- static continueRecent(cwd: string, sessionDir?: string): SessionManager {
1732
+ static async continueRecent(cwd: string, sessionDir?: string): Promise<SessionManager> {
1260
1733
  const dir = sessionDir ?? getDefaultSessionDir(cwd);
1261
1734
  const mostRecent = findMostRecentSession(dir);
1735
+ const manager = new SessionManager(cwd, dir, true);
1262
1736
  if (mostRecent) {
1263
- return new SessionManager(cwd, dir, mostRecent, true);
1737
+ await manager._initSessionFile(mostRecent);
1738
+ } else {
1739
+ manager._initNewSession();
1264
1740
  }
1265
- return new SessionManager(cwd, dir, undefined, true);
1741
+ return manager;
1266
1742
  }
1267
1743
 
1268
1744
  /** Create an in-memory session (no file persistence) */
1269
1745
  static inMemory(cwd: string = process.cwd()): SessionManager {
1270
- return new SessionManager(cwd, "", undefined, false);
1746
+ const manager = new SessionManager(cwd, "", false);
1747
+ manager._initNewSession();
1748
+ return manager;
1271
1749
  }
1272
1750
 
1273
1751
  /**