@oh-my-pi/pi-coding-agent 15.0.0 → 15.0.2

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 (165) hide show
  1. package/CHANGELOG.md +79 -0
  2. package/examples/extensions/plan-mode.ts +0 -1
  3. package/package.json +10 -10
  4. package/scripts/build-binary.ts +5 -0
  5. package/src/autoresearch/helpers.ts +17 -0
  6. package/src/autoresearch/tools/log-experiment.ts +9 -17
  7. package/src/autoresearch/tools/run-experiment.ts +2 -17
  8. package/src/capability/skill.ts +7 -0
  9. package/src/cli/list-models.ts +1 -1
  10. package/src/cli/shell-cli.ts +3 -13
  11. package/src/cli/update-cli.ts +1 -1
  12. package/src/cli.ts +10 -29
  13. package/src/commands/commit.ts +10 -0
  14. package/src/commit/agentic/tools/propose-changelog.ts +8 -1
  15. package/src/commit/analysis/conventional.ts +8 -66
  16. package/src/commit/map-reduce/reduce-phase.ts +6 -65
  17. package/src/commit/pipeline.ts +2 -2
  18. package/src/commit/shared-llm.ts +89 -0
  19. package/src/config/config-file.ts +210 -0
  20. package/src/config/model-equivalence.ts +8 -11
  21. package/src/config/model-registry.ts +44 -3
  22. package/src/config/model-resolver.ts +1 -4
  23. package/src/config/settings-schema.ts +82 -1
  24. package/src/config/settings.ts +1 -1
  25. package/src/config.ts +3 -219
  26. package/src/discovery/claude-plugins.ts +19 -7
  27. package/src/edit/renderer.ts +7 -1
  28. package/src/eval/js/executor.ts +3 -0
  29. package/src/eval/js/shared/rewrite-imports.ts +2 -2
  30. package/src/eval/py/executor.ts +5 -0
  31. package/src/eval/py/runner.py +42 -11
  32. package/src/eval/py/runtime.ts +1 -0
  33. package/src/exa/factory.ts +2 -2
  34. package/src/exa/mcp-client.ts +74 -1
  35. package/src/exec/bash-executor.ts +5 -1
  36. package/src/export/html/template.generated.ts +1 -1
  37. package/src/export/html/template.js +0 -11
  38. package/src/extensibility/extensions/get-commands-handler.ts +77 -0
  39. package/src/extensibility/extensions/runner.ts +1 -1
  40. package/src/extensibility/extensions/types.ts +89 -223
  41. package/src/extensibility/hooks/types.ts +89 -314
  42. package/src/extensibility/plugins/legacy-pi-compat.ts +48 -31
  43. package/src/extensibility/shared-events.ts +343 -0
  44. package/src/extensibility/skills.ts +9 -0
  45. package/src/goals/index.ts +3 -0
  46. package/src/goals/runtime.ts +500 -0
  47. package/src/goals/state.ts +37 -0
  48. package/src/goals/tools/goal-tool.ts +237 -0
  49. package/src/hashline/anchors.ts +2 -2
  50. package/src/hashline/input.ts +2 -1
  51. package/src/hashline/parser.ts +27 -3
  52. package/src/hindsight/mental-models.ts +1 -1
  53. package/src/internal-urls/agent-protocol.ts +1 -20
  54. package/src/internal-urls/artifact-protocol.ts +1 -19
  55. package/src/internal-urls/docs-index.generated.ts +11 -12
  56. package/src/internal-urls/registry-helpers.ts +25 -0
  57. package/src/internal-urls/router.ts +8 -0
  58. package/src/internal-urls/types.ts +21 -0
  59. package/src/lsp/config.ts +15 -6
  60. package/src/lsp/defaults.json +6 -2
  61. package/src/main.ts +11 -2
  62. package/src/mcp/oauth-flow.ts +20 -0
  63. package/src/modes/acp/acp-agent.ts +327 -95
  64. package/src/modes/components/assistant-message.ts +14 -8
  65. package/src/modes/components/bash-execution.ts +24 -63
  66. package/src/modes/components/custom-message.ts +14 -40
  67. package/src/modes/components/eval-execution.ts +27 -57
  68. package/src/modes/components/execution-shared.ts +102 -0
  69. package/src/modes/components/hook-message.ts +17 -49
  70. package/src/modes/components/mcp-add-wizard.ts +26 -5
  71. package/src/modes/components/message-frame.ts +88 -0
  72. package/src/modes/components/model-selector.ts +1 -1
  73. package/src/modes/components/session-observer-overlay.ts +6 -2
  74. package/src/modes/components/session-selector.ts +1 -1
  75. package/src/modes/components/status-line/segments.ts +93 -8
  76. package/src/modes/components/status-line/types.ts +4 -0
  77. package/src/modes/components/status-line.ts +28 -10
  78. package/src/modes/components/tool-execution.ts +7 -8
  79. package/src/modes/controllers/command-controller-shared.ts +108 -0
  80. package/src/modes/controllers/command-controller.ts +13 -4
  81. package/src/modes/controllers/event-controller.ts +36 -7
  82. package/src/modes/controllers/extension-ui-controller.ts +3 -2
  83. package/src/modes/controllers/input-controller.ts +13 -0
  84. package/src/modes/controllers/mcp-command-controller.ts +56 -61
  85. package/src/modes/controllers/ssh-command-controller.ts +18 -57
  86. package/src/modes/interactive-mode.ts +624 -52
  87. package/src/modes/print-mode.ts +16 -86
  88. package/src/modes/rpc/host-uris.ts +235 -0
  89. package/src/modes/rpc/rpc-mode.ts +41 -88
  90. package/src/modes/rpc/rpc-types.ts +57 -0
  91. package/src/modes/runtime-init.ts +116 -0
  92. package/src/modes/theme/defaults/dark-poimandres.json +3 -0
  93. package/src/modes/theme/defaults/light-poimandres.json +3 -0
  94. package/src/modes/theme/theme.ts +24 -6
  95. package/src/modes/types.ts +14 -3
  96. package/src/modes/utils/context-usage.ts +13 -13
  97. package/src/modes/utils/ui-helpers.ts +10 -3
  98. package/src/plan-mode/approved-plan.ts +35 -1
  99. package/src/prompts/goals/goal-budget-limit.md +16 -0
  100. package/src/prompts/goals/goal-continuation.md +28 -0
  101. package/src/prompts/goals/goal-mode-active.md +23 -0
  102. package/src/prompts/system/plan-mode-active.md +5 -5
  103. package/src/prompts/system/plan-mode-tool-decision-reminder.md +1 -1
  104. package/src/prompts/tools/bash.md +6 -0
  105. package/src/prompts/tools/github.md +4 -4
  106. package/src/prompts/tools/goal.md +13 -0
  107. package/src/prompts/tools/hashline.md +101 -117
  108. package/src/prompts/tools/read.md +55 -36
  109. package/src/prompts/tools/resolve.md +6 -5
  110. package/src/sdk.ts +12 -5
  111. package/src/session/agent-session.ts +428 -106
  112. package/src/session/blob-store.ts +36 -3
  113. package/src/session/messages.ts +67 -2
  114. package/src/session/session-manager.ts +131 -12
  115. package/src/session/session-storage.ts +33 -15
  116. package/src/session/streaming-output.ts +309 -13
  117. package/src/slash-commands/builtin-registry.ts +18 -0
  118. package/src/ssh/ssh-executor.ts +5 -0
  119. package/src/system-prompt.ts +4 -2
  120. package/src/task/discovery.ts +5 -2
  121. package/src/task/executor.ts +19 -8
  122. package/src/task/index.ts +3 -0
  123. package/src/task/render.ts +21 -15
  124. package/src/task/types.ts +4 -0
  125. package/src/tools/ast-edit.ts +21 -120
  126. package/src/tools/ast-grep.ts +21 -119
  127. package/src/tools/bash-command-fixup.ts +47 -0
  128. package/src/tools/bash-interactive.ts +9 -1
  129. package/src/tools/bash.ts +66 -19
  130. package/src/tools/browser/attach.ts +3 -3
  131. package/src/tools/browser/launch.ts +81 -18
  132. package/src/tools/browser/registry.ts +1 -5
  133. package/src/tools/browser/render.ts +2 -2
  134. package/src/tools/browser/tab-supervisor.ts +51 -14
  135. package/src/tools/conflict-detect.ts +15 -4
  136. package/src/tools/eval.ts +12 -2
  137. package/src/tools/find.ts +20 -38
  138. package/src/tools/gh.ts +44 -10
  139. package/src/tools/index.ts +22 -11
  140. package/src/tools/inspect-image.ts +3 -10
  141. package/src/tools/job.ts +16 -7
  142. package/src/tools/output-meta.ts +202 -37
  143. package/src/tools/path-utils.ts +125 -2
  144. package/src/tools/read.ts +548 -237
  145. package/src/tools/render-utils.ts +92 -0
  146. package/src/tools/renderers.ts +2 -0
  147. package/src/tools/resolve.ts +72 -44
  148. package/src/tools/search.ts +120 -186
  149. package/src/tools/ssh.ts +3 -2
  150. package/src/tools/write.ts +64 -9
  151. package/src/utils/file-mentions.ts +1 -1
  152. package/src/utils/image-loading.ts +7 -3
  153. package/src/utils/image-resize.ts +32 -43
  154. package/src/vim/parser.ts +0 -17
  155. package/src/vim/render.ts +1 -1
  156. package/src/vim/types.ts +1 -1
  157. package/src/web/search/providers/anthropic.ts +5 -0
  158. package/src/web/search/providers/exa.ts +3 -0
  159. package/src/web/search/providers/gemini.ts +40 -95
  160. package/src/web/search/providers/jina.ts +5 -2
  161. package/src/web/search/providers/zai.ts +5 -2
  162. package/src/prompts/tools/exit-plan-mode.md +0 -6
  163. package/src/tools/exit-plan-mode.ts +0 -97
  164. package/src/utils/fuzzy.ts +0 -108
  165. package/src/utils/image-convert.ts +0 -27
