@oh-my-pi/pi-coding-agent 3.13.1337 → 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 (149) hide show
  1. package/CHANGELOG.md +88 -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/index.ts +1 -1
  15. package/src/core/hooks/tool-wrapper.ts +0 -1
  16. package/src/core/hooks/types.ts +2 -2
  17. package/src/core/plugins/doctor.ts +9 -1
  18. package/src/core/sdk.ts +2 -1
  19. package/src/core/session-manager.ts +552 -41
  20. package/src/core/settings-manager.ts +174 -0
  21. package/src/core/system-prompt.ts +9 -14
  22. package/src/core/title-generator.ts +2 -8
  23. package/src/core/tools/ask.ts +19 -37
  24. package/src/core/tools/bash.ts +2 -37
  25. package/src/core/tools/edit.ts +2 -9
  26. package/src/core/tools/exa/render.ts +52 -48
  27. package/src/core/tools/find.ts +10 -8
  28. package/src/core/tools/grep.ts +45 -17
  29. package/src/core/tools/ls.ts +22 -2
  30. package/src/core/tools/lsp/clients/biome-client.ts +207 -0
  31. package/src/core/tools/lsp/clients/index.ts +49 -0
  32. package/src/core/tools/lsp/clients/lsp-linter-client.ts +98 -0
  33. package/src/core/tools/lsp/config.ts +3 -0
  34. package/src/core/tools/lsp/index.ts +107 -55
  35. package/src/core/tools/lsp/render.ts +192 -79
  36. package/src/core/tools/lsp/types.ts +27 -0
  37. package/src/core/tools/lsp/utils.ts +62 -22
  38. package/src/core/tools/notebook.ts +9 -1
  39. package/src/core/tools/output.ts +37 -14
  40. package/src/core/tools/read.ts +349 -34
  41. package/src/core/tools/renderers.ts +290 -89
  42. package/src/core/tools/review.ts +12 -5
  43. package/src/core/tools/task/agents.ts +5 -5
  44. package/src/core/tools/task/commands.ts +3 -3
  45. package/src/core/tools/task/executor.ts +33 -1
  46. package/src/core/tools/task/index.ts +93 -6
  47. package/src/core/tools/task/render.ts +147 -66
  48. package/src/core/tools/task/types.ts +14 -9
  49. package/src/core/tools/web-fetch.ts +242 -103
  50. package/src/core/tools/web-search/index.ts +64 -20
  51. package/src/core/tools/web-search/providers/exa.ts +68 -172
  52. package/src/core/tools/web-search/render.ts +264 -74
  53. package/src/core/tools/write.ts +2 -8
  54. package/src/main.ts +10 -6
  55. package/src/modes/cleanup.ts +23 -0
  56. package/src/modes/index.ts +9 -4
  57. package/src/modes/interactive/components/bash-execution.ts +6 -3
  58. package/src/modes/interactive/components/branch-summary-message.ts +1 -1
  59. package/src/modes/interactive/components/compaction-summary-message.ts +1 -1
  60. package/src/modes/interactive/components/dynamic-border.ts +1 -1
  61. package/src/modes/interactive/components/extensions/extension-dashboard.ts +4 -5
  62. package/src/modes/interactive/components/extensions/extension-list.ts +18 -16
  63. package/src/modes/interactive/components/extensions/inspector-panel.ts +8 -8
  64. package/src/modes/interactive/components/hook-message.ts +2 -2
  65. package/src/modes/interactive/components/hook-selector.ts +1 -1
  66. package/src/modes/interactive/components/model-selector.ts +22 -9
  67. package/src/modes/interactive/components/oauth-selector.ts +20 -4
  68. package/src/modes/interactive/components/plugin-settings.ts +4 -2
  69. package/src/modes/interactive/components/session-selector.ts +9 -6
  70. package/src/modes/interactive/components/settings-defs.ts +285 -1
  71. package/src/modes/interactive/components/settings-selector.ts +176 -3
  72. package/src/modes/interactive/components/status-line/index.ts +4 -0
  73. package/src/modes/interactive/components/status-line/presets.ts +94 -0
  74. package/src/modes/interactive/components/status-line/segments.ts +350 -0
  75. package/src/modes/interactive/components/status-line/separators.ts +55 -0
  76. package/src/modes/interactive/components/status-line/types.ts +81 -0
  77. package/src/modes/interactive/components/status-line-segment-editor.ts +357 -0
  78. package/src/modes/interactive/components/status-line.ts +169 -233
  79. package/src/modes/interactive/components/tool-execution.ts +446 -211
  80. package/src/modes/interactive/components/tree-selector.ts +17 -6
  81. package/src/modes/interactive/components/ttsr-notification.ts +4 -4
  82. package/src/modes/interactive/components/welcome.ts +27 -19
  83. package/src/modes/interactive/interactive-mode.ts +98 -13
  84. package/src/modes/interactive/theme/dark.json +3 -2
  85. package/src/modes/interactive/theme/defaults/dark-arctic.json +111 -0
  86. package/src/modes/interactive/theme/defaults/dark-catppuccin.json +106 -0
  87. package/src/modes/interactive/theme/defaults/dark-cyberpunk.json +109 -0
  88. package/src/modes/interactive/theme/defaults/dark-dracula.json +105 -0
  89. package/src/modes/interactive/theme/defaults/dark-forest.json +103 -0
  90. package/src/modes/interactive/theme/defaults/dark-github.json +112 -0
  91. package/src/modes/interactive/theme/defaults/dark-gruvbox.json +119 -0
  92. package/src/modes/interactive/theme/defaults/dark-monochrome.json +101 -0
  93. package/src/modes/interactive/theme/defaults/dark-monokai.json +105 -0
  94. package/src/modes/interactive/theme/defaults/dark-nord.json +104 -0
  95. package/src/modes/interactive/theme/defaults/dark-ocean.json +108 -0
  96. package/src/modes/interactive/theme/defaults/dark-one.json +107 -0
  97. package/src/modes/interactive/theme/defaults/dark-retro.json +99 -0
  98. package/src/modes/interactive/theme/defaults/dark-rose-pine.json +95 -0
  99. package/src/modes/interactive/theme/defaults/dark-solarized.json +96 -0
  100. package/src/modes/interactive/theme/defaults/dark-sunset.json +106 -0
  101. package/src/modes/interactive/theme/defaults/dark-synthwave.json +102 -0
  102. package/src/modes/interactive/theme/defaults/dark-tokyo-night.json +108 -0
  103. package/src/modes/interactive/theme/defaults/index.ts +67 -0
  104. package/src/modes/interactive/theme/defaults/light-arctic.json +106 -0
  105. package/src/modes/interactive/theme/defaults/light-catppuccin.json +105 -0
  106. package/src/modes/interactive/theme/defaults/light-cyberpunk.json +103 -0
  107. package/src/modes/interactive/theme/defaults/light-forest.json +107 -0
  108. package/src/modes/interactive/theme/defaults/light-github.json +114 -0
  109. package/src/modes/interactive/theme/defaults/light-gruvbox.json +115 -0
  110. package/src/modes/interactive/theme/defaults/light-monochrome.json +100 -0
  111. package/src/modes/interactive/theme/defaults/light-ocean.json +106 -0
  112. package/src/modes/interactive/theme/defaults/light-one.json +105 -0
  113. package/src/modes/interactive/theme/defaults/light-retro.json +105 -0
  114. package/src/modes/interactive/theme/defaults/light-solarized.json +101 -0
  115. package/src/modes/interactive/theme/defaults/light-sunset.json +106 -0
  116. package/src/modes/interactive/theme/defaults/light-synthwave.json +105 -0
  117. package/src/modes/interactive/theme/defaults/light-tokyo-night.json +118 -0
  118. package/src/modes/interactive/theme/light.json +3 -2
  119. package/src/modes/interactive/theme/theme-schema.json +120 -4
  120. package/src/modes/interactive/theme/theme.ts +1228 -14
  121. package/src/prompts/branch-summary-preamble.md +3 -0
  122. package/src/prompts/branch-summary.md +28 -0
  123. package/src/prompts/compaction-summary.md +34 -0
  124. package/src/prompts/compaction-turn-prefix.md +16 -0
  125. package/src/prompts/compaction-update-summary.md +41 -0
  126. package/src/prompts/init.md +30 -0
  127. package/src/{core/tools/task/bundled-agents → prompts}/reviewer.md +6 -0
  128. package/src/prompts/summarization-system.md +3 -0
  129. package/src/prompts/system-prompt.md +27 -0
  130. package/src/{core/tools/task/bundled-agents → prompts}/task.md +2 -0
  131. package/src/prompts/title-system.md +8 -0
  132. package/src/prompts/tools/ask.md +24 -0
  133. package/src/prompts/tools/bash.md +23 -0
  134. package/src/prompts/tools/edit.md +9 -0
  135. package/src/prompts/tools/find.md +6 -0
  136. package/src/prompts/tools/grep.md +12 -0
  137. package/src/prompts/tools/lsp.md +14 -0
  138. package/src/prompts/tools/output.md +23 -0
  139. package/src/prompts/tools/read.md +25 -0
  140. package/src/prompts/tools/web-fetch.md +8 -0
  141. package/src/prompts/tools/web-search.md +10 -0
  142. package/src/prompts/tools/write.md +10 -0
  143. package/src/commands/init.md +0 -20
  144. /package/src/{core/tools/task/bundled-commands → prompts}/architect-plan.md +0 -0
  145. /package/src/{core/tools/task/bundled-agents → prompts}/browser.md +0 -0
  146. /package/src/{core/tools/task/bundled-agents → prompts}/explore.md +0 -0
  147. /package/src/{core/tools/task/bundled-commands → prompts}/implement-with-critic.md +0 -0
  148. /package/src/{core/tools/task/bundled-commands → prompts}/implement.md +0 -0
  149. /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,
