@oh-my-pi/pi-coding-agent 13.11.1 → 13.12.3

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 (75) hide show
  1. package/CHANGELOG.md +95 -0
  2. package/package.json +7 -7
  3. package/src/capability/context-file.ts +2 -0
  4. package/src/capability/extension-module.ts +1 -0
  5. package/src/capability/hook.ts +1 -0
  6. package/src/capability/index.ts +21 -10
  7. package/src/capability/instruction.ts +1 -0
  8. package/src/capability/mcp.ts +1 -0
  9. package/src/capability/prompt.ts +1 -0
  10. package/src/capability/rule.ts +5 -0
  11. package/src/capability/skill.ts +1 -0
  12. package/src/capability/slash-command.ts +1 -0
  13. package/src/capability/tool.ts +1 -0
  14. package/src/capability/types.ts +10 -0
  15. package/src/cli/commands/init-xdg.ts +27 -0
  16. package/src/cli/config-cli.ts +8 -3
  17. package/src/cli/shell-cli.ts +1 -1
  18. package/src/commands/config.ts +1 -1
  19. package/src/config/model-registry.ts +63 -10
  20. package/src/config/model-resolver.ts +84 -21
  21. package/src/config/settings-schema.ts +977 -769
  22. package/src/discovery/helpers.ts +8 -2
  23. package/src/exec/bash-executor.ts +62 -25
  24. package/src/extensibility/custom-tools/types.ts +2 -3
  25. package/src/extensibility/extensions/loader.ts +5 -1
  26. package/src/extensibility/extensions/types.ts +2 -0
  27. package/src/extensibility/hooks/types.ts +2 -0
  28. package/src/extensibility/plugins/loader.ts +23 -5
  29. package/src/extensibility/plugins/manager.ts +14 -0
  30. package/src/extensibility/plugins/types.ts +4 -0
  31. package/src/extensibility/skills.ts +7 -1
  32. package/src/index.ts +6 -6
  33. package/src/internal-urls/docs-index.generated.ts +2 -2
  34. package/src/ipy/kernel.ts +4 -5
  35. package/src/memories/index.ts +20 -7
  36. package/src/memories/storage.ts +46 -32
  37. package/src/modes/components/agent-dashboard.ts +23 -35
  38. package/src/modes/components/assistant-message.ts +25 -2
  39. package/src/modes/components/btw-panel.ts +104 -0
  40. package/src/modes/components/diff.ts +2 -7
  41. package/src/modes/components/extensions/state-manager.ts +3 -2
  42. package/src/modes/components/settings-defs.ts +56 -6
  43. package/src/modes/components/settings-selector.ts +11 -6
  44. package/src/modes/controllers/btw-controller.ts +193 -0
  45. package/src/modes/controllers/command-controller.ts +9 -3
  46. package/src/modes/controllers/event-controller.ts +4 -0
  47. package/src/modes/controllers/input-controller.ts +10 -1
  48. package/src/modes/interactive-mode.ts +22 -0
  49. package/src/modes/prompt-action-autocomplete.ts +17 -3
  50. package/src/modes/rpc/rpc-client.ts +30 -19
  51. package/src/modes/theme/theme.ts +28 -36
  52. package/src/modes/types.ts +4 -0
  53. package/src/modes/utils/ui-helpers.ts +3 -0
  54. package/src/patch/diff.ts +9 -1
  55. package/src/patch/index.ts +56 -9
  56. package/src/prompts/system/btw-user.md +8 -0
  57. package/src/prompts/system/custom-system-prompt.md +1 -1
  58. package/src/prompts/system/system-prompt.md +1 -0
  59. package/src/sdk.ts +23 -26
  60. package/src/session/agent-session.ts +65 -37
  61. package/src/session/blob-store.ts +32 -0
  62. package/src/session/compaction/compaction.ts +37 -6
  63. package/src/session/history-storage.ts +2 -2
  64. package/src/session/session-manager.ts +129 -49
  65. package/src/slash-commands/builtin-registry.ts +11 -0
  66. package/src/system-prompt.ts +4 -17
  67. package/src/task/agents.ts +1 -1
  68. package/src/task/index.ts +9 -8
  69. package/src/tools/browser.ts +11 -0
  70. package/src/tools/output-meta.ts +103 -3
  71. package/src/tools/path-utils.ts +11 -0
  72. package/src/utils/title-generator.ts +70 -92
  73. package/src/utils/tools-manager.ts +1 -1
  74. package/src/web/scrapers/index.ts +7 -7
  75. package/src/web/scrapers/utils.ts +1 -0