@@ -1,4 +1,5 @@
1
- import * as fs from "node:fs/promises";
1
+ import * as fs from "node:fs";
2
+ import * as fsp from "node:fs/promises";
2
3
  import * as path from "node:path";
3
4
  import { isEnoent, logger } from "@oh-my-pi/pi-utils";
4
5
 
@@ -25,7 +26,7 @@ export class BlobStore {
25
26
  * @returns SHA-256 hex hash of the data
26
27
  */
27
28
  async put(data: Buffer): Promise<BlobPutResult> {
28
- const hash = new Bun.CryptoHasher("sha256").update(data).digest("hex");
29
+ const hash = new Bun.SHA256().update(data).digest("hex");
29
30
  const blobPath = path.join(this.dir, hash);
30
31
  const result = {
31
32
  hash,
@@ -39,6 +40,26 @@ export class BlobStore {
39
40
  return result;
40
41
  }
41
42
 
43
+ /**
44
+ * Synchronous variant of {@link put}. Use on persistence hot paths where the caller
45
+ * cannot afford the microtask hops of the async version (e.g. OOM-safe session writes).
46
+ * Returns once the bytes are in the kernel page cache.
47
+ */
48
+ putSync(data: Buffer): BlobPutResult {
49
+ const hash = new Bun.SHA256().update(data).digest("hex");
50
+ const blobPath = path.join(this.dir, hash);
51
+ const result = {
52
+ hash,
53
+ path: blobPath,
54
+ get ref() {
55
+ return `${BLOB_PREFIX}${hash}`;
56
+ },
57
+ };
58
+ fs.mkdirSync(this.dir, { recursive: true });
59
+ fs.writeFileSync(blobPath, data);
60
+ return result;
61
+ }
62
+
42
63
  /** Read blob by hash, returns Buffer or null if not found. */
43
64
  async get(hash: string): Promise<Buffer | null> {
44
65
  const blobPath = path.join(this.dir, hash);
@@ -55,7 +76,7 @@ export class BlobStore {
55
76
  /** Check if a blob exists. */
56
77
  async has(hash: string): Promise<boolean> {
57
78
  try {
58
- await fs.access(path.join(this.dir, hash));
79
+ await fsp.access(path.join(this.dir, hash));
59
80
  return true;
60
81
  } catch {
61
82
  return false;
@@ -89,6 +110,12 @@ export async function externalizeImageDataUrl(blobStore: BlobStore, dataUrl: str
89
110
  return ref;
90
111
  }
91
112
 
113
+ /** Synchronous variant of {@link externalizeImageDataUrl}. */
114
+ export function externalizeImageDataUrlSync(blobStore: BlobStore, dataUrl: string): string {
115
+ if (isBlobRef(dataUrl)) return dataUrl;
116
+ return blobStore.putSync(Buffer.from(dataUrl, "utf8")).ref;
117
+ }
118
+
92
119
  /**
93
120
  * Externalize an image's base64 data to the blob store, returning a blob reference.
94
121
  * If the data is already a blob reference, returns it unchanged.
@@ -100,6 +127,12 @@ export async function externalizeImageData(blobStore: BlobStore, base64Data: str
100
127
  return ref;
101
128
  }
102
129
 
130
+ /** Synchronous variant of {@link externalizeImageData}. */
131
+ export function externalizeImageDataSync(blobStore: BlobStore, base64Data: string): string {
132
+ if (isBlobRef(base64Data)) return base64Data;
133
+ return blobStore.putSync(Buffer.from(base64Data, "base64")).ref;
134
+ }
135
+
103
136
  /**
104
137
  * Resolve an externalized provider image data URL back to its original string.
105
138
  * If the data is not a blob reference, returns it unchanged.
@@ -30,6 +30,72 @@ export interface SkillPromptDetails {
30
30
  path: string;
31
31
  args?: string;
32
32
  lineCount: number;
33
+ /** Internal: tag used by AgentSession to remove the pending-display chip
34
+ * from `#steeringMessages` / `#followUpMessages` when the agent consumes
35
+ * this message. Not surfaced to renderers; the `__` prefix signals
36
+ * "private". Optional — non-streaming skill prompts never set it. Stripped
37
+ * from persisted `details` by `SessionManager.appendCustomMessageEntry`
38
+ * via the `INTERNAL_DETAILS_FIELDS` allowlist below. */
39
+ __pendingDisplayTag?: string;
40
+ }
41
+
42
+ /** Sentinel value for `AssistantMessage.errorMessage` indicating that the abort
43
+ * was an *expected internal transition* (plan-mode → execution compaction)
44
+ * and must NOT surface as a red "Operation aborted" line. Distinct from
45
+ * `undefined` (default) so user-cancel aborts with no errorMessage still
46
+ * render normally. Persists through SessionManager so history replay
47
+ * branches identically.
48
+ *
49
+ * Consumers: `AgentSession.#handleAgentEvent` (stamper) writes this value;
50
+ * `EventController.#handleMessageEnd`, `AssistantMessageComponent`,
51
+ * `ui-helpers.addMessageToChat` (renderers), `SessionObserverOverlay
52
+ * #buildTranscriptLines`, `runPrintMode`, and `AcpAgent#replayAssistantMessage`
53
+ * (fallback error emission) read it via `isSilentAbort`. */
54
+ export const SILENT_ABORT_MARKER = "__omp.silent_abort__";
55
+
56
+ /** Type-guard for `SILENT_ABORT_MARKER`. Renderers MUST branch on this rather
57
+ * than string-comparing inline so refactors to the marker constant (e.g.,
58
+ * namespacing changes) propagate through every consumer in lockstep. */
59
+ export function isSilentAbort(errorMessage: string | undefined): boolean {
60
+ return errorMessage === SILENT_ABORT_MARKER;
61
+ }
62
+
63
+ /** Extract the optional `__pendingDisplayTag` field from a CustomMessage's
64
+ * `details` blob. Safe over `unknown`; returns undefined when the field is
65
+ * absent or non-string. */
66
+ export function readPendingDisplayTag(details: unknown): string | undefined {
67
+ if (typeof details !== "object" || details === null) return undefined;
68
+ const candidate = (details as { __pendingDisplayTag?: unknown }).__pendingDisplayTag;
69
+ return typeof candidate === "string" ? candidate : undefined;
70
+ }
71
+
72
+ /** Explicit allowlist of `details` field names that are AgentSession-internal
73
+ * transient bookkeeping and MUST be removed before SessionManager persists
74
+ * the CustomMessageEntry to disk. Scoped intentionally narrow: only fields
75
+ * declared here are stripped. Adding a new entry is a deliberate, reviewed
76
+ * change — unrelated future payload fields are never silently dropped. */
77
+ export const INTERNAL_DETAILS_FIELDS = ["__pendingDisplayTag"] as const;
78
+
79
+ /** Return a `details` copy with every key in `INTERNAL_DETAILS_FIELDS`
80
+ * removed. Returns the input unchanged when there is nothing to strip
81
+ * (null/non-object, or no listed fields present) so callers don't pay a
82
+ * clone cost on the common path. */
83
+ export function stripInternalDetailsFields<T>(details: T | undefined): T | undefined {
84
+ if (details == null || typeof details !== "object") return details;
85
+ const obj = details as Record<string, unknown>;
86
+ let hit = false;
87
+ for (const key of INTERNAL_DETAILS_FIELDS) {
88
+ if (key in obj) {
89
+ hit = true;
90
+ break;
91
+ }
92
+ }
93
+ if (!hit) return details;
94
+ const cleaned: Record<string, unknown> = { ...obj };
95
+ for (const key of INTERNAL_DETAILS_FIELDS) {
96
+ delete cleaned[key];
97
+ }
98
+ return cleaned as T;
33
99
  }
34
100
 
35
101
  function getPrunedToolResultContent(message: ToolResultMessage): (TextContent | ImageContent)[] {
@@ -364,8 +430,7 @@ export function convertToLlm(messages: AgentMessage[]): Message[] {
364
430
  attribution: m.attribution ?? "agent",
365
431
  };
366
432
  default:
367
- // biome-ignore lint/correctness/noSwitchDeclarations: fine
368
- const _exhaustiveCheck: never = m;
433
+ m satisfies never;
369
434
  return undefined;
370
435
  }
371
436
  })
@@ -31,7 +31,9 @@ import {
31
31
  type BlobPutResult,
32
32
  BlobStore,
33
33
  externalizeImageData,
34
+ externalizeImageDataSync,
34
35
  externalizeImageDataUrl,
36
+ externalizeImageDataUrlSync,
35
37
  isBlobRef,
36
38
  isImageDataUrl,
37
39
  resolveImageData,
@@ -47,6 +49,7 @@ import {
47
49
  type HookMessage,
48
50
  type PythonExecutionMessage,
49
51
  sanitizeRehydratedOpenAIResponsesAssistantMessage,
52
+ stripInternalDetailsFields,
50
53
  } from "./messages";
51
54
  import type { SessionStorage, SessionStorageWriter } from "./session-storage";
52
55
  import { FileSessionStorage, MemorySessionStorage } from "./session-storage";
@@ -1127,6 +1130,92 @@ async function prepareEntryForPersistence(entry: FileEntry, blobStore: BlobStore
1127
1130
  return truncateForPersistence(entry, blobStore);
1128
1131
  }
1129
1132
 
1133
+ /**
1134
+ * Synchronous variant of {@link truncateForPersistence}.
1135
+ *
1136
+ * The async version's overhead — `Promise.all` over `Object.entries`/`Array.prototype.map`,
1137
+ * one microtask hop per nested node — is pure waste for entries without image blobs
1138
+ * (the vast majority). The fast path runs in one synchronous tick so an OOM/SIGKILL
1139
+ * landing right after `_persist` returns cannot lose the entry. Image externalization
1140
+ * still happens, but via the synchronous blob-store path (`fs.writeFileSync`), so the
1141
+ * blob bytes are in the kernel page cache before the JSONL line referencing them is
1142
+ * written.
1143
+ */
1144
+ function truncateForPersistenceSync(obj: unknown, blobStore: BlobStore, key?: string): unknown {
1145
+ if (obj === null || obj === undefined) return obj;
1146
+
1147
+ if (typeof obj === "string") {
1148
+ if (key === "image_url" && isImageDataUrl(obj)) {
1149
+ return externalizeImageDataUrlSync(blobStore, obj);
1150
+ }
1151
+ if (obj.length > MAX_PERSIST_CHARS) {
1152
+ if (key === "thinkingSignature" || key === "thoughtSignature" || key === "textSignature") {
1153
+ return "";
1154
+ }
1155
+ const limit = Math.max(0, MAX_PERSIST_CHARS - TRUNCATION_NOTICE.length);
1156
+ return `${truncateString(obj, limit)}${TRUNCATION_NOTICE}`;
1157
+ }
1158
+ return obj;
1159
+ }
1160
+
1161
+ if (Array.isArray(obj)) {
1162
+ let changed = false;
1163
+ const result: unknown[] = new Array(obj.length);
1164
+ for (let i = 0; i < obj.length; i++) {
1165
+ const item = obj[i];
1166
+ if (key === TEXT_CONTENT_KEY && isImageBlock(item)) {
1167
+ if (!isBlobRef(item.data) && item.data.length >= BLOB_EXTERNALIZE_THRESHOLD) {
1168
+ changed = true;
1169
+ const blobRef = externalizeImageDataSync(blobStore, item.data);
1170
+ result[i] = { ...item, data: blobRef };
1171
+ continue;
1172
+ }
1173
+ }
1174
+ const newItem = truncateForPersistenceSync(item, blobStore, key);
1175
+ if (newItem !== item) changed = true;
1176
+ result[i] = newItem;
1177
+ }
1178
+ return changed ? result : obj;
1179
+ }
1180
+
1181
+ if (typeof obj === "object") {
1182
+ let changed = false;
1183
+ const entries: Array<readonly [string, unknown]> = [];
1184
+ for (const [childKey, value] of Object.entries(obj)) {
1185
+ if (childKey === "partialJson" || childKey === "jsonlEvents") {
1186
+ changed = true;
1187
+ continue;
1188
+ }
1189
+ const newValue = truncateForPersistenceSync(value, blobStore, childKey);
1190
+ if (newValue !== value) changed = true;
1191
+ entries.push([childKey, newValue]);
1192
+ }
1193
+ if (!changed) return obj;
1194
+
1195
+ const contentEntry = entries.find(([childKey]) => childKey === "content");
1196
+ const lineCountEntry = entries.find(([childKey]) => childKey === "lineCount");
1197
+ if (
1198
+ contentEntry &&
1199
+ typeof contentEntry[1] === "string" &&
1200
+ lineCountEntry &&
1201
+ typeof lineCountEntry[1] === "number"
1202
+ ) {
1203
+ const content = contentEntry[1];
1204
+ const updatedEntries = entries.map(([childKey, value]) =>
1205
+ childKey === "lineCount" ? ([childKey, content.split("\n").length] as const) : ([childKey, value] as const),
1206
+ );
1207
+ return Object.fromEntries(updatedEntries);
1208
+ }
1209
+ return Object.fromEntries(entries);
1210
+ }
1211
+
1212
+ return obj;
1213
+ }
1214
+
1215
+ function prepareEntryForPersistenceSync(entry: FileEntry, blobStore: BlobStore): FileEntry {
1216
+ return truncateForPersistenceSync(entry, blobStore) as FileEntry;
1217
+ }
1218
+
1130
1219
  class NdjsonFileWriter {
1131
1220
  #writer: SessionStorageWriter;
1132
1221
  #closed = false;
@@ -1180,6 +1269,26 @@ class NdjsonFileWriter {
1180
1269
  return this.#enqueue(() => this.#writeLine(line));
1181
1270
  }
1182
1271
 
1272
+ /**
1273
+ * Synchronously serialize and append the entry. Returns once `fs.writeSync` has handed
1274
+ * the bytes to the kernel page cache — durable across OOM/SIGKILL even before fsync.
1275
+ *
1276
+ * Callers MUST NOT mix this with pending async `write()` calls on the same writer:
1277
+ * the async path is queued through `#pendingWrites`, but this method bypasses the
1278
+ * queue. Use only when no concurrent async write is in flight (the session-manager
1279
+ * persist path enforces this via `#flushed`/`#needsFullRewriteOnNextPersist`).
1280
+ */
1281
+ writeSync(entry: FileEntry): void {
1282
+ if (this.#closed || this.#closing) throw new Error("Writer closed");
1283
+ if (this.#error) throw this.#error;
1284
+ const line = `${JSON.stringify(entry)}\n`;
1285
+ try {
1286
+ this.#writer.writeLineSync(line);
1287
+ } catch (err) {
1288
+ throw this.#recordError(err);
1289
+ }
1290
+ }
1291
+
1183
1292
  /** Flush all buffered data to disk. Waits for all queued writes. */
1184
1293
  async flush(): Promise<void> {
1185
1294
  if (this.#closed) return;
@@ -2332,20 +2441,27 @@ export class SessionManager {
2332
2441
  }
2333
2442
 
2334
2443
  if (this.#needsFullRewriteOnNextPersist || !this.#flushed) {
2335
- // Full flush: rewrite the entire file atomically to avoid
2336
- // duplicating entries if the file already exists (e.g. from ensureOnDisk).
2337
- // Errors are already surfaced through #persistChain/#persistError; the
2338
- // caller intentionally fires-and-forgets, so swallow the awaited rejection
2444
+ // Cold path: rewrite the whole file atomically. Async — the writer is
2445
+ // closed/reopened and every entry is re-prepared. Errors flow through
2446
+ // `#persistChain` `#recordPersistError`; we swallow the rejection
2339
2447
  // here to avoid an unhandled rejection when the persist dir races with
2340
2448
  // test-level tempDir cleanup.
2341
2449
  this.#rewriteFile().catch(() => {});
2342
- } else {
2343
- this.#queuePersistTask(async () => {
2344
- const writer = this.#ensurePersistWriter();
2345
- if (!writer) return;
2346
- const persistedEntry = await prepareEntryForPersistence(entry, this.#blobStore);
2347
- await writer.write(persistedEntry);
2348
- }).catch(() => {});
2450
+ return;
2451
+ }
2452
+
2453
+ // Hot path: synchronously truncate + append. `fs.writeSync` returns once the
2454
+ // bytes are in the kernel page cache, so the entry survives an OOM/SIGKILL
2455
+ // landing immediately after this call. Image externalization (rare) runs via
2456
+ // the synchronous blob-store path so blob bytes are durable before the JSONL
2457
+ // line referencing them is written.
2458
+ try {
2459
+ const writer = this.#ensurePersistWriter();
2460
+ if (!writer) return;
2461
+ const persistedEntry = prepareEntryForPersistenceSync(entry, this.#blobStore);
2462
+ writer.writeSync(persistedEntry);
2463
+ } catch (err) {
2464
+ this.#recordPersistError(err);
2349
2465
  }
2350
2466
  }
2351
2467
 
@@ -2544,7 +2660,10 @@ export class SessionManager {
2544
2660
  customType,
2545
2661
  content,
2546
2662
  display,
2547
- details,
2663
+ // Drop AgentSession-internal transient fields (allowlist in
2664
+ // `INTERNAL_DETAILS_FIELDS`) before disk persistence. Single
2665
+ // chokepoint covers every CustomMessage write path.
2666
+ details: stripInternalDetailsFields(details),
2548
2667
  attribution,
2549
2668
  id: generateId(this.#byId),
2550
2669
  parentId: this.#leafId,
@@ -13,6 +13,14 @@ export interface SessionStorageStat {
13
13
 
14
14
  export interface SessionStorageWriter {
15
15
  writeLine(line: string): Promise<void>;
16
+ /**
17
+ * Synchronously append a single line. Returns once the bytes are handed to the kernel
18
+ * (page cache), so the data survives a non-graceful process death (OOM, SIGKILL, etc.)
19
+ * even though it has not yet been fsynced to the underlying disk.
20
+ *
21
+ * `line` MUST already include the trailing newline. Throws synchronously on I/O error.
22
+ */
23
+ writeLineSync(line: string): void;
16
24
  flush(): Promise<void>;
17
25
  fsync(): Promise<void>;
18
26
  close(): Promise<void>;
@@ -23,6 +31,7 @@ export interface SessionStorage {
23
31
  ensureDirSync(dir: string): void;
24
32
  existsSync(path: string): boolean;
25
33
  writeTextSync(path: string, content: string): void;
34
+ readTextSync(path: string): string;
26
35
  statSync(path: string): SessionStorageStat;
27
36
  listFilesSync(dir: string, pattern: string): string[];
28
37
 
@@ -72,7 +81,7 @@ class FileSessionStorageWriter implements SessionStorageWriter {
72
81
  return error;
73
82
  }
74
83
 
75
- async writeLine(line: string): Promise<void> {
84
+ writeLineSync(line: string): void {
76
85
  if (this.#closed) throw new Error("Writer closed");
77
86
  if (this.#error) throw this.#error;
78
87
  try {
@@ -90,6 +99,10 @@ class FileSessionStorageWriter implements SessionStorageWriter {
90
99
  }
91
100
  }
92
101
 
102
+ async writeLine(line: string): Promise<void> {
103
+ this.writeLineSync(line);
104
+ }
105
+
93
106
  async flush(): Promise<void> {
94
107
  if (this.#error) throw this.#error;
95
108
  // OS buffers are flushed on fsync, nothing to do here
@@ -138,6 +151,10 @@ export class FileSessionStorage implements SessionStorage {
138
151
  fs.writeFileSync(fpath, content);
139
152
  }
140
153
 
154
+ readTextSync(fpath: string): string {
155
+ return fs.readFileSync(fpath, "utf-8");
156
+ }
157
+
141
158
  statSync(path: string): SessionStorageStat {
142
159
  const stats = fs.statSync(path);
143
160
  return { size: stats.size, mtimeMs: stats.mtimeMs, mtime: stats.mtime };
@@ -230,7 +247,6 @@ class MemorySessionStorageWriter implements SessionStorageWriter {
230
247
  #closed = false;
231
248
  #error: Error | undefined;
232
249
  #onError: ((err: Error) => void) | undefined;
233
- #ready: Promise<void>;
234
250
 
235
251
  constructor(
236
252
  storage: MemorySessionStorage,
@@ -240,12 +256,8 @@ class MemorySessionStorageWriter implements SessionStorageWriter {
240
256
  this.#storage = storage;
241
257
  this.#path = path;
242
258
  this.#onError = options?.onError;
243
- this.#ready = this.#initialize(options?.flags ?? "a");
244
- }
245
-
246
- async #initialize(flags: "a" | "w"): Promise<void> {
247
- if (flags === "w") {
248
- await this.#storage.writeText(this.#path, "");
259
+ if ((options?.flags ?? "a") === "w") {
260
+ this.#storage.writeTextSync(path, "");
249
261
  }
250
262
  }
251
263
 
@@ -256,32 +268,32 @@ class MemorySessionStorageWriter implements SessionStorageWriter {
256
268
  return error;
257
269
  }
258
270
 
259
- async writeLine(line: string): Promise<void> {
271
+ writeLineSync(line: string): void {
260
272
  if (this.#closed) throw new Error("Writer closed");
261
- await this.#ready;
262
273
  if (this.#error) throw this.#error;
263
274
  try {
264
- const existing = this.#storage.existsSync(this.#path) ? await this.#storage.readText(this.#path) : "";
265
- await this.#storage.writeText(this.#path, `${existing}${line}`);
275
+ const existing = this.#storage.existsSync(this.#path) ? this.#storage.readTextSync(this.#path) : "";
276
+ this.#storage.writeTextSync(this.#path, `${existing}${line}`);
266
277
  } catch (err) {
267
278
  throw this.#recordError(err);
268
279
  }
269
280
  }
270
281
 
282
+ async writeLine(line: string): Promise<void> {
283
+ this.writeLineSync(line);
284
+ }
285
+
271
286
  async flush(): Promise<void> {
272
- await this.#ready;
273
287
  if (this.#error) throw this.#error;
274
288
  }
275
289
 
276
290
  async fsync(): Promise<void> {
277
291
  // No-op for in-memory storage
278
- await this.#ready;
279
292
  if (this.#error) throw this.#error;
280
293
  }
281
294
 
282
295
  async close(): Promise<void> {
283
296
  if (this.#closed) return;
284
- await this.#ready;
285
297
  this.#closed = true;
286
298
  }
287
299
 
@@ -305,6 +317,12 @@ export class MemorySessionStorage implements SessionStorage {
305
317
  this.#files.set(path, { content, mtimeMs: Date.now() });
306
318
  }
307
319
 
320
+ readTextSync(path: string): string {
321
+ const entry = this.#files.get(path);
322
+ if (!entry) throw new Error(`File not found: ${path}`);
323
+ return entry.content;
324
+ }
325
+
308
326
  statSync(path: string): SessionStorageStat {
309
327
  const entry = this.#files.get(path);
310
328
  if (!entry) throw new Error(`File not found: ${path}`);