@@ -192,6 +198,7 @@ export type ReadonlySessionManager = Pick<
192
198
  | "getHeader"
193
199
  | "getEntries"
194
200
  | "getTree"
201
+ | "getUsageStatistics"
195
202
  >;
196
203
 
197
204
  /** Generate a unique short ID (8 hex chars, collision-checked) */
@@ -283,6 +290,10 @@ export function getLatestCompactionEntry(entries: SessionEntry[]): CompactionEnt
283
290
  return null;
284
291
  }
285
292
 
293
+ function toError(value: unknown): Error {
294
+ return value instanceof Error ? value : new Error(String(value));
295
+ }
296
+
286
297
  /**
287
298
  * Build the session context from entries using tree traversal.
288
299
  * If leafId is provided, walks from that entry to root.
@@ -570,6 +581,290 @@ function formatTimeAgo(date: Date): string {
570
581
  return date.toLocaleDateString();
571
582
  }
572
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
+
573
868
  /** Get recent sessions for display in welcome screen */
574
869
  export function getRecentSessions(sessionDir: string, limit = 3): RecentSessionInfo[] {
575
870
  return getSortedSessions(sessionDir).slice(0, limit);
@@ -586,6 +881,22 @@ export function getRecentSessions(sessionDir: string, limit = 3): RecentSessionI
586
881
  * Use buildSessionContext() to get the resolved message list for the LLM, which
587
882
  * handles compaction summaries and follows the path from root to current leaf.
588
883
  */
884
+ export interface UsageStatistics {
885
+ input: number;
886
+ output: number;
887
+ cacheRead: number;
888
+ cacheWrite: number;
889
+ cost: number;
890
+ }
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
+
589
900
  export class SessionManager {
590
901
  private sessionId: string = "";
591
902
  private sessionTitle: string | undefined;
@@ -598,24 +909,38 @@ export class SessionManager {
598
909
  private byId: Map<string, SessionEntry> = new Map();
599
910
  private labelsById: Map<string, string> = new Map();
600
911
  private leafId: string | null = null;
601
-
602
- private constructor(cwd: string, sessionDir: string, sessionFile: string | undefined, persist: boolean) {
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;
918
+
919
+ private constructor(cwd: string, sessionDir: string, persist: boolean) {
603
920
  this.cwd = cwd;
604
921
  this.sessionDir = sessionDir;
605
922
  this.persist = persist;
606
923
  if (persist && sessionDir && !existsSync(sessionDir)) {
607
924
  mkdirSync(sessionDir, { recursive: true });
608
925
  }
926
+ // Note: call _initSession() or _initSessionFile() after construction
927
+ }
609
928
 
610
- if (sessionFile) {
611
- this.setSessionFile(sessionFile);
612
- } else {
613
- this.newSession();
614
- }
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();
615
937
  }
616
938
 
617
939
  /** Switch to a different session file (used for resume and branching) */
618
- setSessionFile(sessionFile: string): void {
940
+ async setSessionFile(sessionFile: string): Promise<void> {
941
+ await this._closePersistWriter();
942
+ this.persistError = undefined;
943
+ this.persistErrorReported = false;
619
944
  this.sessionFile = resolve(sessionFile);
620
945
  if (existsSync(this.sessionFile)) {
621
946
  this.fileEntries = loadEntriesFromFile(this.sessionFile);
@@ -624,17 +949,27 @@ export class SessionManager {
624
949
  this.sessionTitle = header?.title;
625
950
 
626
951
  if (migrateToCurrentVersion(this.fileEntries)) {
627
- this._rewriteFile();
952
+ await this._rewriteFile();
628
953
  }
629
954
 
630
955
  this._buildIndex();
631
956
  this.flushed = true;
632
957
  } else {
633
- this.newSession();
958
+ this._newSessionSync();
634
959
  }
635
960
  }
636
961
 
637
- 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;
638
973
  this.sessionId = nanoid();
639
974
  const timestamp = new Date().toISOString();
640
975
  const header: SessionHeader = {
@@ -649,6 +984,7 @@ export class SessionManager {
649
984
  this.byId.clear();
650
985
  this.leafId = null;
651
986
  this.flushed = false;
987
+ this.usageStatistics = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0 };
652
988
 
653
989
  // Only generate filename if persisting and not already set (e.g., via --session flag)
654
990
  if (this.persist && !this.sessionFile) {
@@ -662,6 +998,7 @@ export class SessionManager {
662
998
  this.byId.clear();
663
999
  this.labelsById.clear();
664
1000
  this.leafId = null;
1001
+ this.usageStatistics = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0 };
665
1002
  for (const entry of this.fileEntries) {
666
1003
  if (entry.type === "session") continue;
667
1004
  this.byId.set(entry.id, entry);
@@ -673,23 +1010,135 @@ export class SessionManager {
673
1010
  this.labelsById.delete(entry.targetId);
674
1011
  }
675
1012
  }
1013
+ if (entry.type === "message" && entry.message.role === "assistant") {
1014
+ const usage = entry.message.usage;
1015
+ this.usageStatistics.input += usage.input;
1016
+ this.usageStatistics.output += usage.output;
1017
+ this.usageStatistics.cacheRead += usage.cacheRead;
1018
+ this.usageStatistics.cacheWrite += usage.cacheWrite;
1019
+ this.usageStatistics.cost += usage.cost.total;
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;
1074
+ }
1075
+ this.persistWriterPath = undefined;
1076
+ }
1077
+
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);
676
1107
  }
677
1108
  }
678
1109
 
679
- private _rewriteFile(): void {
1110
+ private async _rewriteFile(): Promise<void> {
680
1111
  if (!this.persist || !this.sessionFile) return;
681
- const content = `${this.fileEntries.map((e) => JSON.stringify(e)).join("\n")}\n`;
682
- 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
+ });
683
1118
  }
684
1119
 
685
1120
  isPersisted(): boolean {
686
1121
  return this.persist;
687
1122
  }
688
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
+
689
1133
  getCwd(): string {
690
1134
  return this.cwd;
691
1135
  }
692
1136
 
1137
+ /** Get usage statistics across all assistant messages in the session. */
1138
+ getUsageStatistics(): UsageStatistics {
1139
+ return this.usageStatistics;
1140
+ }
1141
+
693
1142
  getSessionDir(): string {
694
1143
  return this.sessionDir;
695
1144
  }
@@ -706,7 +1155,7 @@ export class SessionManager {
706
1155
  return this.sessionTitle;
707
1156
  }
708
1157
 
709
- setSessionTitle(title: string): void {
1158
+ async setSessionTitle(title: string): Promise<void> {
710
1159
  this.sessionTitle = title;
711
1160
 
712
1161
  // Update the in-memory header (so first flush includes title)
@@ -716,37 +1165,71 @@ export class SessionManager {
716
1165
  }
717
1166
 
718
1167
  // Update the session file header with the title (if already flushed)
719
- if (this.persist && this.sessionFile && existsSync(this.sessionFile)) {
720
- try {
721
- const content = readFileSync(this.sessionFile, "utf-8");
722
- const lines = content.split("\n");
723
- if (lines.length > 0) {
724
- const fileHeader = JSON.parse(lines[0]) as SessionHeader;
725
- if (fileHeader.type === "session") {
726
- fileHeader.title = title;
727
- lines[0] = JSON.stringify(fileHeader);
728
- 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
+ }
729
1200
  }
1201
+ } catch (err) {
1202
+ this._recordPersistError(err);
1203
+ throw err;
730
1204
  }
731
- } catch {
732
- // Ignore errors updating title
733
- }
1205
+ });
734
1206
  }
735
1207
  }