@@ -74,6 +74,21 @@ export function parseBlobRef(data: string): string | null {
74
74
  return data.slice(BLOB_PREFIX.length);
75
75
  }
76
76
 
77
+ /** Identify provider transport image data URLs so persistence can externalize and restore them losslessly. */
78
+ export function isImageDataUrl(data: string): boolean {
79
+ return data.startsWith("data:image/") && data.includes(";base64,");
80
+ }
81
+
82
+ /**
83
+ * Externalize a provider image data URL to the blob store, returning a blob reference.
84
+ * The full data URL string is preserved so transport-native history can be reconstructed on resume.
85
+ */
86
+ export async function externalizeImageDataUrl(blobStore: BlobStore, dataUrl: string): Promise<string> {
87
+ if (isBlobRef(dataUrl)) return dataUrl;
88
+ const { ref } = await blobStore.put(Buffer.from(dataUrl, "utf8"));
89
+ return ref;
90
+ }
91
+
77
92
  /**
78
93
  * Externalize an image's base64 data to the blob store, returning a blob reference.
79
94
  * If the data is already a blob reference, returns it unchanged.
@@ -85,6 +100,23 @@ export async function externalizeImageData(blobStore: BlobStore, base64Data: str
85
100
  return ref;
86
101
  }
87
102
 
103
+ /**
104
+ * Resolve an externalized provider image data URL back to its original string.
105
+ * If the data is not a blob reference, returns it unchanged.
106
+ * If the blob is missing, logs a warning and returns the reference as-is.
107
+ */
108
+ export async function resolveImageDataUrl(blobStore: BlobStore, data: string): Promise<string> {
109
+ const hash = parseBlobRef(data);
110
+ if (!hash) return data;
111
+
112
+ const buffer = await blobStore.get(hash);
113
+ if (!buffer) {
114
+ logger.warn("Blob not found for persisted image data URL", { hash });
115
+ return data;
116
+ }
117
+ return buffer.toString("utf8");
118
+ }
119
+
88
120
  /**
89
121
  * Resolve a blob reference back to base64 data.
90
122
  * If the data is not a blob reference, returns it unchanged.
@@ -5,7 +5,14 @@
5
5
  * and after compaction the session is reloaded.
6
6
  */
7
7
  import type { AgentMessage } from "@oh-my-pi/pi-agent-core";
