@oh-my-pi/pi-coding-agent 3.30.0 → 3.32.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 (158) hide show
  1. package/CHANGELOG.md +85 -0
  2. package/package.json +5 -5
  3. package/src/cli/args.ts +4 -0
  4. package/src/core/agent-session.ts +29 -2
  5. package/src/core/bash-executor.ts +2 -1
  6. package/src/core/custom-commands/bundled/review/index.ts +367 -14
  7. package/src/core/custom-commands/bundled/wt/index.ts +1 -1
  8. package/src/core/sdk.ts +10 -2
  9. package/src/core/session-manager.ts +158 -246
  10. package/src/core/session-storage.ts +379 -0
  11. package/src/core/settings-manager.ts +155 -4
  12. package/src/core/slash-commands.ts +39 -13
  13. package/src/core/system-prompt.ts +62 -64
  14. package/src/core/tools/ask.ts +5 -4
  15. package/src/core/tools/bash-interceptor.ts +26 -61
  16. package/src/core/tools/bash.ts +13 -8
  17. package/src/core/tools/edit-diff.ts +11 -4
  18. package/src/core/tools/edit.ts +7 -13
  19. package/src/core/tools/find.ts +111 -50
  20. package/src/core/tools/gemini-image.ts +128 -147
  21. package/src/core/tools/grep.ts +397 -415
  22. package/src/core/tools/index.test.ts +5 -1
  23. package/src/core/tools/index.ts +8 -4
  24. package/src/core/tools/ls.ts +12 -10
  25. package/src/core/tools/lsp/client.ts +84 -19
  26. package/src/core/tools/lsp/config.ts +205 -656
  27. package/src/core/tools/lsp/defaults.json +465 -0
  28. package/src/core/tools/lsp/index.ts +72 -35
  29. package/src/core/tools/lsp/rust-analyzer.ts +49 -10
  30. package/src/core/tools/lsp/types.ts +1 -0
  31. package/src/core/tools/lsp/utils.ts +1 -1
  32. package/src/core/tools/read.ts +150 -74
  33. package/src/core/tools/render-utils.ts +70 -10
  34. package/src/core/tools/review.ts +38 -126
  35. package/src/core/tools/task/artifacts.ts +5 -4
  36. package/src/core/tools/task/commands.ts +4 -0
  37. package/src/core/tools/task/executor.ts +94 -83
  38. package/src/core/tools/task/index.ts +130 -92
  39. package/src/core/tools/task/parallel.ts +30 -3
  40. package/src/core/tools/task/render.ts +85 -39
  41. package/src/core/tools/task/types.ts +15 -6
  42. package/src/core/tools/task/worker.ts +124 -89
  43. package/src/core/tools/web-fetch.ts +112 -377
  44. package/src/core/tools/{web-fetch-handlers → web-scrapers}/artifacthub.ts +6 -1
  45. package/src/core/tools/{web-fetch-handlers → web-scrapers}/arxiv.ts +8 -4
  46. package/src/core/tools/{web-fetch-handlers → web-scrapers}/aur.ts +6 -2
  47. package/src/core/tools/{web-fetch-handlers → web-scrapers}/biorxiv.ts +6 -1
  48. package/src/core/tools/{web-fetch-handlers → web-scrapers}/bluesky.ts +10 -3
  49. package/src/core/tools/{web-fetch-handlers → web-scrapers}/brew.ts +6 -2
  50. package/src/core/tools/{web-fetch-handlers → web-scrapers}/cheatsh.ts +6 -1
  51. package/src/core/tools/{web-fetch-handlers → web-scrapers}/chocolatey.ts +6 -1
  52. package/src/core/tools/web-scrapers/choosealicense.ts +110 -0
  53. package/src/core/tools/web-scrapers/cisa-kev.ts +100 -0
  54. package/src/core/tools/web-scrapers/clojars.ts +180 -0
  55. package/src/core/tools/{web-fetch-handlers → web-scrapers}/coingecko.ts +6 -1
  56. package/src/core/tools/{web-fetch-handlers → web-scrapers}/crates-io.ts +7 -2
  57. package/src/core/tools/web-scrapers/crossref.ts +149 -0
  58. package/src/core/tools/{web-fetch-handlers → web-scrapers}/devto.ts +8 -4
  59. package/src/core/tools/{web-fetch-handlers → web-scrapers}/discogs.ts +6 -1
  60. package/src/core/tools/web-scrapers/discourse.ts +221 -0
  61. package/src/core/tools/{web-fetch-handlers → web-scrapers}/dockerhub.ts +7 -3
  62. package/src/core/tools/web-scrapers/fdroid.ts +158 -0
  63. package/src/core/tools/web-scrapers/firefox-addons.ts +214 -0
  64. package/src/core/tools/web-scrapers/flathub.ts +239 -0
  65. package/src/core/tools/{web-fetch-handlers → web-scrapers}/github-gist.ts +6 -2
  66. package/src/core/tools/{web-fetch-handlers → web-scrapers}/github.ts +63 -32
  67. package/src/core/tools/{web-fetch-handlers → web-scrapers}/gitlab.ts +31 -19
  68. package/src/core/tools/{web-fetch-handlers → web-scrapers}/go-pkg.ts +8 -4
  69. package/src/core/tools/{web-fetch-handlers → web-scrapers}/hackage.ts +6 -1
  70. package/src/core/tools/{web-fetch-handlers → web-scrapers}/hackernews.ts +18 -18
  71. package/src/core/tools/{web-fetch-handlers → web-scrapers}/hex.ts +3 -3
  72. package/src/core/tools/{web-fetch-handlers → web-scrapers}/huggingface.ts +10 -10
  73. package/src/core/tools/{web-fetch-handlers → web-scrapers}/iacr.ts +8 -4
  74. package/src/core/tools/web-scrapers/index.ts +250 -0
  75. package/src/core/tools/web-scrapers/jetbrains-marketplace.ts +169 -0
  76. package/src/core/tools/web-scrapers/lemmy.ts +220 -0
  77. package/src/core/tools/{web-fetch-handlers → web-scrapers}/lobsters.ts +3 -3
  78. package/src/core/tools/{web-fetch-handlers → web-scrapers}/mastodon.ts +11 -3
  79. package/src/core/tools/{web-fetch-handlers → web-scrapers}/maven.ts +6 -1
  80. package/src/core/tools/{web-fetch-handlers → web-scrapers}/mdn.ts +2 -2
  81. package/src/core/tools/{web-fetch-handlers → web-scrapers}/metacpan.ts +13 -7
  82. package/src/core/tools/web-scrapers/musicbrainz.ts +273 -0
  83. package/src/core/tools/{web-fetch-handlers → web-scrapers}/npm.ts +12 -5
  84. package/src/core/tools/{web-fetch-handlers → web-scrapers}/nuget.ts +9 -5
  85. package/src/core/tools/{web-fetch-handlers → web-scrapers}/nvd.ts +6 -1
  86. package/src/core/tools/web-scrapers/ollama.ts +267 -0
  87. package/src/core/tools/web-scrapers/open-vsx.ts +119 -0
  88. package/src/core/tools/{web-fetch-handlers → web-scrapers}/opencorporates.ts +2 -0
  89. package/src/core/tools/{web-fetch-handlers → web-scrapers}/openlibrary.ts +18 -12
  90. package/src/core/tools/web-scrapers/orcid.ts +299 -0
  91. package/src/core/tools/{web-fetch-handlers → web-scrapers}/osv.ts +6 -1
  92. package/src/core/tools/{web-fetch-handlers → web-scrapers}/packagist.ts +6 -2
  93. package/src/core/tools/{web-fetch-handlers → web-scrapers}/pub-dev.ts +3 -3
  94. package/src/core/tools/{web-fetch-handlers → web-scrapers}/pubmed.ts +8 -4
  95. package/src/core/tools/{web-fetch-handlers → web-scrapers}/pypi.ts +7 -3
  96. package/src/core/tools/web-scrapers/rawg.ts +124 -0
  97. package/src/core/tools/{web-fetch-handlers → web-scrapers}/readthedocs.ts +7 -3
  98. package/src/core/tools/{web-fetch-handlers → web-scrapers}/reddit.ts +6 -2
  99. package/src/core/tools/{web-fetch-handlers → web-scrapers}/repology.ts +6 -1
  100. package/src/core/tools/{web-fetch-handlers → web-scrapers}/rfc.ts +7 -3
  101. package/src/core/tools/{web-fetch-handlers → web-scrapers}/rubygems.ts +6 -1
  102. package/src/core/tools/web-scrapers/searchcode.ts +217 -0
  103. package/src/core/tools/{web-fetch-handlers → web-scrapers}/sec-edgar.ts +6 -1
  104. package/src/core/tools/{web-fetch-handlers → web-scrapers}/semantic-scholar.ts +2 -2
  105. package/src/core/tools/web-scrapers/snapcraft.ts +200 -0
  106. package/src/core/tools/web-scrapers/sourcegraph.ts +373 -0
  107. package/src/core/tools/web-scrapers/spdx.ts +121 -0
  108. package/src/core/tools/{web-fetch-handlers → web-scrapers}/spotify.ts +3 -3
  109. package/src/core/tools/{web-fetch-handlers → web-scrapers}/stackoverflow.ts +3 -2
  110. package/src/core/tools/{web-fetch-handlers → web-scrapers}/terraform.ts +11 -3
  111. package/src/core/tools/{web-fetch-handlers → web-scrapers}/tldr.ts +6 -2
  112. package/src/core/tools/{web-fetch-handlers → web-scrapers}/twitter.ts +15 -3
  113. package/src/core/tools/{web-fetch-handlers → web-scrapers}/types.ts +98 -27
  114. package/src/core/tools/web-scrapers/utils.ts +162 -0
  115. package/src/core/tools/{web-fetch-handlers → web-scrapers}/vimeo.ts +3 -3
  116. package/src/core/tools/web-scrapers/vscode-marketplace.ts +195 -0
  117. package/src/core/tools/web-scrapers/w3c.ts +163 -0
  118. package/src/core/tools/{web-fetch-handlers → web-scrapers}/wikidata.ts +13 -5
  119. package/src/core/tools/{web-fetch-handlers → web-scrapers}/wikipedia.ts +7 -3
  120. package/src/core/tools/{web-fetch-handlers → web-scrapers}/youtube.ts +72 -20
  121. package/src/core/tools/write.ts +21 -18
  122. package/src/core/voice.ts +3 -2
  123. package/src/lib/worktree/collapse.ts +2 -1
  124. package/src/lib/worktree/git.ts +2 -18
  125. package/src/main.ts +59 -3
  126. package/src/modes/interactive/components/extensions/extension-dashboard.ts +33 -19
  127. package/src/modes/interactive/components/extensions/extension-list.ts +15 -8
  128. package/src/modes/interactive/components/hook-editor.ts +2 -1
  129. package/src/modes/interactive/components/model-selector.ts +19 -4
  130. package/src/modes/interactive/interactive-mode.ts +41 -63
  131. package/src/modes/interactive/theme/theme.ts +58 -58
  132. package/src/modes/rpc/rpc-mode.ts +10 -9
  133. package/src/prompts/review-request.md +27 -0
  134. package/src/prompts/reviewer.md +64 -68
  135. package/src/prompts/tools/output.md +22 -3
  136. package/src/prompts/tools/task.md +32 -33
  137. package/src/utils/clipboard.ts +2 -1
  138. package/examples/extensions/subagent/agents/reviewer.md +0 -35
  139. package/src/core/tools/web-fetch-handlers/index.ts +0 -69
  140. package/src/core/tools/web-fetch-handlers/utils.ts +0 -91
  141. /package/src/core/tools/{web-fetch-handlers → web-scrapers}/academic.test.ts +0 -0
  142. /package/src/core/tools/{web-fetch-handlers → web-scrapers}/business.test.ts +0 -0
  143. /package/src/core/tools/{web-fetch-handlers → web-scrapers}/dev-platforms.test.ts +0 -0
  144. /package/src/core/tools/{web-fetch-handlers → web-scrapers}/documentation.test.ts +0 -0
  145. /package/src/core/tools/{web-fetch-handlers → web-scrapers}/finance-media.test.ts +0 -0
  146. /package/src/core/tools/{web-fetch-handlers → web-scrapers}/git-hosting.test.ts +0 -0
  147. /package/src/core/tools/{web-fetch-handlers → web-scrapers}/media.test.ts +0 -0
  148. /package/src/core/tools/{web-fetch-handlers → web-scrapers}/package-managers-2.test.ts +0 -0
  149. /package/src/core/tools/{web-fetch-handlers → web-scrapers}/package-managers.test.ts +0 -0
  150. /package/src/core/tools/{web-fetch-handlers → web-scrapers}/package-registries.test.ts +0 -0
  151. /package/src/core/tools/{web-fetch-handlers → web-scrapers}/research.test.ts +0 -0
  152. /package/src/core/tools/{web-fetch-handlers → web-scrapers}/security.test.ts +0 -0
  153. /package/src/core/tools/{web-fetch-handlers → web-scrapers}/social-extended.test.ts +0 -0
  154. /package/src/core/tools/{web-fetch-handlers → web-scrapers}/social.test.ts +0 -0
  155. /package/src/core/tools/{web-fetch-handlers → web-scrapers}/stackexchange.test.ts +0 -0
  156. /package/src/core/tools/{web-fetch-handlers → web-scrapers}/standards.test.ts +0 -0
  157. /package/src/core/tools/{web-fetch-handlers → web-scrapers}/wikipedia.test.ts +0 -0
  158. /package/src/core/tools/{web-fetch-handlers → web-scrapers}/youtube.test.ts +0 -0
