@oh-my-pi/pi-coding-agent 11.2.3 → 11.3.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 +100 -0
- package/examples/extensions/plan-mode.ts +1 -1
- package/examples/hooks/qna.ts +1 -1
- package/examples/hooks/status-line.ts +1 -1
- package/examples/sdk/11-sessions.ts +1 -1
- package/package.json +8 -8
- package/src/cli/args.ts +9 -6
- package/src/cli/update-cli.ts +2 -2
- package/src/commands/index/index.ts +2 -5
- package/src/commit/agentic/agent.ts +1 -1
- package/src/commit/changelog/index.ts +2 -2
- package/src/config/keybindings.ts +16 -1
- package/src/config/model-registry.ts +25 -20
- package/src/config/model-resolver.ts +8 -8
- package/src/config/resolve-config-value.ts +92 -0
- package/src/config/settings-schema.ts +9 -0
- package/src/config.ts +14 -1
- package/src/export/html/template.css +7 -0
- package/src/export/html/template.generated.ts +1 -1
- package/src/export/html/template.js +33 -16
- package/src/extensibility/custom-commands/bundled/review/index.ts +1 -1
- package/src/extensibility/extensions/index.ts +18 -0
- package/src/extensibility/extensions/loader.ts +15 -0
- package/src/extensibility/extensions/runner.ts +78 -1
- package/src/extensibility/extensions/types.ts +131 -5
- package/src/extensibility/extensions/wrapper.ts +1 -1
- package/src/extensibility/plugins/git-url.ts +270 -0
- package/src/extensibility/plugins/index.ts +2 -0
- package/src/extensibility/slash-commands.ts +45 -0
- package/src/index.ts +7 -0
- package/src/lsp/render.ts +50 -43
- package/src/lsp/utils.ts +2 -2
- package/src/main.ts +11 -10
- package/src/mcp/transports/stdio.ts +3 -5
- package/src/modes/components/custom-message.ts +0 -8
- package/src/modes/components/diff.ts +1 -7
- package/src/modes/components/footer.ts +4 -4
- package/src/modes/components/model-selector.ts +4 -0
- package/src/modes/components/todo-display.ts +13 -3
- package/src/modes/components/tool-execution.ts +30 -16
- package/src/modes/components/tree-selector.ts +50 -19
- package/src/modes/controllers/event-controller.ts +1 -0
- package/src/modes/controllers/extension-ui-controller.ts +34 -2
- package/src/modes/controllers/input-controller.ts +47 -33
- package/src/modes/controllers/selector-controller.ts +10 -15
- package/src/modes/interactive-mode.ts +50 -38
- package/src/modes/print-mode.ts +6 -0
- package/src/modes/rpc/rpc-client.ts +4 -4
- package/src/modes/rpc/rpc-mode.ts +17 -2
- package/src/modes/rpc/rpc-types.ts +2 -2
- package/src/modes/types.ts +1 -0
- package/src/modes/utils/ui-helpers.ts +3 -1
- package/src/patch/applicator.ts +2 -3
- package/src/patch/fuzzy.ts +1 -1
- package/src/patch/shared.ts +74 -61
- package/src/prompts/system/system-prompt.md +1 -0
- package/src/prompts/tools/task.md +6 -0
- package/src/sdk.ts +15 -11
- package/src/session/agent-session.ts +72 -23
- package/src/session/auth-storage.ts +2 -1
- package/src/session/blob-store.ts +105 -0
- package/src/session/session-manager.ts +107 -44
- package/src/task/executor.ts +19 -9
- package/src/task/render.ts +80 -58
- package/src/tools/ask.ts +28 -5
- package/src/tools/bash.ts +47 -39
- package/src/tools/browser.ts +248 -26
- package/src/tools/calculator.ts +42 -23
- package/src/tools/fetch.ts +33 -16
- package/src/tools/find.ts +57 -22
- package/src/tools/grep.ts +54 -25
- package/src/tools/index.ts +5 -5
- package/src/tools/notebook.ts +19 -6
- package/src/tools/path-utils.ts +26 -1
- package/src/tools/python.ts +20 -14
- package/src/tools/read.ts +21 -8
- package/src/tools/render-utils.ts +5 -45
- package/src/tools/ssh.ts +59 -53
- package/src/tools/submit-result.ts +2 -2
- package/src/tools/todo-write.ts +32 -14
- package/src/tools/truncate.ts +1 -1
- package/src/tools/write.ts +39 -24
- package/src/tui/output-block.ts +61 -3
- package/src/tui/tree-list.ts +4 -4
- package/src/tui/utils.ts +71 -1
- package/src/utils/frontmatter.ts +1 -1
- package/src/utils/title-generator.ts +1 -1
- package/src/utils/tools-manager.ts +18 -2
- package/src/web/scrapers/osv.ts +4 -1
- package/src/web/scrapers/youtube.ts +1 -1
- package/src/web/search/index.ts +1 -1
- package/src/web/search/render.ts +96 -90
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import * as fs from "node:fs/promises";
|
|
2
|
+
import * as path from "node:path";
|
|
3
|
+
import { isEnoent, logger } from "@oh-my-pi/pi-utils";
|
|
4
|
+
|
|
5
|
+
const BLOB_PREFIX = "blob:sha256:";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Content-addressed blob store for externalizing large binary data (images) from session JSONL files.
|
|
9
|
+
*
|
|
10
|
+
* Files are stored at `<dir>/<sha256-hex>` with no extension. The SHA-256 hash is computed
|
|
11
|
+
* over the raw binary data (not base64). Content-addressing makes writes idempotent and
|
|
12
|
+
* provides automatic deduplication across sessions.
|
|
13
|
+
*/
|
|
14
|
+
export class BlobStore {
|
|
15
|
+
constructor(readonly dir: string) {}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Write binary data to the blob store.
|
|
19
|
+
* @returns SHA-256 hex hash of the data
|
|
20
|
+
*/
|
|
21
|
+
async put(data: Buffer): Promise<string> {
|
|
22
|
+
const hasher = new Bun.CryptoHasher("sha256");
|
|
23
|
+
hasher.update(data);
|
|
24
|
+
const hash = hasher.digest("hex");
|
|
25
|
+
const blobPath = path.join(this.dir, hash);
|
|
26
|
+
|
|
27
|
+
// Content-addressed: skip write if blob already exists
|
|
28
|
+
try {
|
|
29
|
+
await fs.access(blobPath);
|
|
30
|
+
return hash;
|
|
31
|
+
} catch {
|
|
32
|
+
// Does not exist, write it
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
await Bun.write(blobPath, data);
|
|
36
|
+
return hash;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Read blob by hash, returns Buffer or null if not found. */
|
|
40
|
+
async get(hash: string): Promise<Buffer | null> {
|
|
41
|
+
const blobPath = path.join(this.dir, hash);
|
|
42
|
+
try {
|
|
43
|
+
const file = Bun.file(blobPath);
|
|
44
|
+
const ab = await file.arrayBuffer();
|
|
45
|
+
return Buffer.from(ab);
|
|
46
|
+
} catch (err) {
|
|
47
|
+
if (isEnoent(err)) return null;
|
|
48
|
+
throw err;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Check if a blob exists. */
|
|
53
|
+
async has(hash: string): Promise<boolean> {
|
|
54
|
+
try {
|
|
55
|
+
await fs.access(path.join(this.dir, hash));
|
|
56
|
+
return true;
|
|
57
|
+
} catch {
|
|
58
|
+
return false;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Check if a data string is a blob reference. */
|
|
64
|
+
export function isBlobRef(data: string): boolean {
|
|
65
|
+
return data.startsWith(BLOB_PREFIX);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** Extract the SHA-256 hash from a blob reference string. */
|
|
69
|
+
export function parseBlobRef(data: string): string | null {
|
|
70
|
+
if (!data.startsWith(BLOB_PREFIX)) return null;
|
|
71
|
+
return data.slice(BLOB_PREFIX.length);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** Create a blob reference string from a SHA-256 hash. */
|
|
75
|
+
export function makeBlobRef(hash: string): string {
|
|
76
|
+
return `${BLOB_PREFIX}${hash}`;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Externalize an image's base64 data to the blob store, returning a blob reference.
|
|
81
|
+
* If the data is already a blob reference, returns it unchanged.
|
|
82
|
+
*/
|
|
83
|
+
export async function externalizeImageData(blobStore: BlobStore, base64Data: string): Promise<string> {
|
|
84
|
+
if (isBlobRef(base64Data)) return base64Data;
|
|
85
|
+
const buffer = Buffer.from(base64Data, "base64");
|
|
86
|
+
const hash = await blobStore.put(buffer);
|
|
87
|
+
return makeBlobRef(hash);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Resolve a blob reference back to base64 data.
|
|
92
|
+
* If the data is not a blob reference, returns it unchanged.
|
|
93
|
+
* If the blob is missing, logs a warning and returns a placeholder.
|
|
94
|
+
*/
|
|
95
|
+
export async function resolveImageData(blobStore: BlobStore, data: string): Promise<string> {
|
|
96
|
+
const hash = parseBlobRef(data);
|
|
97
|
+
if (!hash) return data;
|
|
98
|
+
|
|
99
|
+
const buffer = await blobStore.get(hash);
|
|
100
|
+
if (!buffer) {
|
|
101
|
+
logger.warn("Blob not found for image reference", { hash });
|
|
102
|
+
return data; // Return the ref as-is; downstream will see invalid base64 but won't crash
|
|
103
|
+
}
|
|
104
|
+
return buffer.toString("base64");
|
|
105
|
+
}
|
|
@@ -2,8 +2,8 @@ import * as path from "node:path";
|
|
|
2
2
|
import type { AgentMessage } from "@oh-my-pi/pi-agent-core";
|
|
3
3
|
import type { ImageContent, Message, TextContent, Usage } from "@oh-my-pi/pi-ai";
|
|
4
4
|
import { isEnoent, logger, parseJsonlLenient, Snowflake } from "@oh-my-pi/pi-utils";
|
|
5
|
-
import { getAgentDir as getDefaultAgentDir } from "../config";
|
|
6
|
-
import {
|
|
5
|
+
import { getBlobsDir, getAgentDir as getDefaultAgentDir } from "../config";
|
|
6
|
+
import { BlobStore, externalizeImageData, isBlobRef, resolveImageData } from "./blob-store";
|
|
7
7
|
import {
|
|
8
8
|
type BashExecutionMessage,
|
|
9
9
|
type CustomMessage,
|
|
@@ -125,6 +125,15 @@ export interface SessionInitEntry extends SessionEntryBase {
|
|
|
125
125
|
outputSchema?: unknown;
|
|
126
126
|
}
|
|
127
127
|
|
|
128
|
+
/** Mode change entry - tracks agent mode transitions (e.g. plan mode). */
|
|
129
|
+
export interface ModeChangeEntry extends SessionEntryBase {
|
|
130
|
+
type: "mode_change";
|
|
131
|
+
/** Current mode name, or "none" when exiting a mode */
|
|
132
|
+
mode: string;
|
|
133
|
+
/** Optional mode-specific data (e.g. plan file path) */
|
|
134
|
+
data?: Record<string, unknown>;
|
|
135
|
+
}
|
|
136
|
+
|
|
128
137
|
/**
|
|
129
138
|
* Custom message entry for extensions to inject messages into LLM context.
|
|
130
139
|
* Use customType to identify your extension's entries.
|
|
@@ -156,7 +165,8 @@ export type SessionEntry =
|
|
|
156
165
|
| CustomMessageEntry
|
|
157
166
|
| LabelEntry
|
|
158
167
|
| TtsrInjectionEntry
|
|
159
|
-
| SessionInitEntry
|
|
168
|
+
| SessionInitEntry
|
|
169
|
+
| ModeChangeEntry;
|
|
160
170
|
|
|
161
171
|
/** Raw file entry (includes header) */
|
|
162
172
|
export type FileEntry = SessionHeader | SessionEntry;
|
|
@@ -176,6 +186,10 @@ export interface SessionContext {
|
|
|
176
186
|
models: Record<string, string>;
|
|
177
187
|
/** Names of TTSR rules that have been injected this session */
|
|
178
188
|
injectedTtsrRules: string[];
|
|
189
|
+
/** Active mode (e.g. "plan") or "none" if no special mode is active */
|
|
190
|
+
mode: string;
|
|
191
|
+
/** Mode-specific data from the last mode_change entry */
|
|
192
|
+
modeData?: Record<string, unknown>;
|
|
179
193
|
}
|
|
180
194
|
|
|
181
195
|
export interface SessionInfo {
|
|
@@ -184,6 +198,8 @@ export interface SessionInfo {
|
|
|
184
198
|
/** Working directory where the session was started. Empty string for old sessions. */
|
|
185
199
|
cwd: string;
|
|
186
200
|
title?: string;
|
|
201
|
+
/** Path to the parent session (if this session was forked). */
|
|
202
|
+
parentSessionPath?: string;
|
|
187
203
|
created: Date;
|
|
188
204
|
modified: Date;
|
|
189
205
|
messageCount: number;
|
|
@@ -324,7 +340,7 @@ export function buildSessionContext(
|
|
|
324
340
|
let leaf: SessionEntry | undefined;
|
|
325
341
|
if (leafId === null) {
|
|
326
342
|
// Explicitly null - return no messages (navigated to before first entry)
|
|
327
|
-
return { messages: [], thinkingLevel: "off", models: {}, injectedTtsrRules: [] };
|
|
343
|
+
return { messages: [], thinkingLevel: "off", models: {}, injectedTtsrRules: [], mode: "none" };
|
|
328
344
|
}
|
|
329
345
|
if (leafId) {
|
|
330
346
|
leaf = byId.get(leafId);
|
|
@@ -335,7 +351,7 @@ export function buildSessionContext(
|
|
|
335
351
|
}
|
|
336
352
|
|
|
337
353
|
if (!leaf) {
|
|
338
|
-
return { messages: [], thinkingLevel: "off", models: {}, injectedTtsrRules: [] };
|
|
354
|
+
return { messages: [], thinkingLevel: "off", models: {}, injectedTtsrRules: [], mode: "none" };
|
|
339
355
|
}
|
|
340
356
|
|
|
341
357
|
// Walk from leaf to root, collecting path
|
|
@@ -351,6 +367,8 @@ export function buildSessionContext(
|
|
|
351
367
|
const models: Record<string, string> = {};
|
|
352
368
|
let compaction: CompactionEntry | null = null;
|
|
353
369
|
const injectedTtsrRulesSet = new Set<string>();
|
|
370
|
+
let mode = "none";
|
|
371
|
+
let modeData: Record<string, unknown> | undefined;
|
|
354
372
|
|
|
355
373
|
for (const entry of path) {
|
|
356
374
|
if (entry.type === "thinking_level_change") {
|
|
@@ -371,6 +389,9 @@ export function buildSessionContext(
|
|
|
371
389
|
for (const ruleName of entry.injectedRules) {
|
|
372
390
|
injectedTtsrRulesSet.add(ruleName);
|
|
373
391
|
}
|
|
392
|
+
} else if (entry.type === "mode_change") {
|
|
393
|
+
mode = entry.mode;
|
|
394
|
+
modeData = entry.data;
|
|
374
395
|
}
|
|
375
396
|
}
|
|
376
397
|
|
|
@@ -433,7 +454,7 @@ export function buildSessionContext(
|
|
|
433
454
|
}
|
|
434
455
|
}
|
|
435
456
|
|
|
436
|
-
return { messages, thinkingLevel, models, injectedTtsrRules };
|
|
457
|
+
return { messages, thinkingLevel, models, injectedTtsrRules, mode, modeData };
|
|
437
458
|
}
|
|
438
459
|
|
|
439
460
|
/**
|
|
@@ -471,6 +492,41 @@ export async function loadEntriesFromFile(
|
|
|
471
492
|
return entries;
|
|
472
493
|
}
|
|
473
494
|
|
|
495
|
+
/**
|
|
496
|
+
* Resolve blob references in loaded entries, replacing `blob:sha256:<hash>` data fields
|
|
497
|
+
* with the actual base64 content from the blob store. Mutates entries in place.
|
|
498
|
+
*/
|
|
499
|
+
async function resolveBlobRefsInEntries(entries: FileEntry[], blobStore: BlobStore): Promise<void> {
|
|
500
|
+
const promises: Promise<void>[] = [];
|
|
501
|
+
|
|
502
|
+
for (const entry of entries) {
|
|
503
|
+
if (entry.type === "session") continue;
|
|
504
|
+
|
|
505
|
+
// Resolve image blocks in message content arrays
|
|
506
|
+
let contentArray: unknown[] | undefined;
|
|
507
|
+
if (entry.type === "message") {
|
|
508
|
+
const content = (entry.message as { content?: unknown }).content;
|
|
509
|
+
if (Array.isArray(content)) contentArray = content;
|
|
510
|
+
} else if (entry.type === "custom_message" && Array.isArray(entry.content)) {
|
|
511
|
+
contentArray = entry.content;
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
if (!contentArray) continue;
|
|
515
|
+
|
|
516
|
+
for (const block of contentArray) {
|
|
517
|
+
if (isImageBlock(block) && isBlobRef(block.data)) {
|
|
518
|
+
promises.push(
|
|
519
|
+
resolveImageData(blobStore, block.data).then(resolved => {
|
|
520
|
+
(block as { data: string }).data = resolved;
|
|
521
|
+
}),
|
|
522
|
+
);
|
|
523
|
+
}
|
|
524
|
+
}
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
await Promise.all(promises);
|
|
528
|
+
}
|
|
529
|
+
|
|
474
530
|
/**
|
|
475
531
|
* Lightweight metadata for a session file, used in session picker UI.
|
|
476
532
|
* Uses lazy getters to defer string formatting until actually displayed.
|
|
@@ -513,7 +569,7 @@ class RecentSessionInfo {
|
|
|
513
569
|
get name(): string {
|
|
514
570
|
if (this.#name) return this.#name;
|
|
515
571
|
const fullName = this.fullName;
|
|
516
|
-
this.#name = fullName.length <= 40 ? fullName : `${fullName.slice(0,
|
|
572
|
+
this.#name = fullName.length <= 40 ? fullName : `${fullName.slice(0, 39)}…`;
|
|
517
573
|
return this.#name;
|
|
518
574
|
}
|
|
519
575
|
|
|
@@ -603,9 +659,8 @@ function formatTimeAgo(date: Date): string {
|
|
|
603
659
|
|
|
604
660
|
const MAX_PERSIST_CHARS = 500_000;
|
|
605
661
|
const TRUNCATION_NOTICE = "\n\n[Session persistence truncated large content]";
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
662
|
+
/** Minimum base64 length to externalize to blob store (skip tiny inline images) */
|
|
663
|
+
const BLOB_EXTERNALIZE_THRESHOLD = 1024;
|
|
609
664
|
const TEXT_CONTENT_KEY = "content";
|
|
610
665
|
|
|
611
666
|
/**
|
|
@@ -638,25 +693,7 @@ function isImageBlock(value: unknown): value is { type: "image"; data: string; m
|
|
|
638
693
|
);
|
|
639
694
|
}
|
|
640
695
|
|
|
641
|
-
async function
|
|
642
|
-
try {
|
|
643
|
-
const maxBytes = Math.floor((MAX_PERSIST_CHARS * 3) / 4);
|
|
644
|
-
const resized = await resizeImage(image, {
|
|
645
|
-
maxWidth: 512,
|
|
646
|
-
maxHeight: 512,
|
|
647
|
-
maxBytes,
|
|
648
|
-
jpegQuality: 70,
|
|
649
|
-
});
|
|
650
|
-
if (resized.data.length > MAX_PERSIST_CHARS) {
|
|
651
|
-
return { type: "image", data: PLACEHOLDER_IMAGE_DATA, mimeType: "image/jpeg" };
|
|
652
|
-
}
|
|
653
|
-
return { type: "image", data: resized.data, mimeType: resized.mimeType };
|
|
654
|
-
} catch {
|
|
655
|
-
return { type: "image", data: PLACEHOLDER_IMAGE_DATA, mimeType: "image/jpeg" };
|
|
656
|
-
}
|
|
657
|
-
}
|
|
658
|
-
|
|
659
|
-
async function truncateForPersistence<T>(obj: T, key?: string): Promise<T> {
|
|
696
|
+
async function truncateForPersistence<T>(obj: T, blobStore: BlobStore, key?: string): Promise<T> {
|
|
660
697
|
if (obj === null || obj === undefined) return obj;
|
|
661
698
|
|
|
662
699
|
if (typeof obj === "string") {
|
|
@@ -673,16 +710,13 @@ async function truncateForPersistence<T>(obj: T, key?: string): Promise<T> {
|
|
|
673
710
|
obj.map(async item => {
|
|
674
711
|
// Special handling: compress oversized images while preserving shape
|
|
675
712
|
if (key === TEXT_CONTENT_KEY && isImageBlock(item)) {
|
|
676
|
-
if (item.data.length
|
|
713
|
+
if (!isBlobRef(item.data) && item.data.length >= BLOB_EXTERNALIZE_THRESHOLD) {
|
|
677
714
|
changed = true;
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
data: item.data,
|
|
681
|
-
mimeType: item.mimeType ?? "image/jpeg",
|
|
682
|
-
});
|
|
715
|
+
const blobRef = await externalizeImageData(blobStore, item.data);
|
|
716
|
+
return { ...item, data: blobRef };
|
|
683
717
|
}
|
|
684
718
|
}
|
|
685
|
-
const newItem = await truncateForPersistence(item, key);
|
|
719
|
+
const newItem = await truncateForPersistence(item, blobStore, key);
|
|
686
720
|
if (newItem !== item) changed = true;
|
|
687
721
|
return newItem;
|
|
688
722
|
}),
|
|
@@ -701,7 +735,7 @@ async function truncateForPersistence<T>(obj: T, key?: string): Promise<T> {
|
|
|
701
735
|
changed = true;
|
|
702
736
|
continue;
|
|
703
737
|
}
|
|
704
|
-
const newV = await truncateForPersistence(v, k);
|
|
738
|
+
const newV = await truncateForPersistence(v, blobStore, k);
|
|
705
739
|
result[k] = newV;
|
|
706
740
|
if (newV !== v) changed = true;
|
|
707
741
|
}
|
|
@@ -715,8 +749,8 @@ async function truncateForPersistence<T>(obj: T, key?: string): Promise<T> {
|
|
|
715
749
|
return obj;
|
|
716
750
|
}
|
|
717
751
|
|
|
718
|
-
async function prepareEntryForPersistence(entry: FileEntry): Promise<FileEntry> {
|
|
719
|
-
return truncateForPersistence(entry);
|
|
752
|
+
async function prepareEntryForPersistence(entry: FileEntry, blobStore: BlobStore): Promise<FileEntry> {
|
|
753
|
+
return truncateForPersistence(entry, blobStore);
|
|
720
754
|
}
|
|
721
755
|
|
|
722
756
|
class NdjsonFileWriter {
|
|
@@ -933,6 +967,7 @@ async function collectSessionsFromFiles(files: string[], storage: SessionStorage
|
|
|
933
967
|
id: header.id,
|
|
934
968
|
cwd: typeof header.cwd === "string" ? header.cwd : "",
|
|
935
969
|
title: header.title ?? shortSummary,
|
|
970
|
+
parentSessionPath: (header as SessionHeader).parentSession,
|
|
936
971
|
created: new Date(header.timestamp),
|
|
937
972
|
modified: stats.mtime,
|
|
938
973
|
messageCount,
|
|
@@ -963,6 +998,7 @@ export class SessionManager {
|
|
|
963
998
|
private persistChain: Promise<void> = Promise.resolve();
|
|
964
999
|
private persistError: Error | undefined;
|
|
965
1000
|
private persistErrorReported = false;
|
|
1001
|
+
private readonly blobStore: BlobStore;
|
|
966
1002
|
|
|
967
1003
|
private constructor(
|
|
968
1004
|
private readonly cwd: string,
|
|
@@ -970,6 +1006,7 @@ export class SessionManager {
|
|
|
970
1006
|
private readonly persist: boolean,
|
|
971
1007
|
private readonly storage: SessionStorage,
|
|
972
1008
|
) {
|
|
1009
|
+
this.blobStore = new BlobStore(getBlobsDir());
|
|
973
1010
|
if (persist && sessionDir) {
|
|
974
1011
|
this.storage.ensureDirSync(sessionDir);
|
|
975
1012
|
}
|
|
@@ -1002,6 +1039,8 @@ export class SessionManager {
|
|
|
1002
1039
|
await this._rewriteFile();
|
|
1003
1040
|
}
|
|
1004
1041
|
|
|
1042
|
+
await resolveBlobRefsInEntries(this.fileEntries, this.blobStore);
|
|
1043
|
+
|
|
1005
1044
|
this._buildIndex();
|
|
1006
1045
|
this.flushed = true;
|
|
1007
1046
|
} else {
|
|
@@ -1226,7 +1265,9 @@ export class SessionManager {
|
|
|
1226
1265
|
if (!this.persist || !this.sessionFile) return;
|
|
1227
1266
|
await this._queuePersistTask(async () => {
|
|
1228
1267
|
await this._closePersistWriterInternal();
|
|
1229
|
-
const entries = await Promise.all(
|
|
1268
|
+
const entries = await Promise.all(
|
|
1269
|
+
this.fileEntries.map(entry => prepareEntryForPersistence(entry, this.blobStore)),
|
|
1270
|
+
);
|
|
1230
1271
|
await this._writeEntriesAtomically(entries);
|
|
1231
1272
|
this.flushed = true;
|
|
1232
1273
|
});
|
|
@@ -1294,14 +1335,18 @@ export class SessionManager {
|
|
|
1294
1335
|
if (this.persistError) throw this.persistError;
|
|
1295
1336
|
|
|
1296
1337
|
const hasAssistant = this.fileEntries.some(e => e.type === "message" && e.message.role === "assistant");
|
|
1297
|
-
if (!hasAssistant
|
|
1338
|
+
if (!hasAssistant) {
|
|
1339
|
+
// Mark as not flushed so when assistant arrives, all entries get written
|
|
1340
|
+
this.flushed = false;
|
|
1341
|
+
return;
|
|
1342
|
+
}
|
|
1298
1343
|
|
|
1299
1344
|
if (!this.flushed) {
|
|
1300
1345
|
this.flushed = true;
|
|
1301
1346
|
void this._queuePersistTask(async () => {
|
|
1302
1347
|
const writer = this._ensurePersistWriter();
|
|
1303
1348
|
if (!writer) return;
|
|
1304
|
-
const entries = await Promise.all(this.fileEntries.map(e => prepareEntryForPersistence(e)));
|
|
1349
|
+
const entries = await Promise.all(this.fileEntries.map(e => prepareEntryForPersistence(e, this.blobStore)));
|
|
1305
1350
|
for (const persistedEntry of entries) {
|
|
1306
1351
|
await writer.write(persistedEntry);
|
|
1307
1352
|
}
|
|
@@ -1310,7 +1355,7 @@ export class SessionManager {
|
|
|
1310
1355
|
void this._queuePersistTask(async () => {
|
|
1311
1356
|
const writer = this._ensurePersistWriter();
|
|
1312
1357
|
if (!writer) return;
|
|
1313
|
-
const persistedEntry = await prepareEntryForPersistence(entry);
|
|
1358
|
+
const persistedEntry = await prepareEntryForPersistence(entry, this.blobStore);
|
|
1314
1359
|
await writer.write(persistedEntry);
|
|
1315
1360
|
});
|
|
1316
1361
|
}
|
|
@@ -1381,6 +1426,20 @@ export class SessionManager {
|
|
|
1381
1426
|
return entry.id;
|
|
1382
1427
|
}
|
|
1383
1428
|
|
|
1429
|
+
/** Append a mode change as child of current leaf, then advance leaf. Returns entry id. */
|
|
1430
|
+
appendModeChange(mode: string, data?: Record<string, unknown>): string {
|
|
1431
|
+
const entry: ModeChangeEntry = {
|
|
1432
|
+
type: "mode_change",
|
|
1433
|
+
id: generateId(this.byId),
|
|
1434
|
+
parentId: this.leafId,
|
|
1435
|
+
timestamp: new Date().toISOString(),
|
|
1436
|
+
mode,
|
|
1437
|
+
data,
|
|
1438
|
+
};
|
|
1439
|
+
this._appendEntry(entry);
|
|
1440
|
+
return entry.id;
|
|
1441
|
+
}
|
|
1442
|
+
|
|
1384
1443
|
/**
|
|
1385
1444
|
* Append a model change as child of current leaf, then advance leaf. Returns entry id.
|
|
1386
1445
|
* @param model Model in "provider/modelId" format
|
|
@@ -1776,6 +1835,7 @@ export class SessionManager {
|
|
|
1776
1835
|
* Returns the new session file path, or undefined if not persisting.
|
|
1777
1836
|
*/
|
|
1778
1837
|
createBranchedSession(leafId: string): string | undefined {
|
|
1838
|
+
const previousSessionFile = this.sessionFile;
|
|
1779
1839
|
const branchPath = this.getBranch(leafId);
|
|
1780
1840
|
if (branchPath.length === 0) {
|
|
1781
1841
|
throw new Error(`Entry ${leafId} not found`);
|
|
@@ -1795,7 +1855,7 @@ export class SessionManager {
|
|
|
1795
1855
|
id: newSessionId,
|
|
1796
1856
|
timestamp,
|
|
1797
1857
|
cwd: this.cwd,
|
|
1798
|
-
parentSession: this.persist ?
|
|
1858
|
+
parentSession: this.persist ? previousSessionFile : undefined,
|
|
1799
1859
|
};
|
|
1800
1860
|
|
|
1801
1861
|
// Collect labels for entries in the path
|
|
@@ -1834,6 +1894,8 @@ export class SessionManager {
|
|
|
1834
1894
|
this.storage.writeTextSync(newSessionFile, `${lines.join("\n")}\n`);
|
|
1835
1895
|
this.fileEntries = [header, ...pathWithoutLabels, ...labelEntries];
|
|
1836
1896
|
this.sessionId = newSessionId;
|
|
1897
|
+
this.sessionFile = newSessionFile;
|
|
1898
|
+
this.flushed = true;
|
|
1837
1899
|
this._buildIndex();
|
|
1838
1900
|
return newSessionFile;
|
|
1839
1901
|
}
|
|
@@ -1885,6 +1947,7 @@ export class SessionManager {
|
|
|
1885
1947
|
const manager = new SessionManager(cwd, dir, true, storage);
|
|
1886
1948
|
const forkEntries = structuredClone(await loadEntriesFromFile(sourcePath, storage)) as FileEntry[];
|
|
1887
1949
|
migrateToCurrentVersion(forkEntries);
|
|
1950
|
+
await resolveBlobRefsInEntries(forkEntries, manager.blobStore);
|
|
1888
1951
|
const sourceHeader = forkEntries.find(e => e.type === "session") as SessionHeader | undefined;
|
|
1889
1952
|
const historyEntries = forkEntries.filter(entry => entry.type !== "session") as SessionEntry[];
|
|
1890
1953
|
manager._newSessionSync({ parentSession: sourceHeader?.id });
|
package/src/task/executor.ts
CHANGED
|
@@ -270,7 +270,7 @@ function extractToolArgsPreview(args: Record<string, unknown>): string {
|
|
|
270
270
|
for (const key of previewKeys) {
|
|
271
271
|
if (args[key] && typeof args[key] === "string") {
|
|
272
272
|
const value = args[key] as string;
|
|
273
|
-
return value.length > 60 ? `${value.slice(0,
|
|
273
|
+
return value.length > 60 ? `${value.slice(0, 59)}…` : value;
|
|
274
274
|
}
|
|
275
275
|
}
|
|
276
276
|
|
|
@@ -940,6 +940,7 @@ export async function runSubprocess(options: ExecutorOptions): Promise<SingleRes
|
|
|
940
940
|
getActiveTools: () => session.getActiveToolNames(),
|
|
941
941
|
getAllTools: () => session.getAllToolNames(),
|
|
942
942
|
setActiveTools: (toolNames: string[]) => session.setActiveToolsByName(toolNames),
|
|
943
|
+
getCommands: () => [],
|
|
943
944
|
setModel: async model => {
|
|
944
945
|
const key = await session.modelRegistry.getApiKey(model);
|
|
945
946
|
if (!key) return false;
|
|
@@ -956,6 +957,7 @@ export async function runSubprocess(options: ExecutorOptions): Promise<SingleRes
|
|
|
956
957
|
hasPendingMessages: () => session.queuedMessageCount > 0,
|
|
957
958
|
shutdown: () => {},
|
|
958
959
|
getContextUsage: () => session.getContextUsage(),
|
|
960
|
+
getSystemPrompt: () => session.systemPrompt,
|
|
959
961
|
compact: async instructionsOrOptions => {
|
|
960
962
|
const instructions = typeof instructionsOrOptions === "string" ? instructionsOrOptions : undefined;
|
|
961
963
|
const options =
|
|
@@ -1100,15 +1102,23 @@ export async function runSubprocess(options: ExecutorOptions): Promise<SingleRes
|
|
|
1100
1102
|
}
|
|
1101
1103
|
} else {
|
|
1102
1104
|
// Normal successful completion
|
|
1103
|
-
const
|
|
1104
|
-
|
|
1105
|
-
|
|
1106
|
-
|
|
1107
|
-
const
|
|
1108
|
-
rawOutput =
|
|
1105
|
+
const submitData = lastSubmitResult?.data;
|
|
1106
|
+
if (submitData === null || submitData === undefined) {
|
|
1107
|
+
// Agent called submit_result but with null/undefined data — treat as missing
|
|
1108
|
+
// so the fallback path can try to extract output from conversation text
|
|
1109
|
+
const warning = "SYSTEM WARNING: Subagent called submit_result with null data.";
|
|
1110
|
+
rawOutput = rawOutput ? `${warning}\n\n${rawOutput}` : warning;
|
|
1111
|
+
} else {
|
|
1112
|
+
const completeData = normalizeCompleteData(submitData, reportFindings);
|
|
1113
|
+
try {
|
|
1114
|
+
rawOutput = JSON.stringify(completeData, null, 2) ?? "null";
|
|
1115
|
+
} catch (err) {
|
|
1116
|
+
const errorMessage = err instanceof Error ? err.message : String(err);
|
|
1117
|
+
rawOutput = `{"error":"Failed to serialize submit_result data: ${errorMessage}"}`;
|
|
1118
|
+
}
|
|
1119
|
+
exitCode = 0;
|
|
1120
|
+
stderr = "";
|
|
1109
1121
|
}
|
|
1110
|
-
exitCode = 0;
|
|
1111
|
-
stderr = "";
|
|
1112
1122
|
}
|
|
1113
1123
|
} else {
|
|
1114
1124
|
const allowFallback = exitCode === 0 && !done.aborted && !signal?.aborted;
|