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