@@ -1,23 +1,10 @@
1
- import {
2
- closeSync,
3
- createWriteStream,
4
- existsSync,
5
- fsyncSync,
6
- mkdirSync,
7
- openSync,
8
- readFileSync,
9
- readSync,
10
- renameSync,
11
- statSync,
12
- unlinkSync,
13
- type WriteStream,
14
- } from "node:fs";
15
1
  import { basename, join, resolve } from "node:path";
16
2
  import type { ImageContent, Message, TextContent, Usage } from "@mariozechner/pi-ai";
17
3
  import type { AgentMessage } from "@oh-my-pi/pi-agent-core";
18
4
  import { nanoid } from "nanoid";
19
5
  import { getAgentDir as getDefaultAgentDir } from "../config";
20
6
  import { resizeImage } from "../utils/image-resize";
7
+ import { logger } from "./logger";
21
8
  import {
22
9
  type BashExecutionMessage,
23
10
  type CustomMessage,
@@ -26,6 +13,8 @@ import {
26
13
  createCustomMessage,
27
14
  type HookMessage,
28
15
  } from "./messages";
16
+ import type { SessionStorage, SessionStorageWriter } from "./session-storage";
17
+ import { FileSessionStorage, MemorySessionStorage } from "./session-storage";
29
18
 
30
19
  export const CURRENT_SESSION_VERSION = 3;
31
20
 
@@ -437,20 +426,18 @@ export function buildSessionContext(
437
426
  * Compute the default session directory for a cwd.
438
427
  * Encodes cwd into a safe directory name under ~/.omp/agent/sessions/.
439
428
  */
440
- function getDefaultSessionDir(cwd: string): string {
429
+ function getDefaultSessionDir(cwd: string, storage: SessionStorage): string {
441
430
  const safePath = `--${cwd.replace(/^[/\\]/, "").replace(/[/\\:]/g, "-")}--`;
442
431
  const sessionDir = join(getDefaultAgentDir(), "sessions", safePath);
443
- if (!existsSync(sessionDir)) {
444
- mkdirSync(sessionDir, { recursive: true });
445
- }
432
+ storage.ensureDirSync(sessionDir);
446
433
  return sessionDir;
447
434
  }
448
435
 
449
436
  /** Exported for testing */
450
- export function loadEntriesFromFile(filePath: string): FileEntry[] {
451
- if (!existsSync(filePath)) return [];
437
+ export function loadEntriesFromFile(filePath: string, storage: SessionStorage = new FileSessionStorage()): FileEntry[] {
438
+ if (!storage.existsSync(filePath)) return [];
452
439
 
453
- const content = readFileSync(filePath, "utf-8");
440
+ const content = storage.readTextSync(filePath);
454
441
  const entries: FileEntry[] = [];
455
442
  const lines = content.trim().split("\n");
456
443
 
@@ -523,57 +510,38 @@ class RecentSessionInfo {
523
510
  * Uses low-level file I/O to efficiently read only the first 512 bytes of each file
524
511
  * to extract the JSON header without loading entire session logs into memory.
525
512
  */
526
- function getSortedSessions(sessionDir: string): RecentSessionInfo[] {
513
+ function getSortedSessions(sessionDir: string, storage: SessionStorage): RecentSessionInfo[] {
527
514
  try {
528
- // Reusable buffer for reading file headers
529
- const buf = Buffer.allocUnsafe(512);
530
-
531
- /**
532
- * Reads the first line (JSON header) from an open file descriptor.
533
- * Returns null if the file is empty or doesn't start with valid JSON.
534
- */
535
- const readHeader = (fd: number) => {
536
- const bytesRead = readSync(fd, buf, 0, 512, 0);
537
- if (bytesRead === 0) return null;
538
- const sub = buf.subarray(0, bytesRead);
539
- // Quick check: first char must be '{' for valid JSON object
540
- if (sub.at(0) !== "{".charCodeAt(0)) return null;
541
- // Find end of first JSON line
542
- const eol = sub.indexOf("}\n");
543
- if (eol <= 0) return null;
544
- const header = JSON.parse(sub.toString("utf8", 0, eol + 1));
545
- // Validate session header
546
- if (header.type !== "session" || typeof header.id !== "string") return null;
547
- return header;
548
- };
549
-
550
- return Array.from(new Bun.Glob("*.jsonl").scanSync(sessionDir))
551
- .map((f) => {
515
+ const buf = Buffer.alloc(512);
516
+ const files: string[] = storage.listFilesSync(sessionDir, "*.jsonl");
517
+ return files
518
+ .map((path: string) => {
552
519
  try {
553
- const path = join(sessionDir, f);
554
- const fd = openSync(path, "r");
555
- try {
556
- const header = readHeader(fd);
557
- if (!header) return null;
558
- const mtime = statSync(path).mtimeMs;
559
- return new RecentSessionInfo(path, mtime, header);
560
- } finally {
561
- closeSync(fd);
562
- }
520
+ const length = storage.readTextPrefixSync(path, buf);
521
+ const content = buf.toString("utf-8", 0, length);
522
+ const firstLine = content.split("\n")[0];
523
+ if (!firstLine || !firstLine.trim()) return null;
524
+ const header = JSON.parse(firstLine) as Record<string, unknown>;
525
+ if (header.type !== "session" || typeof header.id !== "string") return null;
526
+ const mtime = storage.statSync(path).mtimeMs;
527
+ return new RecentSessionInfo(path, mtime, header);
563
528
  } catch {
564
529
  return null;
565
530
  }
566
531
  })
567
- .filter((x) => x !== null)
568
- .sort((a, b) => b.mtime - a.mtime); // Sort newest first
532
+ .filter((item): item is RecentSessionInfo => item !== null)
533
+ .sort((a, b) => b.mtime - a.mtime);
569
534
  } catch {
570
535
  return [];
571
536
  }
572
537
  }
573
538
 
574
539
  /** Exported for testing */
575
- export function findMostRecentSession(sessionDir: string): string | null {
576
- const sessions = getSortedSessions(sessionDir);
540
+ export function findMostRecentSession(
541
+ sessionDir: string,
542
+ storage: SessionStorage = new FileSessionStorage(),
543
+ ): string | null {
544
+ const sessions = getSortedSessions(sessionDir, storage);
577
545
  return sessions[0]?.path || null;
578
546
  }
579
547
 
@@ -599,19 +567,6 @@ const PLACEHOLDER_IMAGE_DATA =
599
567
 
600
568
  const TEXT_CONTENT_KEY = "content";
601
569
 
602
- function fsyncDirSync(dir: string): void {
603
- try {
604
- const fd = openSync(dir, "r");
605
- try {
606
- fsyncSync(fd);
607
- } finally {
608
- closeSync(fd);
609
- }
610
- } catch {
611
- // Best-effort: some platforms/filesystems don't support fsync on directories.
612
- }
613
- }
614
-
615
570
  /**
616
571
  * Recursively truncate large strings in an object for session persistence.
617
572
  * - Truncates any oversized string fields (key-agnostic)
@@ -724,81 +679,48 @@ async function prepareEntryForPersistence(entry: FileEntry): Promise<FileEntry>
724
679
  }
725
680
 
726
681
  class NdjsonFileWriter {
727
- private writeStream: WriteStream;
682
+ private writer: SessionStorageWriter;
728
683
  private closed = false;
729
684
  private closing = false;
730
685
  private error: Error | undefined;
731
686
  private pendingWrites: Promise<void> = Promise.resolve();
732
- private ready: Promise<void>;
733
- private fd: number | null = null;
734
687
  private onError: ((err: Error) => void) | undefined;
735
688
 
736
- constructor(path: string, options?: { flags?: string; onError?: (err: Error) => void }) {
689
+ constructor(storage: SessionStorage, path: string, options?: { flags?: "a" | "w"; onError?: (err: Error) => void }) {
737
690
  this.onError = options?.onError;
738
- this.writeStream = createWriteStream(path, { flags: options?.flags ?? "a" });
739
- this.ready = new Promise<void>((resolve, reject) => {
740
- const onOpen = (fd: number) => {
741
- this.fd = fd;
742
- this.writeStream.off("error", onError);
743
- resolve();
744
- };
745
- const onError = (err: Error) => {
746
- this.writeStream.off("open", onOpen);
747
- reject(err);
748
- };
749
- this.writeStream.once("open", onOpen);
750
- this.writeStream.once("error", onError);
751
- });
752
- this.writeStream.on("error", (err: Error) => {
753
- const writeErr = toError(err);
754
- if (!this.error) this.error = writeErr;
755
- this.onError?.(writeErr);
691
+ this.writer = storage.openWriter(path, {
692
+ flags: options?.flags ?? "a",
693
+ onError: (err: Error) => this.recordError(err),
756
694
  });
757
695
  }
758
696
 
697
+ private recordError(err: unknown): Error {
698
+ const writeErr = toError(err);
699
+ if (!this.error) this.error = writeErr;
700
+ this.onError?.(writeErr);
701
+ return writeErr;
702
+ }
703
+
759
704
  private enqueue(task: () => Promise<void>): Promise<void> {
760
705
  const run = async () => {
761
706
  if (this.error) throw this.error;
762
707
  await task();
763
708
  };
764
709
  const next = this.pendingWrites.then(run);
765
- this.pendingWrites = next.catch((err) => {
710
+ void next.catch((err: unknown) => {
766
711
  if (!this.error) this.error = toError(err);
767
712
  });
713
+ this.pendingWrites = next;
768
714
  return next;
769
715
  }
770
716
 
771
717
  private async writeLine(line: string): Promise<void> {
772
718
  if (this.error) throw this.error;
773
- await new Promise<void>((resolve, reject) => {
774
- let settled = false;
775
- const onError = (err: Error) => {
776
- if (settled) return;
777
- settled = true;
778
- const writeErr = toError(err);
779
- if (!this.error) this.error = writeErr;
780
- this.writeStream.off("error", onError);
781
- reject(writeErr);
782
- };
783
- this.writeStream.once("error", onError);
784
- this.writeStream.write(line, (err) => {
785
- if (settled) return;
786
- settled = true;
787
- this.writeStream.off("error", onError);
788
- if (err) {
789
- const writeErr = toError(err);
790
- if (!this.error) this.error = writeErr;
791
- reject(writeErr);
792
- } else {
793
- resolve();
794
- }
795
- });
796
- if (this.error && !settled) {
797
- settled = true;
798
- this.writeStream.off("error", onError);
799
- reject(this.error);
800
- }
801
- });
719
+ try {
720
+ await this.writer.writeLine(line);
721
+ } catch (err) {
722
+ throw this.recordError(err);
723
+ }
802
724
  }
803
725
 
804
726
  /** Queue a write. Returns a promise so callers can await if needed. */
@@ -809,7 +731,7 @@ class NdjsonFileWriter {
809
731
  return this.enqueue(() => this.writeLine(line));
810
732
  }
811
733
 
812
- /** Flush all buffered data to disk. Waits for all queued writes and fsync. */
734
+ /** Flush all buffered data to disk. Waits for all queued writes. */
813
735
  async flush(): Promise<void> {
814
736
  if (this.closed) return;
815
737
  if (this.error) throw this.error;
@@ -818,19 +740,22 @@ class NdjsonFileWriter {
818
740
 
819
741
  if (this.error) throw this.error;
820
742
 
821
- await this.ready;
822
- const fd = this.fd;
823
- if (typeof fd === "number") {
824
- try {
825
- fsyncSync(fd);
826
- } catch (err) {
827
- const fsyncErr = toError(err);
828
- if (!this.error) this.error = fsyncErr;
829
- throw fsyncErr;
830
- }
743
+ try {
744
+ await this.writer.flush();
745
+ } catch (err) {
746
+ throw this.recordError(err);
831
747
  }
748
+ }
832
749
 
750
+ /** Sync data to persistent storage. */
751
+ async fsync(): Promise<void> {
752
+ if (this.closed) return;
833
753
  if (this.error) throw this.error;
754
+ try {
755
+ await this.writer.fsync();
756
+ } catch (err) {
757
+ throw this.recordError(err);
758
+ }
834
759
  }
835
760
 
836
761
  /** Close the writer, flushing all data. */
@@ -845,25 +770,23 @@ class NdjsonFileWriter {
845
770
  closeError = toError(err);
846
771
  }
847
772
 
848
- await this.pendingWrites;
773
+ try {
774
+ await this.pendingWrites;
775
+ } catch (err) {
776
+ if (!closeError) closeError = toError(err);
777
+ }
849
778
 
850
- await new Promise<void>((resolve, reject) => {
851
- this.writeStream.end((err?: Error | null) => {
852
- if (err) {
853
- const endErr = toError(err);
854
- if (!this.error) this.error = endErr;
855
- reject(endErr);
856
- } else {
857
- resolve();
858
- }
859
- });
860
- });
779
+ try {
780
+ await this.writer.close();
781
+ } catch (err) {
782
+ const endErr = this.recordError(err);
783
+ if (!closeError) closeError = endErr;
784
+ }
861
785
 
862
786
  this.closed = true;
863
- this.writeStream.removeAllListeners();
864
787
 
788
+ if (!closeError && this.error) closeError = this.error;
865
789
  if (closeError) throw closeError;
866
- if (this.error) throw this.error;
867
790
  }
868
791
 
869
792
  /** Check if there's a stored error. */
@@ -873,8 +796,12 @@ class NdjsonFileWriter {
873
796
  }
874
797
 
875
798
  /** Get recent sessions for display in welcome screen */
876
- export function getRecentSessions(sessionDir: string, limit = 3): RecentSessionInfo[] {
877
- return getSortedSessions(sessionDir).slice(0, limit);
799
+ export function getRecentSessions(
800
+ sessionDir: string,
801
+ limit = 3,
802
+ storage: SessionStorage = new FileSessionStorage(),
803
+ ): RecentSessionInfo[] {
804
+ return getSortedSessions(sessionDir, storage).slice(0, limit);
878
805
  }
879
806
 
880
807
  /**
@@ -922,20 +849,22 @@ export class SessionManager {
922
849
  private persistChain: Promise<void> = Promise.resolve();
923
850
  private persistError: Error | undefined;
924
851
  private persistErrorReported = false;
852
+ private storage: SessionStorage;
925
853
 
926
- private constructor(cwd: string, sessionDir: string, persist: boolean) {
854
+ private constructor(cwd: string, sessionDir: string, persist: boolean, storage: SessionStorage) {
927
855
  this.cwd = cwd;
928
856
  this.sessionDir = sessionDir;
929
857
  this.persist = persist;
930
- if (persist && sessionDir && !existsSync(sessionDir)) {
931
- mkdirSync(sessionDir, { recursive: true });
858
+ this.storage = storage;
859
+ if (persist && sessionDir) {
860
+ this.storage.ensureDirSync(sessionDir);
932
861
  }
933
862
  // Note: call _initSession() or _initSessionFile() after construction
934
863
  }
935
864
 
936
865
  /** Initialize with a specific session file (used by factory methods) */
937
- private _initSessionFile(sessionFile: string): void {
938
- this.setSessionFile(sessionFile);
866
+ private async _initSessionFile(sessionFile: string): Promise<void> {
867
+ await this.setSessionFile(sessionFile);
939
868
  }
940
869
 
941
870
  /** Initialize with a new session (used by factory methods) */
@@ -944,25 +873,23 @@ export class SessionManager {
944
873
  }
945
874
 
946
875
  /** Switch to a different session file (used for resume and branching) */
947
- setSessionFile(sessionFile: string): void {
948
- void this._closePersistWriter();
876
+ async setSessionFile(sessionFile: string): Promise<void> {
877
+ await this._closePersistWriter();
949
878
  this.persistError = undefined;
950
879
  this.persistErrorReported = false;
951
880
  this.sessionFile = resolve(sessionFile);
952
- if (existsSync(this.sessionFile)) {
953
- void (async () => {
954
- this.fileEntries = await loadEntriesFromFile(this.sessionFile!);
955
- const header = this.fileEntries.find((e) => e.type === "session") as SessionHeader | undefined;
956
- this.sessionId = header?.id ?? nanoid();
957
- this.sessionTitle = header?.title;
958
-
959
- if (migrateToCurrentVersion(this.fileEntries)) {
960
- await this._rewriteFile();
961
- }
881
+ if (this.storage.existsSync(this.sessionFile)) {
882
+ this.fileEntries = loadEntriesFromFile(this.sessionFile!, this.storage);
883
+ const header = this.fileEntries.find((e) => e.type === "session") as SessionHeader | undefined;
884
+ this.sessionId = header?.id ?? nanoid();
885
+ this.sessionTitle = header?.title;
886
+
887
+ if (migrateToCurrentVersion(this.fileEntries)) {
888
+ await this._rewriteFile();
889
+ }
962
890
 
963
- this._buildIndex();
964
- this.flushed = true;
965
- })();
891
+ this._buildIndex();
892
+ this.flushed = true;
966
893
  } else {
967
894
  this._newSessionSync();
968
895
  }
@@ -1046,7 +973,11 @@ export class SessionManager {
1046
973
  if (!this.persistError) this.persistError = normalized;
1047
974
  if (!this.persistErrorReported) {
1048
975
  this.persistErrorReported = true;
1049
- console.error("Session persistence error:", normalized);
976
+ logger.error("Session persistence error.", {
977
+ sessionFile: this.sessionFile,
978
+ error: normalized.message,
979
+ stack: normalized.stack,
980
+ });
1050
981
  }
1051
982
  return normalized;
1052
983
  }
@@ -1067,7 +998,7 @@ export class SessionManager {
1067
998
  if (this.persistError) throw this.persistError;
1068
999
  if (this.persistWriter && this.persistWriterPath === this.sessionFile) return this.persistWriter;
1069
1000
  // Note: caller must await _closePersistWriter() before calling this if switching files
1070
- this.persistWriter = new NdjsonFileWriter(this.sessionFile, {
1001
+ this.persistWriter = new NdjsonFileWriter(this.storage, this.sessionFile, {
1071
1002
  onError: (err) => {
1072
1003
  this._recordPersistError(err);
1073
1004
  },
@@ -1097,18 +1028,24 @@ export class SessionManager {
1097
1028
  if (!this.sessionFile) return;
1098
1029
  const dir = resolve(this.sessionFile, "..");
1099
1030
  const tempPath = join(dir, `.${basename(this.sessionFile)}.${nanoid(6)}.tmp`);
1100
- const writer = new NdjsonFileWriter(tempPath, { flags: "w" });
1101
- for (const entry of entries) {
1102
- await writer.write(entry);
1103
- }
1104
- await writer.flush();
1105
- await writer.close();
1031
+ const writer = new NdjsonFileWriter(this.storage, tempPath, { flags: "w" });
1106
1032
  try {
1107
- renameSync(tempPath, this.sessionFile);
1108
- fsyncDirSync(dir);
1033
+ for (const entry of entries) {
1034
+ await writer.write(entry);
1035
+ }
1036
+ await writer.flush();
1037
+ await writer.fsync();
1038
+ await writer.close();
1039
+ await this.storage.rename(tempPath, this.sessionFile);
1040
+ this.storage.fsyncDirSync(dir);
1109
1041
  } catch (err) {
1110
1042
  try {
1111
- unlinkSync(tempPath);
1043
+ await writer.close();
1044
+ } catch {
1045
+ // Ignore cleanup errors
1046
+ }
1047
+ try {
1048
+ await this.storage.unlink(tempPath);
1112
1049
  } catch {
1113
1050
  // Ignore cleanup errors
1114
1051
  }
@@ -1134,7 +1071,10 @@ export class SessionManager {
1134
1071
  async flush(): Promise<void> {
1135
1072
  if (!this.persistWriter) return;
1136
1073
  await this._queuePersistTask(async () => {
1137
- if (this.persistWriter) await this.persistWriter.flush();
1074
+ if (this.persistWriter) {
1075
+ await this.persistWriter.flush();
1076
+ await this.persistWriter.fsync();
1077
+ }
1138
1078
  });
1139
1079
  if (this.persistError) throw this.persistError;
1140
1080
  }
@@ -1175,43 +1115,8 @@ export class SessionManager {
1175
1115
 
1176
1116
  // Update the session file header with the title (if already flushed)
1177
1117
  const sessionFile = this.sessionFile;
1178
- if (this.persist && sessionFile && existsSync(sessionFile)) {
1179
- await this._queuePersistTask(async () => {
1180
- await this._closePersistWriterInternal();
1181
- try {
1182
- const content = readFileSync(sessionFile, "utf-8");
1183
- const lines = content.split("\n");
1184
- if (lines.length > 0) {
1185
- const fileHeader = JSON.parse(lines[0]) as SessionHeader;
1186
- if (fileHeader.type === "session") {
1187
- fileHeader.title = title;
1188
- lines[0] = JSON.stringify(fileHeader);
1189
- const tempPath = join(resolve(sessionFile, ".."), `.${basename(sessionFile)}.${nanoid(6)}.tmp`);
1190
- await Bun.write(tempPath, lines.join("\n"));
1191
- const fd = openSync(tempPath, "r");
1192
- try {
1193
- fsyncSync(fd);
1194
- } finally {
1195
- closeSync(fd);
1196
- }
1197
- try {
1198
- renameSync(tempPath, sessionFile);
1199
- fsyncDirSync(resolve(sessionFile, ".."));
1200
- } catch (err) {
1201
- try {
1202
- unlinkSync(tempPath);
1203
- } catch {
1204
- // Ignore cleanup errors
1205
- }
1206
- throw err;
1207
- }
1208
- }
1209
- }
1210
- } catch (err) {
1211
- this._recordPersistError(err);
1212
- throw err;
1213
- }
1214
- });
1118
+ if (this.persist && sessionFile && this.storage.existsSync(sessionFile)) {
1119
+ await this._rewriteFile();
1215
1120
  }
1216
1121
  }
1217
1122
 
@@ -1655,11 +1560,10 @@ export class SessionManager {
1655
1560
  }
1656
1561
 
1657
1562
  if (this.persist) {
1658
- const file = Bun.file(newSessionFile);
1659
- const writer = file.writer();
1660
- writer.write(`${JSON.stringify(header)}\n`);
1563
+ const lines: string[] = [];
1564
+ lines.push(JSON.stringify(header));
1661
1565
  for (const entry of pathWithoutLabels) {
1662
- writer.write(`${JSON.stringify(entry)}\n`);
1566
+ lines.push(JSON.stringify(entry));
1663
1567
  }
1664
1568
  // Write fresh label entries at the end
1665
1569
  const lastEntryId = pathWithoutLabels[pathWithoutLabels.length - 1]?.id || null;
@@ -1674,12 +1578,12 @@ export class SessionManager {
1674
1578
  targetId,
1675
1579
  label,
1676
1580
  };
1677
- writer.write(`${JSON.stringify(labelEntry)}\n`);
1581
+ lines.push(JSON.stringify(labelEntry));
1678
1582
  pathEntryIds.add(labelEntry.id);
1679
1583
  labelEntries.push(labelEntry);
1680
1584
  parentId = labelEntry.id;
1681
1585
  }
1682
- writer.end();
1586
+ this.storage.writeTextSync(newSessionFile, `${lines.join("\n")}\n`);
1683
1587
  this.fileEntries = [header, ...pathWithoutLabels, ...labelEntries];
1684
1588
  this.sessionId = newSessionId;
1685
1589
  this._buildIndex();
@@ -1712,9 +1616,9 @@ export class SessionManager {
1712
1616
  * @param cwd Working directory (stored in session header)
1713
1617
  * @param sessionDir Optional session directory. If omitted, uses default (~/.omp/agent/sessions/<encoded-cwd>/).
1714
1618
  */
1715
- static create(cwd: string, sessionDir?: string): SessionManager {
1716
- const dir = sessionDir ?? getDefaultSessionDir(cwd);
1717
- const manager = new SessionManager(cwd, dir, true);
1619
+ static create(cwd: string, sessionDir?: string, storage: SessionStorage = new FileSessionStorage()): SessionManager {
1620
+ const dir = sessionDir ?? getDefaultSessionDir(cwd, storage);
1621
+ const manager = new SessionManager(cwd, dir, true, storage);
1718
1622
  manager._initNewSession();
1719
1623
  return manager;
1720
1624
  }
@@ -1724,15 +1628,19 @@ export class SessionManager {
1724
1628
  * @param path Path to session file
1725
1629
  * @param sessionDir Optional session directory for /new or /branch. If omitted, derives from file's parent.
1726
1630
  */
1727
- static async open(path: string, sessionDir?: string): Promise<SessionManager> {
1631
+ static async open(
1632
+ path: string,
1633
+ sessionDir?: string,
1634
+ storage: SessionStorage = new FileSessionStorage(),
1635
+ ): Promise<SessionManager> {
1728
1636
  // Extract cwd from session header if possible, otherwise use process.cwd()
1729
- const entries = await loadEntriesFromFile(path);
1637
+ const entries = loadEntriesFromFile(path, storage);
1730
1638
  const header = entries.find((e) => e.type === "session") as SessionHeader | undefined;
1731
1639
  const cwd = header?.cwd ?? process.cwd();
1732
1640
  // If no sessionDir provided, derive from file's parent directory
1733
1641
  const dir = sessionDir ?? resolve(path, "..");
1734
- const manager = new SessionManager(cwd, dir, true);
1735
- manager._initSessionFile(path);
1642
+ const manager = new SessionManager(cwd, dir, true, storage);
1643
+ await manager._initSessionFile(path);
1736
1644
  return manager;
1737
1645
  }
1738
1646
 
@@ -1741,12 +1649,16 @@ export class SessionManager {
1741
1649
  * @param cwd Working directory
1742
1650
  * @param sessionDir Optional session directory. If omitted, uses default (~/.omp/agent/sessions/<encoded-cwd>/).
1743
1651
  */
1744
- static continueRecent(cwd: string, sessionDir?: string): SessionManager {
1745
- const dir = sessionDir ?? getDefaultSessionDir(cwd);
1746
- const mostRecent = findMostRecentSession(dir);
1747
- const manager = new SessionManager(cwd, dir, true);
1652
+ static async continueRecent(
1653
+ cwd: string,
1654
+ sessionDir?: string,
1655
+ storage: SessionStorage = new FileSessionStorage(),
1656
+ ): Promise<SessionManager> {
1657
+ const dir = sessionDir ?? getDefaultSessionDir(cwd, storage);
1658
+ const mostRecent = findMostRecentSession(dir, storage);
1659
+ const manager = new SessionManager(cwd, dir, true, storage);
1748
1660
  if (mostRecent) {
1749
- manager._initSessionFile(mostRecent);
1661
+ await manager._initSessionFile(mostRecent);
1750
1662
  } else {
1751
1663
  manager._initNewSession();
1752
1664
  }
@@ -1754,8 +1666,8 @@ export class SessionManager {
1754
1666
  }
1755
1667
 
1756
1668
  /** Create an in-memory session (no file persistence) */
1757
- static inMemory(cwd: string = process.cwd()): SessionManager {
1758
- const manager = new SessionManager(cwd, "", false);
1669
+ static inMemory(cwd: string = process.cwd(), storage: SessionStorage = new MemorySessionStorage()): SessionManager {
1670
+ const manager = new SessionManager(cwd, "", false, storage);
1759
1671
  manager._initNewSession();
1760
1672
  return manager;
1761
1673
  }
@@ -1765,16 +1677,16 @@ export class SessionManager {
1765
1677
  * @param cwd Working directory (used to compute default session directory)
1766
1678
  * @param sessionDir Optional session directory. If omitted, uses default (~/.omp/agent/sessions/<encoded-cwd>/).
1767
1679
  */
1768
- static list(cwd: string, sessionDir?: string): SessionInfo[] {
1769
- const dir = sessionDir ?? getDefaultSessionDir(cwd);
1680
+ static list(cwd: string, sessionDir?: string, storage: SessionStorage = new FileSessionStorage()): SessionInfo[] {
1681
+ const dir = sessionDir ?? getDefaultSessionDir(cwd, storage);
1770
1682
  const sessions: SessionInfo[] = [];
1771
1683
 
1772
1684
  try {
1773
- const files = Array.from(new Bun.Glob("*.jsonl").scanSync(dir)).map((f) => join(dir, f));
1685
+ const files = storage.listFilesSync(dir, "*.jsonl");
1774
1686
 
1775
1687
  for (const file of files) {
1776
1688
  try {
1777
- const content = readFileSync(file, "utf-8");
1689
+ const content = storage.readTextSync(file);
1778
1690
  const lines = content.trim().split("\n");
1779
1691
  if (lines.length === 0) continue;
1780
1692
 
@@ -1790,7 +1702,7 @@ export class SessionManager {
1790
1702
  }
1791
1703
  if (!header) continue;
1792
1704
 
1793
- const stats = statSync(file);
1705
+ const stats = storage.statSync(file);
1794
1706
  let messageCount = 0;
1795
1707
  let firstMessage = "";
1796
1708
  const allMessages: string[] = [];