8
- import { type AssistantMessage, completeSimple, Effort, type Model, type Usage } from "@oh-my-pi/pi-ai";
8
+ import {
9
+ type AssistantMessage,
10
+ completeSimple,
11
+ Effort,
12
+ type MessageAttribution,
13
+ type Model,
14
+ type Usage,
15
+ } from "@oh-my-pi/pi-ai";
9
16
  import {
10
17
  CODEX_BASE_URL,
11
18
  getCodexAccountId,
@@ -129,6 +136,7 @@ export interface CompactionSettings {
129
136
  enabled: boolean;
130
137
  strategy?: "context-full" | "handoff" | "off";
131
138
  thresholdPercent?: number;
139
+ thresholdTokens?: number;
132
140
  reserveTokens: number;
133
141
  keepRecentTokens: number;
134
142
  autoContinue?: boolean;
@@ -140,6 +148,7 @@ export const DEFAULT_COMPACTION_SETTINGS: CompactionSettings = {
140
148
  enabled: true,
141
149
  strategy: "context-full",
142
150
  thresholdPercent: -1,
151
+ thresholdTokens: -1,
143
152
  reserveTokens: 16384,
144
153
  keepRecentTokens: 20000,
145
154
  autoContinue: true,
@@ -211,6 +220,14 @@ export function shouldCompact(contextTokens: number, contextWindow: number, sett
211
220
  }
212
221
 
213
222
  function resolveThresholdTokens(contextWindow: number, settings: CompactionSettings): number {
223
+ // Fixed token limit takes priority over percentage
224
+ const thresholdTokens = settings.thresholdTokens;
225
+ if (typeof thresholdTokens === "number" && Number.isFinite(thresholdTokens) && thresholdTokens > 0) {
226
+ // Clamp to [1, contextWindow - 1] so there's always room
227
+ return Math.min(contextWindow - 1, Math.max(1, thresholdTokens));
228
+ }
229
+
230
+ // Percentage-based threshold
214
231
  const thresholdPercent = settings.thresholdPercent;
215
232
  if (typeof thresholdPercent !== "number" || !Number.isFinite(thresholdPercent) || thresholdPercent <= 0) {
216
233
  return contextWindow - effectiveReserveTokens(contextWindow, settings);
@@ -935,6 +952,7 @@ export interface SummaryOptions {
935
952
  extraContext?: string[];
936
953
  remoteEndpoint?: string;
937
954
  remoteInstructions?: string;
955
+ initiatorOverride?: MessageAttribution;
938
956
  }
939
957
 
940
958
  export async function generateSummary(
@@ -990,7 +1008,7 @@ export async function generateSummary(
990
1008
  const response = await completeSimple(
991
1009
  model,
992
1010
  { systemPrompt: SUMMARIZATION_SYSTEM_PROMPT, messages: summarizationMessages },
993
- { maxTokens, signal, apiKey, reasoning: Effort.High },
1011
+ { maxTokens, signal, apiKey, reasoning: Effort.High, initiatorOverride: options?.initiatorOverride },
994
1012
  );
995
1013
 
996
1014
  if (response.stopReason === "error") {
@@ -1039,7 +1057,7 @@ async function generateShortSummary(
1039
1057
  systemPrompt: SUMMARIZATION_SYSTEM_PROMPT,
1040
1058
  messages: [{ role: "user", content: [{ type: "text", text: promptText }], timestamp: Date.now() }],
1041
1059
  },
1042
- { maxTokens, signal, apiKey, reasoning: Effort.High },
1060
+ { maxTokens, signal, apiKey, reasoning: Effort.High, initiatorOverride: options?.initiatorOverride },
1043
1061
  );
1044
1062
 
1045
1063
  if (response.stopReason === "error") {
@@ -1218,6 +1236,7 @@ export async function compact(
1218
1236
  extraContext: options?.extraContext,
1219
1237
  remoteEndpoint: settings.remoteEnabled === false ? undefined : settings.remoteEndpoint,
1220
1238
  remoteInstructions: options?.remoteInstructions,
1239
+ initiatorOverride: options?.initiatorOverride,
1221
1240
  };
1222
1241
 
1223
1242
  let preserveData = withOpenAiRemoteCompactionPreserveData(previousPreserveData, undefined);
@@ -1266,7 +1285,14 @@ export async function compact(
1266
1285
  summaryOptions,
1267
1286
  )
1268
1287
  : Promise.resolve("No prior history."),
1269
- generateTurnPrefixSummary(turnPrefixMessages, model, settings.reserveTokens, apiKey, signal),
1288
+ generateTurnPrefixSummary(
1289
+ turnPrefixMessages,
1290
+ model,
1291
+ settings.reserveTokens,
1292
+ apiKey,
1293
+ signal,
1294
+ summaryOptions.initiatorOverride,
1295
+ ),
1270
1296
  ]);
1271
1297
  // Merge into single summary
1272
1298
  summary = `${historyResult}\n\n---\n\n**Turn Context (split turn):**\n\n${turnPrefixResult}`;
@@ -1297,7 +1323,11 @@ export async function compact(
1297
1323
  settings.reserveTokens,
1298
1324
  apiKey,
1299
1325
  signal,
1300
- { extraContext: options?.extraContext, remoteEndpoint: summaryOptions.remoteEndpoint },
1326
+ {
1327
+ extraContext: options?.extraContext,
1328
+ remoteEndpoint: summaryOptions.remoteEndpoint,
1329
+ initiatorOverride: summaryOptions.initiatorOverride,
1330
+ },
1301
1331
  );
1302
1332
 
1303
1333
  // Compute file lists and append to summary
@@ -1327,6 +1357,7 @@ async function generateTurnPrefixSummary(
1327
1357
  reserveTokens: number,
1328
1358
  apiKey: string,
1329
1359
  signal?: AbortSignal,
1360
+ initiatorOverride?: MessageAttribution,
1330
1361
  ): Promise<string> {
1331
1362
  const maxTokens = Math.floor(0.5 * reserveTokens); // Smaller budget for turn prefix
1332
1363
 
@@ -1344,7 +1375,7 @@ async function generateTurnPrefixSummary(
1344
1375
  const response = await completeSimple(
1345
1376
  model,
1346
1377
  { systemPrompt: SUMMARIZATION_SYSTEM_PROMPT, messages: summarizationMessages },
1347
- { maxTokens, signal, apiKey, reasoning: Effort.High },
1378
+ { maxTokens, signal, apiKey, reasoning: Effort.High, initiatorOverride },
1348
1379
  );
1349
1380
 
1350
1381
  if (response.stopReason === "error") {
@@ -1,7 +1,7 @@
1
1
  import { Database, type Statement } from "bun:sqlite";
2
2
  import * as fs from "node:fs";
3
3
  import * as path from "node:path";
4
- import { getAgentDir, logger } from "@oh-my-pi/pi-utils";
4
+ import { getHistoryDbPath, logger } from "@oh-my-pi/pi-utils";
5
5
 
6
6
  export interface HistoryEntry {
7
7
  id: number;
@@ -78,7 +78,7 @@ END;
78
78
  this.#lastPromptCache = last?.prompt ?? null;
79
79
  }
80
80
 
81
- static open(dbPath: string = path.join(getAgentDir(), "history.db")): HistoryStorage {
81
+ static open(dbPath: string = getHistoryDbPath()): HistoryStorage {
82
82
  if (!HistoryStorage.#instance) {
83
83
  HistoryStorage.#instance = new HistoryStorage(dbPath);
84
84
  }
@@ -16,6 +16,8 @@ import {
16
16
  getBlobsDir,
17
17
  getAgentDir as getDefaultAgentDir,
18
18
  getProjectDir,
19
+ getSessionsDir,
20
+ getTerminalSessionsDir,
19
21
  isEnoent,
20
22
  logger,
21
23
  parseJsonlLenient,
@@ -23,7 +25,16 @@ import {
23
25
  toError,
24
26
  } from "@oh-my-pi/pi-utils";
25
27
  import { ArtifactManager } from "./artifacts";
26
- import { type BlobPutResult, BlobStore, externalizeImageData, isBlobRef, resolveImageData } from "./blob-store";
28
+ import {
29
+ type BlobPutResult,
30
+ BlobStore,
31
+ externalizeImageData,
32
+ externalizeImageDataUrl,
33
+ isBlobRef,
34
+ isImageDataUrl,
35
+ resolveImageData,
36
+ resolveImageDataUrl,
37
+ } from "./blob-store";
27
38
  import {
28
39
  type BashExecutionMessage,
29
40
  type CustomMessage,
@@ -348,7 +359,7 @@ function migrateHomeSessionDirs(): void {
348
359
  const homeEncoded = home.replace(/^[/\\]/, "").replace(/[/\\:]/g, "-");
349
360
  const oldPrefix = `--${homeEncoded}-`;
350
361
  const oldExact = `--${homeEncoded}--`;
351
- const sessionsRoot = path.join(getDefaultAgentDir(), "sessions");
362
+ const sessionsRoot = getSessionsDir();
352
363
 
353
364
  let entries: string[];
354
365
  try {
@@ -608,8 +619,7 @@ function encodeSessionDirName(cwd: string): string {
608
619
  */
609
620
  function getDefaultSessionDir(cwd: string, storage: SessionStorage): string {
610
621
  migrateHomeSessionDirs();
611
- const dirName = encodeSessionDirName(cwd);
612
- const sessionDir = path.join(getDefaultAgentDir(), "sessions", dirName);
622
+ const sessionDir = path.join(getSessionsDir(), encodeSessionDirName(cwd));
613
623
  storage.ensureDirSync(sessionDir);
614
624
  return sessionDir;
615
625
  }
@@ -618,8 +628,6 @@ function getDefaultSessionDir(cwd: string, storage: SessionStorage): string {
618
628
  // Terminal breadcrumbs: maps terminal (TTY) -> last session file for --continue
619
629
  // =============================================================================
620
630
 
621
- const TERMINAL_SESSIONS_DIR = "terminal-sessions";
622
-
623
631
  /**
624
632
  * Write a breadcrumb linking the current terminal to a session file.
625
633
  * The breadcrumb contains the cwd and session path so --continue can
@@ -629,7 +637,7 @@ function writeTerminalBreadcrumb(cwd: string, sessionFile: string): void {
629
637
  const terminalId = getTerminalId();
630
638
  if (!terminalId) return;
631
639
 
632
- const breadcrumbDir = path.join(getDefaultAgentDir(), TERMINAL_SESSIONS_DIR);
640
+ const breadcrumbDir = getTerminalSessionsDir();
633
641
  const breadcrumbFile = path.join(breadcrumbDir, terminalId);
634
642
  const content = `${cwd}\n${sessionFile}\n`;
635
643
  // Best-effort — don't break session creation if breadcrumb fails
@@ -645,7 +653,7 @@ async function readTerminalBreadcrumb(cwd: string): Promise<string | null> {
645
653
  if (!terminalId) return null;
646
654
 
647
655
  try {
648
- const breadcrumbFile = path.join(getDefaultAgentDir(), TERMINAL_SESSIONS_DIR, terminalId);
656
+ const breadcrumbFile = path.join(getTerminalSessionsDir(), terminalId);
649
657
  const content = await Bun.file(breadcrumbFile).text();
650
658
  const lines = content.trim().split("\n");
651
659
  if (lines.length < 2) return null;
@@ -691,35 +699,54 @@ export async function loadEntriesFromFile(
691
699
  }
692
700
 
693
701
  /**
694
- * Resolve blob references in loaded entries, replacing `blob:sha256:<hash>` data fields
695
- * with the actual base64 content from the blob store. Mutates entries in place.
702
+ * Resolve blob references in loaded entries, restoring both session image blocks and persisted
703
+ * provider image URLs back to the inline data expected by downstream transports. Mutates entries in place.
696
704
  */
705
+ function hasImageUrl(value: unknown): value is { image_url: string } {
706
+ return typeof value === "object" && value !== null && "image_url" in value && typeof value.image_url === "string";
707
+ }
708
+
709
+ async function resolvePersistedImageUrlRefs(value: unknown, blobStore: BlobStore): Promise<void> {
710
+ if (Array.isArray(value)) {
711
+ await Promise.all(value.map(item => resolvePersistedImageUrlRefs(item, blobStore)));
712
+ return;
713
+ }
714
+
715
+ if (typeof value !== "object" || value === null) return;
716
+
717
+ if (hasImageUrl(value) && isBlobRef(value.image_url)) {
718
+ value.image_url = await resolveImageDataUrl(blobStore, value.image_url);
719
+ }
720
+
721
+ await Promise.all(Object.values(value).map(item => resolvePersistedImageUrlRefs(item, blobStore)));
722
+ }
723
+
697
724
  async function resolveBlobRefsInEntries(entries: FileEntry[], blobStore: BlobStore): Promise<void> {
698
725
  const promises: Promise<void>[] = [];
699
726
 
700
727
  for (const entry of entries) {
701
728
  if (entry.type === "session") continue;
702
729
 
703
- // Resolve image blocks in message content arrays
704
730
  let contentArray: unknown[] | undefined;
705
- if (entry.type === "message") {
706
- const content = (entry.message as { content?: unknown }).content;
707
- if (Array.isArray(content)) contentArray = content;
731
+ if (entry.type === "message" && "content" in entry.message && Array.isArray(entry.message.content)) {
732
+ contentArray = entry.message.content;
708
733
  } else if (entry.type === "custom_message" && Array.isArray(entry.content)) {
709
734
  contentArray = entry.content;
710
735
  }
711
736
 
712
- if (!contentArray) continue;
713
-
714
- for (const block of contentArray) {
715
- if (isImageBlock(block) && isBlobRef(block.data)) {
716
- promises.push(
717
- resolveImageData(blobStore, block.data).then(resolved => {
718
- (block as { data: string }).data = resolved;
719
- }),
720
- );
737
+ if (contentArray) {
738
+ for (const block of contentArray) {
739
+ if (isImageBlock(block) && isBlobRef(block.data)) {
740
+ promises.push(
741
+ resolveImageData(blobStore, block.data).then(resolved => {
742
+ block.data = resolved;
743
+ }),
744
+ );
745
+ }
721
746
  }
722
747
  }
748
+
749
+ promises.push(resolvePersistedImageUrlRefs(entry, blobStore));
723
750
  }
724
751
 
725
752
  await Promise.all(promises);
@@ -891,19 +918,34 @@ function isImageBlock(value: unknown): value is { type: "image"; data: string; m
891
918
  );
892
919
  }
893
920
 
894
- async function truncateForPersistence<T>(obj: T, blobStore: BlobStore, key?: string): Promise<T> {
921
+ async function truncateForPersistence(obj: FileEntry, blobStore: BlobStore, key?: string): Promise<FileEntry>;
922
+ async function truncateForPersistence(obj: string, blobStore: BlobStore, key?: string): Promise<string>;
923
+ async function truncateForPersistence(obj: unknown[], blobStore: BlobStore, key?: string): Promise<unknown[]>;
924
+ async function truncateForPersistence(obj: object, blobStore: BlobStore, key?: string): Promise<object>;
925
+ async function truncateForPersistence(
926
+ obj: null | undefined,
927
+ blobStore: BlobStore,
928
+ key?: string,
929
+ ): Promise<null | undefined>;
930
+ async function truncateForPersistence(obj: unknown, blobStore: BlobStore, key?: string): Promise<unknown> {
895
931
  if (obj === null || obj === undefined) return obj;
896
932
 
897
933
  if (typeof obj === "string") {
934
+ if (key === "image_url" && isImageDataUrl(obj)) {
935
+ return externalizeImageDataUrl(blobStore, obj);
936
+ }
937
+
898
938
  if (obj.length > MAX_PERSIST_CHARS) {
899
939
  // Cryptographic signatures must be preserved exactly or cleared entirely — never truncated.
900
940
  // Truncation would produce an invalid signature that the API rejects.
901
941
  if (key === "thinkingSignature" || key === "thoughtSignature" || key === "textSignature") {
902
- return "" as T;
942
+ return "";
903
943
  }
944
+
904
945
  const limit = Math.max(0, MAX_PERSIST_CHARS - TRUNCATION_NOTICE.length);
905
- return `${truncateString(obj, limit)}${TRUNCATION_NOTICE}` as T;
946
+ return `${truncateString(obj, limit)}${TRUNCATION_NOTICE}`;
906
947
  }
948
+
907
949
  return obj;
908
950
  }
909
951
 
@@ -919,34 +961,54 @@ async function truncateForPersistence<T>(obj: T, blobStore: BlobStore, key?: str
919
961
  return { ...item, data: blobRef };
920
962
  }
921
963
  }
964
+
922
965
  const newItem = await truncateForPersistence(item, blobStore, key);
923
966
  if (newItem !== item) changed = true;
924
967
  return newItem;
925
968
  }),
926
969
  );
927
- return changed ? (result as T) : obj;
970
+ return changed ? result : obj;
928
971
  }
929
972
 
930
973
  if (typeof obj === "object") {
931
974
  let changed = false;
932
- const result: Record<string, unknown> = {};
933
- for (const [k, v] of Object.entries(obj as Record<string, unknown>)) {
934
- // Strip transient/redundant properties that shouldn't be persisted
935
- // - partialJson: streaming accumulator for tool call JSON parsing
936
- // - jsonlEvents: raw subprocess streaming events (already saved to artifact files)
937
- if (k === "partialJson" || k === "jsonlEvents") {
938
- changed = true;
939
- continue;
940
- }
941
- const newV = await truncateForPersistence(v, blobStore, k);
942
- result[k] = newV;
943
- if (newV !== v) changed = true;
944
- }
945
- // Update lineCount if content was truncated (for FileMentionFile)
946
- if (changed && "lineCount" in result && "content" in result && typeof result.content === "string") {
947
- result.lineCount = result.content.split("\n").length;
975
+ const entries: Array<readonly [string, unknown]> = await Promise.all(
976
+ Object.entries(obj).flatMap(([childKey, value]) => {
977
+ // Strip transient/redundant properties that shouldn't be persisted.
978
+ // - partialJson: streaming accumulator for tool call JSON parsing
979
+ // - jsonlEvents: raw subprocess streaming events (already saved to artifact files)
980
+ if (childKey === "partialJson" || childKey === "jsonlEvents") {
981
+ changed = true;
982
+ return [];
983
+ }
984
+
985
+ return [
986
+ (async () => {
987
+ const newValue = await truncateForPersistence(value, blobStore, childKey);
988
+ if (newValue !== value) changed = true;
989
+ return [childKey, newValue] as const;
990
+ })(),
991
+ ];
992
+ }),
993
+ );
994
+
995
+ if (!changed) return obj;
996
+
997
+ const contentEntry = entries.find(([childKey]) => childKey === "content");
998
+ const lineCountEntry = entries.find(([childKey]) => childKey === "lineCount");
999
+ if (
1000
+ contentEntry &&
1001
+ typeof contentEntry[1] === "string" &&
1002
+ lineCountEntry &&
1003
+ typeof lineCountEntry[1] === "number"
1004
+ ) {
1005
+ const content = contentEntry[1];
1006
+ const updatedEntries = entries.map(([childKey, value]) =>
1007
+ childKey === "lineCount" ? ([childKey, content.split("\n").length] as const) : ([childKey, value] as const),
1008
+ );
1009
+ return Object.fromEntries(updatedEntries);
948
1010
  }
949
- return changed ? (result as T) : obj;
1011
+ return Object.fromEntries(entries);
950
1012
  }
951
1013
 
952
1014
  return obj;
@@ -1387,6 +1449,7 @@ export class SessionManager {
1387
1449
  if (resolvedCwd === this.cwd) return;
1388
1450
 
1389
1451
  const newSessionDir = getDefaultSessionDir(resolvedCwd, this.storage);
1452
+ let hadSessionFile = false;
1390
1453
 
1391
1454
  if (this.persist && this.#sessionFile) {
1392
1455
  // Close the persist writer before moving files
@@ -1399,12 +1462,16 @@ export class SessionManager {
1399
1462
  const newSessionFile = path.join(newSessionDir, path.basename(oldSessionFile));
1400
1463
  const oldArtifactDir = oldSessionFile.slice(0, -6); // strip .jsonl
1401
1464
  const newArtifactDir = newSessionFile.slice(0, -6);
1465
+ hadSessionFile = this.storage.existsSync(oldSessionFile);
1402
1466
  let movedSessionFile = false;
1403
1467
  let movedArtifactDir = false;
1404
1468
 
1405
1469
  try {
1406
- await fs.promises.rename(oldSessionFile, newSessionFile);
1407
- movedSessionFile = true;
1470
+ // Guard: session file may not exist yet (no assistant messages persisted)
1471
+ if (hadSessionFile) {
1472
+ await fs.promises.rename(oldSessionFile, newSessionFile);
1473
+ movedSessionFile = true;
1474
+ }
1408
1475
 
1409
1476
  try {
1410
1477
  const stat = await fs.promises.stat(oldArtifactDir);
@@ -1449,8 +1516,12 @@ export class SessionManager {
1449
1516
  header.cwd = resolvedCwd;
1450
1517
  }
1451
1518
 
1452
- // Rewrite the session file at its new location with updated header
1453
- if (this.persist && this.#sessionFile) {
1519
+ // Rewrite the session file at its new location with updated header.
1520
+ // hadSessionFile: file existed before move → must rewrite to update cwd
1521
+ // hasAssistant: assistant messages in memory but file missing → recreate from memory
1522
+ // Neither true → fresh session, never written → preserve lazy-persist
1523
+ const hasAssistant = this.#fileEntries.some(e => e.type === "message" && e.message.role === "assistant");
1524
+ if (this.persist && this.#sessionFile && (hadSessionFile || hasAssistant)) {
1454
1525
  await this.#rewriteFile();
1455
1526
  }
1456
1527
 
@@ -1633,7 +1704,6 @@ export class SessionManager {
1633
1704
 
1634
1705
  /** Flush pending writes to disk. Call before switching sessions or on shutdown. */
1635
1706
  async flush(): Promise<void> {
1636
- if (!this.#persistWriter) return;
1637
1707
  await this.#queuePersistTask(async () => {
1638
1708
  if (this.#persistWriter) {
1639
1709
  await this.#persistWriter.flush();
@@ -1643,6 +1713,16 @@ export class SessionManager {
1643
1713
  if (this.#persistError) throw this.#persistError;
1644
1714
  }
1645
1715
 
1716
+ /** Close the persistent writer after flushing all pending data. */
1717
+ async close(): Promise<void> {
1718
+ if (!this.#persistWriter) return;
1719
+ await this.#queuePersistTask(async () => {
1720
+ await this.#closePersistWriterInternal();
1721
+ this.#flushed = true;
1722
+ });
1723
+ if (this.#persistError) throw this.#persistError;
1724
+ }
1725
+
1646
1726
  getCwd(): string {
1647
1727
  return this.cwd;
1648
1728
  }
@@ -465,6 +465,17 @@ const BUILTIN_SLASH_COMMAND_REGISTRY: ReadonlyArray<BuiltinSlashCommandSpec> = [
465
465
  runtime.ctx.editor.setText("");
466
466
  },
467
467
  },
468
+ {
469
+ name: "btw",
470
+ description: "Ask an ephemeral side question using the current session context",
471
+ inlineHint: "<question>",
472
+ allowArgs: true,
473
+ handle: async (command, runtime) => {
474
+ const question = command.text.slice(`/${command.name}`.length).trim();
475
+ runtime.ctx.editor.setText("");
476
+ await runtime.ctx.handleBtwCommand(question);
477
+ },
478
+ },
468
479
  {
469
480
  name: "background",
470
481
  aliases: ["bg"],
@@ -447,22 +447,9 @@ export async function buildSystemPrompt(options: BuildSystemPromptOptions = {}):
447
447
  skills = prepResult.value.skills;
448
448
  }
449
449
 
450
- const now = new Date();
451
- const date = now.toLocaleDateString("en-CA", {
452
- year: "numeric",
453
- month: "2-digit",
454
- day: "2-digit",
455
- });
456
- const dateTime = now.toLocaleString("en-US", {
457
- weekday: "long",
458
- year: "numeric",
459
- month: "long",
460
- day: "numeric",
461
- hour: "2-digit",
462
- minute: "2-digit",
463
- second: "2-digit",
464
- timeZoneName: "short",
465
- });
450
+ const date = new Date().toISOString().slice(0, 10);
451
+ const dateTime = date;
452
+ const promptCwd = resolvedCwd.replace(/\\/g, "/");
466
453
 
467
454
  // Build tool metadata for system prompt rendering
468
455
  // Priority: explicit list > tools map > defaults
@@ -504,7 +491,7 @@ export async function buildSystemPrompt(options: BuildSystemPromptOptions = {}):
504
491
  rules: rules ?? [],
505
492
  date,
506
493
  dateTime,
507
- cwd: resolvedCwd,
494
+ cwd: promptCwd,
508
495
  intentTracing: !!intentField,
509
496
  intentField: intentField ?? "",
510
497
  eagerTasks,
@@ -53,7 +53,7 @@ const EMBEDDED_AGENT_DEFS: EmbeddedAgentDef[] = [
53
53
  name: "task",
54
54
  description: "General-purpose subagent with full capabilities for delegated multi-step tasks",
55
55
  spawns: "*",
56
- model: "default",
56
+ model: "pi/task",
57
57
  thinkingLevel: Effort.Medium,
58
58
  },
59
59
  template: taskMd,
package/src/task/index.ts CHANGED
@@ -20,7 +20,7 @@ import type { Usage } from "@oh-my-pi/pi-ai";
20
20
  import { $env, Snowflake } from "@oh-my-pi/pi-utils";
21
21
  import { $ } from "bun";
22
22
  import type { ToolSession } from "..";
23
- import { isDefaultModelAlias } from "../config/model-resolver";
23
+ import { resolveAgentModelPatterns } from "../config/model-resolver";
24
24
  import { renderPromptTemplate } from "../config/prompt-templates";
25
25
  import type { Theme } from "../modes/theme/theme";
26
26
  import planModeSubagentPrompt from "../prompts/system/plan-mode-subagent.md" with { type: "text" };
@@ -507,14 +507,15 @@ export class TaskTool implements AgentTool<TaskSchema, TaskToolDetails, Theme> {
507
507
  : agent;
508
508
 
509
509
  // Apply per-agent model override from settings (highest priority)
510
- const agentModelOverrides = this.session.settings.get("task.agentModelOverrides") as Record<string, string>;
510
+ const agentModelOverrides = this.session.settings.get("task.agentModelOverrides");
511
511
  const settingsModelOverride = agentModelOverrides[agentName];
512
- const effectiveAgentModel = isDefaultModelAlias(effectiveAgent.model) ? undefined : effectiveAgent.model;
513
- const modelOverride =
514
- settingsModelOverride ??
515
- effectiveAgentModel ??
516
- this.session.getActiveModelString?.() ??
517
- this.session.getModelString?.();
512
+ const modelOverride = resolveAgentModelPatterns({
513
+ settingsOverride: settingsModelOverride,
514
+ agentModel: effectiveAgent.model,
515
+ settings: this.session.settings,
516
+ activeModelPattern: this.session.getActiveModelString?.(),
517
+ fallbackModelPattern: this.session.getModelString?.(),
518
+ });
518
519
  const thinkingLevelOverride = effectiveAgent.thinkingLevel;
519
520
 
520
521
  // Output schema priority: agent frontmatter > params > inherited from parent session
@@ -526,6 +526,17 @@ export class BrowserTool implements AgentTool<typeof browserSchema, BrowserToolD
526
526
  const proxy = process.env.PUPPETEER_PROXY;
527
527
  if (proxy) {
528
528
  launchArgs.push(`--proxy-server=${proxy}`);
529
+ // Chrome (since v72) bypasses proxies for localhost by default. When PUPPETEER_PROXY_BYPASS_LOOPBACK
530
+ // is true, add <-loopback> so traffic to localhost reaches the proxy (e.g. for mitmdump/auth capture).
531
+ const bypassLoopback = process.env.PUPPETEER_PROXY_BYPASS_LOOPBACK?.toLowerCase();
532
+ if (
533
+ bypassLoopback === "true" ||
534
+ bypassLoopback === "1" ||
535
+ bypassLoopback === "yes" ||
536
+ bypassLoopback === "on"
537
+ ) {
538
+ launchArgs.push("--proxy-bypass-list=<-loopback>");
539
+ }
529
540
  }
530
541
  const ignoreCert = process.env.PUPPETEER_PROXY_IGNORE_CERT_ERRORS?.toLowerCase();
531
542
  if (ignoreCert === "true" || ignoreCert === "1" || ignoreCert === "yes" || ignoreCert === "on") {