736
1208
 
737
1209
  _persist(entry: SessionEntry): void {
738
1210
  if (!this.persist || !this.sessionFile) return;
1211
+ if (this.persistError) throw this.persistError;
739
1212
 
740
1213
  const hasAssistant = this.fileEntries.some((e) => e.type === "message" && e.message.role === "assistant");
741
1214
  if (!hasAssistant) return;
742
1215
 
743
1216
  if (!this.flushed) {
744
- for (const e of this.fileEntries) {
745
- appendFileSync(this.sessionFile, `${JSON.stringify(e)}\n`);
746
- }
747
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
+ });
748
1226
  } else {
749
- 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
+ });
750
1233
  }
751
1234
  }
752
1235
 
@@ -755,6 +1238,25 @@ export class SessionManager {
755
1238
  this.byId.set(entry.id, entry);
756
1239
  this.leafId = entry.id;
757
1240
  this._persist(entry);
1241
+ if (entry.type === "message" && entry.message.role === "assistant") {
1242
+ const usage = entry.message.usage;
1243
+ this.usageStatistics.input += usage.input;
1244
+ this.usageStatistics.output += usage.output;
1245
+ this.usageStatistics.cacheRead += usage.cacheRead;
1246
+ this.usageStatistics.cacheWrite += usage.cacheWrite;
1247
+ this.usageStatistics.cost += usage.cost.total;
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
+ }
758
1260
  }
