@oh-my-pi/pi-coding-agent 3.30.0 → 3.31.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.
- package/CHANGELOG.md +71 -0
- package/package.json +5 -5
- package/src/cli/args.ts +4 -0
- package/src/core/agent-session.ts +29 -2
- package/src/core/bash-executor.ts +2 -1
- package/src/core/custom-commands/bundled/review/index.ts +369 -14
- package/src/core/custom-commands/bundled/wt/index.ts +1 -1
- package/src/core/session-manager.ts +158 -246
- package/src/core/session-storage.ts +379 -0
- package/src/core/settings-manager.ts +155 -4
- package/src/core/system-prompt.ts +62 -64
- package/src/core/tools/ask.ts +5 -4
- package/src/core/tools/bash-interceptor.ts +26 -61
- package/src/core/tools/bash.ts +13 -8
- package/src/core/tools/edit-diff.ts +11 -4
- package/src/core/tools/edit.ts +7 -13
- package/src/core/tools/find.ts +111 -50
- package/src/core/tools/gemini-image.ts +128 -147
- package/src/core/tools/grep.ts +397 -415
- package/src/core/tools/index.test.ts +5 -1
- package/src/core/tools/index.ts +6 -8
- package/src/core/tools/ls.ts +12 -10
- package/src/core/tools/lsp/client.ts +58 -9
- package/src/core/tools/lsp/config.ts +205 -656
- package/src/core/tools/lsp/defaults.json +465 -0
- package/src/core/tools/lsp/index.ts +55 -32
- package/src/core/tools/lsp/rust-analyzer.ts +49 -10
- package/src/core/tools/lsp/types.ts +1 -0
- package/src/core/tools/lsp/utils.ts +1 -1
- package/src/core/tools/read.ts +150 -74
- package/src/core/tools/render-utils.ts +70 -10
- package/src/core/tools/review.ts +38 -126
- package/src/core/tools/task/artifacts.ts +5 -4
- package/src/core/tools/task/executor.ts +94 -83
- package/src/core/tools/task/index.ts +129 -92
- package/src/core/tools/task/parallel.ts +30 -3
- package/src/core/tools/task/render.ts +85 -39
- package/src/core/tools/task/types.ts +15 -6
- package/src/core/tools/task/worker.ts +124 -89
- package/src/core/tools/web-fetch.ts +112 -377
- package/src/core/tools/{web-fetch-handlers → web-scrapers}/artifacthub.ts +6 -1
- package/src/core/tools/{web-fetch-handlers → web-scrapers}/arxiv.ts +8 -4
- package/src/core/tools/{web-fetch-handlers → web-scrapers}/aur.ts +6 -2
- package/src/core/tools/{web-fetch-handlers → web-scrapers}/biorxiv.ts +6 -1
- package/src/core/tools/{web-fetch-handlers → web-scrapers}/bluesky.ts +10 -3
- package/src/core/tools/{web-fetch-handlers → web-scrapers}/brew.ts +6 -2
- package/src/core/tools/{web-fetch-handlers → web-scrapers}/cheatsh.ts +6 -1
- package/src/core/tools/{web-fetch-handlers → web-scrapers}/chocolatey.ts +6 -1
- package/src/core/tools/web-scrapers/choosealicense.ts +110 -0
- package/src/core/tools/web-scrapers/cisa-kev.ts +100 -0
- package/src/core/tools/web-scrapers/clojars.ts +180 -0
- package/src/core/tools/{web-fetch-handlers → web-scrapers}/coingecko.ts +6 -1
- package/src/core/tools/{web-fetch-handlers → web-scrapers}/crates-io.ts +7 -2
- package/src/core/tools/web-scrapers/crossref.ts +149 -0
- package/src/core/tools/{web-fetch-handlers → web-scrapers}/devto.ts +8 -4
- package/src/core/tools/{web-fetch-handlers → web-scrapers}/discogs.ts +6 -1
- package/src/core/tools/web-scrapers/discourse.ts +221 -0
- package/src/core/tools/{web-fetch-handlers → web-scrapers}/dockerhub.ts +7 -3
- package/src/core/tools/web-scrapers/fdroid.ts +158 -0
- package/src/core/tools/web-scrapers/firefox-addons.ts +214 -0
- package/src/core/tools/web-scrapers/flathub.ts +239 -0
- package/src/core/tools/{web-fetch-handlers → web-scrapers}/github-gist.ts +6 -2
- package/src/core/tools/{web-fetch-handlers → web-scrapers}/github.ts +63 -32
- package/src/core/tools/{web-fetch-handlers → web-scrapers}/gitlab.ts +31 -19
- package/src/core/tools/{web-fetch-handlers → web-scrapers}/go-pkg.ts +8 -4
- package/src/core/tools/{web-fetch-handlers → web-scrapers}/hackage.ts +6 -1
- package/src/core/tools/{web-fetch-handlers → web-scrapers}/hackernews.ts +18 -18
- package/src/core/tools/{web-fetch-handlers → web-scrapers}/hex.ts +3 -3
- package/src/core/tools/{web-fetch-handlers → web-scrapers}/huggingface.ts +10 -10
- package/src/core/tools/{web-fetch-handlers → web-scrapers}/iacr.ts +8 -4
- package/src/core/tools/web-scrapers/index.ts +250 -0
- package/src/core/tools/web-scrapers/jetbrains-marketplace.ts +169 -0
- package/src/core/tools/web-scrapers/lemmy.ts +220 -0
- package/src/core/tools/{web-fetch-handlers → web-scrapers}/lobsters.ts +3 -3
- package/src/core/tools/{web-fetch-handlers → web-scrapers}/mastodon.ts +11 -3
- package/src/core/tools/{web-fetch-handlers → web-scrapers}/maven.ts +6 -1
- package/src/core/tools/{web-fetch-handlers → web-scrapers}/mdn.ts +2 -2
- package/src/core/tools/{web-fetch-handlers → web-scrapers}/metacpan.ts +13 -7
- package/src/core/tools/web-scrapers/musicbrainz.ts +273 -0
- package/src/core/tools/{web-fetch-handlers → web-scrapers}/npm.ts +12 -5
- package/src/core/tools/{web-fetch-handlers → web-scrapers}/nuget.ts +9 -5
- package/src/core/tools/{web-fetch-handlers → web-scrapers}/nvd.ts +6 -1
- package/src/core/tools/web-scrapers/ollama.ts +267 -0
- package/src/core/tools/web-scrapers/open-vsx.ts +119 -0
- package/src/core/tools/{web-fetch-handlers → web-scrapers}/opencorporates.ts +2 -0
- package/src/core/tools/{web-fetch-handlers → web-scrapers}/openlibrary.ts +18 -12
- package/src/core/tools/web-scrapers/orcid.ts +299 -0
- package/src/core/tools/{web-fetch-handlers → web-scrapers}/osv.ts +6 -1
- package/src/core/tools/{web-fetch-handlers → web-scrapers}/packagist.ts +6 -2
- package/src/core/tools/{web-fetch-handlers → web-scrapers}/pub-dev.ts +3 -3
- package/src/core/tools/{web-fetch-handlers → web-scrapers}/pubmed.ts +8 -4
- package/src/core/tools/{web-fetch-handlers → web-scrapers}/pypi.ts +7 -3
- package/src/core/tools/web-scrapers/rawg.ts +124 -0
- package/src/core/tools/{web-fetch-handlers → web-scrapers}/readthedocs.ts +7 -3
- package/src/core/tools/{web-fetch-handlers → web-scrapers}/reddit.ts +6 -2
- package/src/core/tools/{web-fetch-handlers → web-scrapers}/repology.ts +6 -1
- package/src/core/tools/{web-fetch-handlers → web-scrapers}/rfc.ts +7 -3
- package/src/core/tools/{web-fetch-handlers → web-scrapers}/rubygems.ts +6 -1
- package/src/core/tools/web-scrapers/searchcode.ts +217 -0
- package/src/core/tools/{web-fetch-handlers → web-scrapers}/sec-edgar.ts +6 -1
- package/src/core/tools/{web-fetch-handlers → web-scrapers}/semantic-scholar.ts +2 -2
- package/src/core/tools/web-scrapers/snapcraft.ts +200 -0
- package/src/core/tools/web-scrapers/sourcegraph.ts +373 -0
- package/src/core/tools/web-scrapers/spdx.ts +121 -0
- package/src/core/tools/{web-fetch-handlers → web-scrapers}/spotify.ts +3 -3
- package/src/core/tools/{web-fetch-handlers → web-scrapers}/stackoverflow.ts +3 -2
- package/src/core/tools/{web-fetch-handlers → web-scrapers}/terraform.ts +11 -3
- package/src/core/tools/{web-fetch-handlers → web-scrapers}/tldr.ts +6 -2
- package/src/core/tools/{web-fetch-handlers → web-scrapers}/twitter.ts +15 -3
- package/src/core/tools/{web-fetch-handlers → web-scrapers}/types.ts +98 -27
- package/src/core/tools/web-scrapers/utils.ts +162 -0
- package/src/core/tools/{web-fetch-handlers → web-scrapers}/vimeo.ts +3 -3
- package/src/core/tools/web-scrapers/vscode-marketplace.ts +195 -0
- package/src/core/tools/web-scrapers/w3c.ts +163 -0
- package/src/core/tools/{web-fetch-handlers → web-scrapers}/wikidata.ts +13 -5
- package/src/core/tools/{web-fetch-handlers → web-scrapers}/wikipedia.ts +7 -3
- package/src/core/tools/{web-fetch-handlers → web-scrapers}/youtube.ts +72 -20
- package/src/core/tools/write.ts +21 -18
- package/src/core/voice.ts +3 -2
- package/src/lib/worktree/collapse.ts +2 -1
- package/src/lib/worktree/git.ts +2 -18
- package/src/main.ts +59 -3
- package/src/modes/interactive/components/extensions/extension-dashboard.ts +33 -19
- package/src/modes/interactive/components/extensions/extension-list.ts +15 -8
- package/src/modes/interactive/components/hook-editor.ts +2 -1
- package/src/modes/interactive/components/model-selector.ts +19 -4
- package/src/modes/interactive/interactive-mode.ts +41 -38
- package/src/modes/interactive/theme/theme.ts +58 -58
- package/src/modes/rpc/rpc-mode.ts +10 -9
- package/src/prompts/review-request.md +27 -0
- package/src/prompts/reviewer.md +64 -68
- package/src/prompts/tools/output.md +22 -3
- package/src/prompts/tools/task.md +32 -33
- package/src/utils/clipboard.ts +2 -1
- package/examples/extensions/subagent/agents/reviewer.md +0 -35
- package/src/core/tools/web-fetch-handlers/index.ts +0 -69
- package/src/core/tools/web-fetch-handlers/utils.ts +0 -91
- /package/src/core/tools/{web-fetch-handlers → web-scrapers}/academic.test.ts +0 -0
- /package/src/core/tools/{web-fetch-handlers → web-scrapers}/business.test.ts +0 -0
- /package/src/core/tools/{web-fetch-handlers → web-scrapers}/dev-platforms.test.ts +0 -0
- /package/src/core/tools/{web-fetch-handlers → web-scrapers}/documentation.test.ts +0 -0
- /package/src/core/tools/{web-fetch-handlers → web-scrapers}/finance-media.test.ts +0 -0
- /package/src/core/tools/{web-fetch-handlers → web-scrapers}/git-hosting.test.ts +0 -0
- /package/src/core/tools/{web-fetch-handlers → web-scrapers}/media.test.ts +0 -0
- /package/src/core/tools/{web-fetch-handlers → web-scrapers}/package-managers-2.test.ts +0 -0
- /package/src/core/tools/{web-fetch-handlers → web-scrapers}/package-managers.test.ts +0 -0
- /package/src/core/tools/{web-fetch-handlers → web-scrapers}/package-registries.test.ts +0 -0
- /package/src/core/tools/{web-fetch-handlers → web-scrapers}/research.test.ts +0 -0
- /package/src/core/tools/{web-fetch-handlers → web-scrapers}/security.test.ts +0 -0
- /package/src/core/tools/{web-fetch-handlers → web-scrapers}/social-extended.test.ts +0 -0
- /package/src/core/tools/{web-fetch-handlers → web-scrapers}/social.test.ts +0 -0
- /package/src/core/tools/{web-fetch-handlers → web-scrapers}/stackexchange.test.ts +0 -0
- /package/src/core/tools/{web-fetch-handlers → web-scrapers}/standards.test.ts +0 -0
- /package/src/core/tools/{web-fetch-handlers → web-scrapers}/wikipedia.test.ts +0 -0
- /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
|
-
|
|
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 =
|
|
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
|
-
|
|
529
|
-
const
|
|
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
|
|
554
|
-
const
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
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((
|
|
568
|
-
.sort((a, b) => b.mtime - a.mtime);
|
|
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(
|
|
576
|
-
|
|
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
|
|
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?:
|
|
689
|
+
constructor(storage: SessionStorage, path: string, options?: { flags?: "a" | "w"; onError?: (err: Error) => void }) {
|
|
737
690
|
this.onError = options?.onError;
|
|
738
|
-
this.
|
|
739
|
-
|
|
740
|
-
|
|
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
|
-
|
|
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
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
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
|
|
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
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
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
|
-
|
|
773
|
+
try {
|
|
774
|
+
await this.pendingWrites;
|
|
775
|
+
} catch (err) {
|
|
776
|
+
if (!closeError) closeError = toError(err);
|
|
777
|
+
}
|
|
849
778
|
|
|
850
|
-
|
|
851
|
-
this.
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
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(
|
|
877
|
-
|
|
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
|
-
|
|
931
|
-
|
|
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
|
-
|
|
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
|
-
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
|
|
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
|
-
|
|
964
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1108
|
-
|
|
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
|
-
|
|
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)
|
|
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.
|
|
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
|
|
1659
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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 =
|
|
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(
|
|
1745
|
-
|
|
1746
|
-
|
|
1747
|
-
|
|
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 =
|
|
1685
|
+
const files = storage.listFilesSync(dir, "*.jsonl");
|
|
1774
1686
|
|
|
1775
1687
|
for (const file of files) {
|
|
1776
1688
|
try {
|
|
1777
|
-
const content =
|
|
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[] = [];
|