@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.
Files changed (92) hide show
  1. package/CHANGELOG.md +100 -0
  2. package/examples/extensions/plan-mode.ts +1 -1
  3. package/examples/hooks/qna.ts +1 -1
  4. package/examples/hooks/status-line.ts +1 -1
  5. package/examples/sdk/11-sessions.ts +1 -1
  6. package/package.json +8 -8
  7. package/src/cli/args.ts +9 -6
  8. package/src/cli/update-cli.ts +2 -2
  9. package/src/commands/index/index.ts +2 -5
  10. package/src/commit/agentic/agent.ts +1 -1
  11. package/src/commit/changelog/index.ts +2 -2
  12. package/src/config/keybindings.ts +16 -1
  13. package/src/config/model-registry.ts +25 -20
  14. package/src/config/model-resolver.ts +8 -8
  15. package/src/config/resolve-config-value.ts +92 -0
  16. package/src/config/settings-schema.ts +9 -0
  17. package/src/config.ts +14 -1
  18. package/src/export/html/template.css +7 -0
  19. package/src/export/html/template.generated.ts +1 -1
  20. package/src/export/html/template.js +33 -16
  21. package/src/extensibility/custom-commands/bundled/review/index.ts +1 -1
  22. package/src/extensibility/extensions/index.ts +18 -0
  23. package/src/extensibility/extensions/loader.ts +15 -0
  24. package/src/extensibility/extensions/runner.ts +78 -1
  25. package/src/extensibility/extensions/types.ts +131 -5
  26. package/src/extensibility/extensions/wrapper.ts +1 -1
  27. package/src/extensibility/plugins/git-url.ts +270 -0
  28. package/src/extensibility/plugins/index.ts +2 -0
  29. package/src/extensibility/slash-commands.ts +45 -0
  30. package/src/index.ts +7 -0
  31. package/src/lsp/render.ts +50 -43
  32. package/src/lsp/utils.ts +2 -2
  33. package/src/main.ts +11 -10
  34. package/src/mcp/transports/stdio.ts +3 -5
  35. package/src/modes/components/custom-message.ts +0 -8
  36. package/src/modes/components/diff.ts +1 -7
  37. package/src/modes/components/footer.ts +4 -4
  38. package/src/modes/components/model-selector.ts +4 -0
  39. package/src/modes/components/todo-display.ts +13 -3
  40. package/src/modes/components/tool-execution.ts +30 -16
  41. package/src/modes/components/tree-selector.ts +50 -19
  42. package/src/modes/controllers/event-controller.ts +1 -0
  43. package/src/modes/controllers/extension-ui-controller.ts +34 -2
  44. package/src/modes/controllers/input-controller.ts +47 -33
  45. package/src/modes/controllers/selector-controller.ts +10 -15
  46. package/src/modes/interactive-mode.ts +50 -38
  47. package/src/modes/print-mode.ts +6 -0
  48. package/src/modes/rpc/rpc-client.ts +4 -4
  49. package/src/modes/rpc/rpc-mode.ts +17 -2
  50. package/src/modes/rpc/rpc-types.ts +2 -2
  51. package/src/modes/types.ts +1 -0
  52. package/src/modes/utils/ui-helpers.ts +3 -1
  53. package/src/patch/applicator.ts +2 -3
  54. package/src/patch/fuzzy.ts +1 -1
  55. package/src/patch/shared.ts +74 -61
  56. package/src/prompts/system/system-prompt.md +1 -0
  57. package/src/prompts/tools/task.md +6 -0
  58. package/src/sdk.ts +15 -11
  59. package/src/session/agent-session.ts +72 -23
  60. package/src/session/auth-storage.ts +2 -1
  61. package/src/session/blob-store.ts +105 -0
  62. package/src/session/session-manager.ts +107 -44
  63. package/src/task/executor.ts +19 -9
  64. package/src/task/render.ts +80 -58
  65. package/src/tools/ask.ts +28 -5
  66. package/src/tools/bash.ts +47 -39
  67. package/src/tools/browser.ts +248 -26
  68. package/src/tools/calculator.ts +42 -23
  69. package/src/tools/fetch.ts +33 -16
  70. package/src/tools/find.ts +57 -22
  71. package/src/tools/grep.ts +54 -25
  72. package/src/tools/index.ts +5 -5
  73. package/src/tools/notebook.ts +19 -6
  74. package/src/tools/path-utils.ts +26 -1
  75. package/src/tools/python.ts +20 -14
  76. package/src/tools/read.ts +21 -8
  77. package/src/tools/render-utils.ts +5 -45
  78. package/src/tools/ssh.ts +59 -53
  79. package/src/tools/submit-result.ts +2 -2
  80. package/src/tools/todo-write.ts +32 -14
  81. package/src/tools/truncate.ts +1 -1
  82. package/src/tools/write.ts +39 -24
  83. package/src/tui/output-block.ts +61 -3
  84. package/src/tui/tree-list.ts +4 -4
  85. package/src/tui/utils.ts +71 -1
  86. package/src/utils/frontmatter.ts +1 -1
  87. package/src/utils/title-generator.ts +1 -1
  88. package/src/utils/tools-manager.ts +18 -2
  89. package/src/web/scrapers/osv.ts +4 -1
  90. package/src/web/scrapers/youtube.ts +1 -1
  91. package/src/web/search/index.ts +1 -1
  92. 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 { resizeImage } from "../utils/image-resize";
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, 37)}...`;
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
- const PLACEHOLDER_IMAGE_DATA =
607
- "/9j/4AAQSkZJRgABAQEASABIAAD/2wBDAP//////////////////////////////////////////////////////////////////////////////////////2wBDAf//////////////////////////////////////////////////////////////////////////////////////wAARCAAQABADASIAAhEBAxEB/8QAFQABAQAAAAAAAAAAAAAAAAAAAAf/xAAUEAEAAAAAAAAAAAAAAAAAAAAA/8QAFQEBAQAAAAAAAAAAAAAAAAAAAgP/xAAUEQEAAAAAAAAAAAAAAAAAAAAA/9oADAMBAAIRAxEAPwCkA//Z";
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 compressImageForPersistence(image: ImageContent): Promise<ImageContent> {
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 > MAX_PERSIST_CHARS) {
713
+ if (!isBlobRef(item.data) && item.data.length >= BLOB_EXTERNALIZE_THRESHOLD) {
677
714
  changed = true;
678
- return compressImageForPersistence({
679
- type: "image",
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(this.fileEntries.map(entry => prepareEntryForPersistence(entry)));
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 && !this.flushed) return;
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 ? this.sessionFile : undefined,
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 });
@@ -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, 57)}...` : value;
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 completeData = normalizeCompleteData(lastSubmitResult?.data ?? null, reportFindings);
1104
- try {
1105
- rawOutput = JSON.stringify(completeData, null, 2) ?? "null";
1106
- } catch (err) {
1107
- const errorMessage = err instanceof Error ? err.message : String(err);
1108
- rawOutput = `{"error":"Failed to serialize submit_result data: ${errorMessage}"}`;
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;