759
1261
 
760
1262
  /** Append a message as child of current leaf, then advance leaf. Returns entry id.
@@ -1200,7 +1702,9 @@ export class SessionManager {
1200
1702
  */
1201
1703
  static create(cwd: string, sessionDir?: string): SessionManager {
1202
1704
  const dir = sessionDir ?? getDefaultSessionDir(cwd);
1203
- return new SessionManager(cwd, dir, undefined, true);
1705
+ const manager = new SessionManager(cwd, dir, true);
1706
+ manager._initNewSession();
1707
+ return manager;
1204
1708
  }
1205
1709
 
1206
1710
  /**
@@ -1208,14 +1712,16 @@ export class SessionManager {
1208
1712
  * @param path Path to session file
1209
1713
  * @param sessionDir Optional session directory for /new or /branch. If omitted, derives from file's parent.
1210
1714
  */
1211
- static open(path: string, sessionDir?: string): SessionManager {
1715
+ static async open(path: string, sessionDir?: string): Promise<SessionManager> {
1212
1716
  // Extract cwd from session header if possible, otherwise use process.cwd()
1213
1717
  const entries = loadEntriesFromFile(path);
1214
1718
  const header = entries.find((e) => e.type === "session") as SessionHeader | undefined;
1215
1719
  const cwd = header?.cwd ?? process.cwd();
1216
1720
  // If no sessionDir provided, derive from file's parent directory
1217
1721
  const dir = sessionDir ?? resolve(path, "..");
1218
- return new SessionManager(cwd, dir, path, true);
1722
+ const manager = new SessionManager(cwd, dir, true);
1723
+ await manager._initSessionFile(path);
1724
+ return manager;
1219
1725
  }
1220
1726
 
1221
1727
  /**
@@ -1223,18 +1729,23 @@ export class SessionManager {
1223
1729
  * @param cwd Working directory
1224
1730
  * @param sessionDir Optional session directory. If omitted, uses default (~/.omp/agent/sessions/<encoded-cwd>/).
1225
1731
  */
1226
- static continueRecent(cwd: string, sessionDir?: string): SessionManager {
1732
+ static async continueRecent(cwd: string, sessionDir?: string): Promise<SessionManager> {
1227
1733
  const dir = sessionDir ?? getDefaultSessionDir(cwd);
1228
1734
  const mostRecent = findMostRecentSession(dir);
1735
+ const manager = new SessionManager(cwd, dir, true);
1229
1736
  if (mostRecent) {
1230
- return new SessionManager(cwd, dir, mostRecent, true);
1737
+ await manager._initSessionFile(mostRecent);
1738
+ } else {
1739
+ manager._initNewSession();
1231
1740
  }
1232
- return new SessionManager(cwd, dir, undefined, true);
1741
+ return manager;
1233
1742
  }
1234
1743
 
1235
1744
  /** Create an in-memory session (no file persistence) */
1236
1745
  static inMemory(cwd: string = process.cwd()): SessionManager {
1237
- return new SessionManager(cwd, "", undefined, false);
1746
+ const manager = new SessionManager(cwd, "", false);
1747
+ manager._initNewSession();
1748
+ return manager;
1238
1749
  }
1239
1750
 
1240
1751
  /**