@gajae-code/coding-agent 0.3.1 → 0.4.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 (166) hide show
  1. package/CHANGELOG.md +46 -0
  2. package/README.md +1 -1
  3. package/dist/types/cli/args.d.ts +2 -0
  4. package/dist/types/commands/launch.d.ts +6 -0
  5. package/dist/types/config/model-profile-activation.d.ts +30 -0
  6. package/dist/types/config/model-profiles.d.ts +19 -0
  7. package/dist/types/config/model-registry.d.ts +25 -10
  8. package/dist/types/config/model-resolver.d.ts +1 -1
  9. package/dist/types/config/models-config-schema.d.ts +84 -0
  10. package/dist/types/config/settings-schema.d.ts +15 -0
  11. package/dist/types/edit/diff.d.ts +16 -0
  12. package/dist/types/edit/modes/replace.d.ts +7 -0
  13. package/dist/types/extensibility/gjc-plugins/activation.d.ts +14 -0
  14. package/dist/types/extensibility/gjc-plugins/index.d.ts +9 -0
  15. package/dist/types/extensibility/gjc-plugins/injection.d.ts +31 -0
  16. package/dist/types/extensibility/gjc-plugins/loader.d.ts +3 -0
  17. package/dist/types/extensibility/gjc-plugins/paths.d.ts +8 -0
  18. package/dist/types/extensibility/gjc-plugins/schema.d.ts +3 -0
  19. package/dist/types/extensibility/gjc-plugins/state.d.ts +9 -0
  20. package/dist/types/extensibility/gjc-plugins/tools.d.ts +8 -0
  21. package/dist/types/extensibility/gjc-plugins/types.d.ts +64 -0
  22. package/dist/types/extensibility/gjc-plugins/validation.d.ts +4 -0
  23. package/dist/types/extensibility/skills.d.ts +9 -1
  24. package/dist/types/gjc-runtime/state-runtime.d.ts +22 -0
  25. package/dist/types/harness-control-plane/storage.d.ts +7 -0
  26. package/dist/types/lsp/client.d.ts +1 -0
  27. package/dist/types/main.d.ts +10 -1
  28. package/dist/types/modes/bridge/bridge-mode.d.ts +2 -0
  29. package/dist/types/modes/components/custom-provider-wizard.d.ts +10 -0
  30. package/dist/types/modes/components/model-selector.d.ts +6 -1
  31. package/dist/types/modes/components/provider-onboarding-selector.d.ts +1 -1
  32. package/dist/types/modes/controllers/selector-controller.d.ts +1 -0
  33. package/dist/types/modes/prompt-action-autocomplete.d.ts +2 -2
  34. package/dist/types/modes/rpc/rpc-client.d.ts +9 -1
  35. package/dist/types/modes/rpc/rpc-types.d.ts +179 -2
  36. package/dist/types/modes/shared/agent-wire/approval-gate.d.ts +57 -0
  37. package/dist/types/modes/shared/agent-wire/command-dispatch.d.ts +16 -1
  38. package/dist/types/modes/shared/agent-wire/deep-interview-gate.d.ts +47 -0
  39. package/dist/types/modes/shared/agent-wire/event-envelope.d.ts +7 -0
  40. package/dist/types/modes/shared/agent-wire/handshake.d.ts +11 -1
  41. package/dist/types/modes/shared/agent-wire/protocol.d.ts +3 -1
  42. package/dist/types/modes/shared/agent-wire/responses.d.ts +1 -1
  43. package/dist/types/modes/shared/agent-wire/unattended-action-policy.d.ts +27 -0
  44. package/dist/types/modes/shared/agent-wire/unattended-audit.d.ts +68 -0
  45. package/dist/types/modes/shared/agent-wire/unattended-run-controller.d.ts +161 -0
  46. package/dist/types/modes/shared/agent-wire/unattended-session.d.ts +61 -0
  47. package/dist/types/modes/shared/agent-wire/workflow-gate-broker.d.ts +114 -0
  48. package/dist/types/modes/shared/agent-wire/workflow-gate-schema.d.ts +39 -0
  49. package/dist/types/modes/theme/theme.d.ts +2 -1
  50. package/dist/types/modes/types.d.ts +1 -0
  51. package/dist/types/runtime-mcp/transports/stdio.d.ts +0 -4
  52. package/dist/types/sdk.d.ts +8 -1
  53. package/dist/types/session/agent-session.d.ts +10 -0
  54. package/dist/types/session/blob-store.d.ts +17 -0
  55. package/dist/types/session/messages.d.ts +3 -0
  56. package/dist/types/session/session-storage.d.ts +6 -0
  57. package/dist/types/skill-state/active-state.d.ts +13 -0
  58. package/dist/types/task/executor.d.ts +1 -0
  59. package/dist/types/thinking.d.ts +3 -2
  60. package/dist/types/tools/hindsight-recall.d.ts +0 -2
  61. package/dist/types/tools/hindsight-reflect.d.ts +0 -2
  62. package/dist/types/tools/hindsight-retain.d.ts +0 -2
  63. package/dist/types/tools/index.d.ts +7 -4
  64. package/package.json +9 -7
  65. package/src/cli/args.ts +10 -0
  66. package/src/cli.ts +14 -0
  67. package/src/commands/harness.ts +192 -7
  68. package/src/commands/launch.ts +8 -0
  69. package/src/commands/ultragoal.ts +1 -21
  70. package/src/config/model-equivalence.ts +1 -1
  71. package/src/config/model-profile-activation.ts +157 -0
  72. package/src/config/model-profiles.ts +155 -0
  73. package/src/config/model-registry.ts +51 -5
  74. package/src/config/model-resolver.ts +3 -2
  75. package/src/config/models-config-schema.ts +42 -1
  76. package/src/config/settings-schema.ts +14 -1
  77. package/src/defaults/gjc/skills/ultragoal/SKILL.md +11 -1
  78. package/src/defaults/gjc/skills/ultragoal/ai-slop-cleaner.md +61 -0
  79. package/src/defaults/gjc-defaults.ts +7 -0
  80. package/src/discovery/claude-plugins.ts +25 -5
  81. package/src/edit/diff.ts +64 -1
  82. package/src/edit/modes/replace.ts +60 -2
  83. package/src/extensibility/gjc-plugins/activation.ts +87 -0
  84. package/src/extensibility/gjc-plugins/index.ts +9 -0
  85. package/src/extensibility/gjc-plugins/injection.ts +114 -0
  86. package/src/extensibility/gjc-plugins/loader.ts +131 -0
  87. package/src/extensibility/gjc-plugins/paths.ts +66 -0
  88. package/src/extensibility/gjc-plugins/schema.ts +79 -0
  89. package/src/extensibility/gjc-plugins/state.ts +29 -0
  90. package/src/extensibility/gjc-plugins/tools.ts +47 -0
  91. package/src/extensibility/gjc-plugins/types.ts +97 -0
  92. package/src/extensibility/gjc-plugins/validation.ts +76 -0
  93. package/src/extensibility/skills.ts +39 -7
  94. package/src/gjc-runtime/state-runtime.ts +93 -2
  95. package/src/gjc-runtime/state-writer.ts +17 -1
  96. package/src/gjc-runtime/ultragoal-runtime.ts +62 -2
  97. package/src/gjc-runtime/workflow-manifest.generated.json +5 -0
  98. package/src/gjc-runtime/workflow-manifest.ts +2 -2
  99. package/src/harness-control-plane/storage.ts +144 -2
  100. package/src/hashline/hash.ts +23 -0
  101. package/src/hooks/skill-state.ts +2 -0
  102. package/src/internal-urls/docs-index.generated.ts +8 -11
  103. package/src/lsp/client.ts +7 -0
  104. package/src/main.ts +67 -1
  105. package/src/modes/acp/acp-agent.ts +25 -2
  106. package/src/modes/bridge/bridge-mode.ts +124 -2
  107. package/src/modes/components/custom-provider-wizard.ts +318 -0
  108. package/src/modes/components/model-selector.ts +108 -18
  109. package/src/modes/components/provider-onboarding-selector.ts +6 -1
  110. package/src/modes/controllers/input-controller.ts +14 -2
  111. package/src/modes/controllers/selector-controller.ts +57 -1
  112. package/src/modes/prompt-action-autocomplete.ts +49 -10
  113. package/src/modes/rpc/rpc-client.ts +57 -3
  114. package/src/modes/rpc/rpc-mode.ts +67 -0
  115. package/src/modes/rpc/rpc-types.ts +224 -2
  116. package/src/modes/shared/agent-wire/approval-gate.ts +151 -0
  117. package/src/modes/shared/agent-wire/command-dispatch.ts +97 -4
  118. package/src/modes/shared/agent-wire/command-validation.ts +25 -1
  119. package/src/modes/shared/agent-wire/deep-interview-gate.ts +222 -0
  120. package/src/modes/shared/agent-wire/event-envelope.ts +13 -0
  121. package/src/modes/shared/agent-wire/handshake.ts +43 -3
  122. package/src/modes/shared/agent-wire/protocol.ts +7 -0
  123. package/src/modes/shared/agent-wire/responses.ts +2 -2
  124. package/src/modes/shared/agent-wire/scopes.ts +2 -0
  125. package/src/modes/shared/agent-wire/unattended-action-policy.ts +341 -0
  126. package/src/modes/shared/agent-wire/unattended-audit.ts +175 -0
  127. package/src/modes/shared/agent-wire/unattended-run-controller.ts +406 -0
  128. package/src/modes/shared/agent-wire/unattended-session.ts +180 -0
  129. package/src/modes/shared/agent-wire/workflow-gate-broker.ts +324 -0
  130. package/src/modes/shared/agent-wire/workflow-gate-schema.ts +331 -0
  131. package/src/modes/theme/theme.ts +6 -0
  132. package/src/modes/types.ts +1 -0
  133. package/src/prompts/memories/consolidation.md +1 -1
  134. package/src/prompts/memories/read-path.md +6 -7
  135. package/src/prompts/memories/unavailable.md +2 -2
  136. package/src/prompts/tools/bash.md +1 -1
  137. package/src/prompts/tools/irc.md +1 -1
  138. package/src/prompts/tools/read.md +2 -2
  139. package/src/prompts/tools/recall.md +1 -0
  140. package/src/prompts/tools/reflect.md +1 -0
  141. package/src/prompts/tools/retain.md +1 -0
  142. package/src/runtime-mcp/client.ts +7 -4
  143. package/src/runtime-mcp/manager.ts +45 -13
  144. package/src/runtime-mcp/transports/http.ts +40 -14
  145. package/src/runtime-mcp/transports/stdio.ts +11 -10
  146. package/src/sdk.ts +48 -1
  147. package/src/session/agent-session.ts +211 -2
  148. package/src/session/blob-store.ts +84 -0
  149. package/src/session/messages.ts +3 -0
  150. package/src/session/session-manager.ts +390 -33
  151. package/src/session/session-storage.ts +26 -0
  152. package/src/setup/provider-onboarding.ts +2 -2
  153. package/src/skill-state/active-state.ts +89 -1
  154. package/src/slash-commands/builtin-registry.ts +1 -1
  155. package/src/task/discovery.ts +7 -1
  156. package/src/task/executor.ts +18 -2
  157. package/src/task/index.ts +2 -0
  158. package/src/thinking.ts +8 -2
  159. package/src/tools/ask.ts +39 -9
  160. package/src/tools/hindsight-recall.ts +0 -2
  161. package/src/tools/hindsight-reflect.ts +0 -2
  162. package/src/tools/hindsight-retain.ts +0 -2
  163. package/src/tools/index.ts +7 -18
  164. package/src/tools/read.ts +3 -3
  165. package/src/tools/skill.ts +15 -3
  166. package/src/utils/edit-mode.ts +1 -1
@@ -38,8 +38,12 @@ import {
38
38
  externalizeImageDataUrlSync,
39
39
  isBlobRef,
40
40
  isImageDataUrl,
41
+ MemoryBlobStore,
41
42
  resolveImageData,
43
+ resolveImageDataSync,
42
44
  resolveImageDataUrl,
45
+ resolveImageDataUrlSync,
46
+ resolveTextBlobSync,
43
47
  } from "./blob-store";
44
48
  import {
45
49
  type BashExecutionMessage,
@@ -825,23 +829,44 @@ export async function loadEntriesFromFile(
825
829
  * Resolve blob references in loaded entries, restoring both session image blocks and persisted
826
830
  * provider image URLs back to the inline data expected by downstream transports. Mutates entries in place.
827
831
  */
828
- function hasImageUrl(value: unknown): value is { image_url: string } {
829
- return typeof value === "object" && value !== null && "image_url" in value && typeof value.image_url === "string";
832
+ function hasImageUrl(value: unknown): value is { image_url: string | { url?: string } } {
833
+ return typeof value === "object" && value !== null && "image_url" in value;
830
834
  }
831
835
 
832
- async function resolvePersistedImageUrlRefs(value: unknown, blobStore: BlobStore): Promise<void> {
836
+ async function resolvePersistedBlobRefs(value: unknown, blobStore: BlobStore, key?: string): Promise<void> {
833
837
  if (Array.isArray(value)) {
834
- await Promise.all(value.map(item => resolvePersistedImageUrlRefs(item, blobStore)));
838
+ await Promise.all(value.map(item => resolvePersistedBlobRefs(item, blobStore, key)));
835
839
  return;
836
840
  }
837
841
 
838
842
  if (typeof value !== "object" || value === null) return;
839
843
 
840
- if (hasImageUrl(value) && isBlobRef(value.image_url)) {
841
- value.image_url = await resolveImageDataUrl(blobStore, value.image_url);
844
+ if (isImageBlock(value) && isBlobRef(value.data)) {
845
+ value.data = await resolveImageData(blobStore, value.data);
842
846
  }
843
847
 
844
- await Promise.all(Object.values(value).map(item => resolvePersistedImageUrlRefs(item, blobStore)));
848
+ if (hasImageUrl(value)) {
849
+ if (typeof value.image_url === "string" && isBlobRef(value.image_url)) {
850
+ value.image_url = await resolveImageDataUrl(blobStore, value.image_url);
851
+ } else if (
852
+ typeof value.image_url === "object" &&
853
+ value.image_url !== null &&
854
+ typeof value.image_url.url === "string" &&
855
+ isBlobRef(value.image_url.url)
856
+ ) {
857
+ value.image_url.url = await resolveImageDataUrl(blobStore, value.image_url.url);
858
+ }
859
+ }
860
+
861
+ await Promise.all(
862
+ Object.entries(value).map(async ([childKey, item]) => {
863
+ if (childKey === "data" && typeof item === "string" && isBlobRef(item) && key !== TEXT_CONTENT_KEY) {
864
+ (value as Record<string, unknown>)[childKey] = await resolveImageDataUrl(blobStore, item);
865
+ return;
866
+ }
867
+ await resolvePersistedBlobRefs(item, blobStore, childKey);
868
+ }),
869
+ );
845
870
  }
846
871
 
847
872
  async function resolveBlobRefsInEntries(entries: FileEntry[], blobStore: BlobStore): Promise<void> {
@@ -869,7 +894,7 @@ async function resolveBlobRefsInEntries(entries: FileEntry[], blobStore: BlobSto
869
894
  }
870
895
  }
871
896
 
872
- promises.push(resolvePersistedImageUrlRefs(entry, blobStore));
897
+ promises.push(resolvePersistedBlobRefs(entry, blobStore));
873
898
  }
874
899
 
875
900
  await Promise.all(promises);
@@ -1073,6 +1098,30 @@ const TRUNCATION_NOTICE = "\n\n[Session persistence truncated large content]";
1073
1098
  /** Minimum base64 length to externalize to blob store (skip tiny inline images) */
1074
1099
  const BLOB_EXTERNALIZE_THRESHOLD = 1024;
1075
1100
  const TEXT_CONTENT_KEY = "content";
1101
+ const RESIDENT_BLOB_SENTINEL_KEY = "__gjcResidentBlob";
1102
+ type ResidentBlobKind = "text" | "imageUrl" | "imageData";
1103
+ interface ResidentBlobSentinel {
1104
+ [RESIDENT_BLOB_SENTINEL_KEY]: true;
1105
+ kind: ResidentBlobKind;
1106
+ ref: string;
1107
+ }
1108
+
1109
+ function residentBlobSentinel(kind: ResidentBlobKind, ref: string): ResidentBlobSentinel {
1110
+ return { [RESIDENT_BLOB_SENTINEL_KEY]: true, kind, ref };
1111
+ }
1112
+
1113
+ function isResidentBlobSentinel(value: unknown): value is ResidentBlobSentinel {
1114
+ return (
1115
+ typeof value === "object" &&
1116
+ value !== null &&
1117
+ (value as { [RESIDENT_BLOB_SENTINEL_KEY]?: unknown })[RESIDENT_BLOB_SENTINEL_KEY] === true &&
1118
+ ((value as { kind?: unknown }).kind === "text" ||
1119
+ (value as { kind?: unknown }).kind === "imageUrl" ||
1120
+ (value as { kind?: unknown }).kind === "imageData") &&
1121
+ typeof (value as { ref?: unknown }).ref === "string" &&
1122
+ isBlobRef((value as { ref: string }).ref)
1123
+ );
1124
+ }
1076
1125
 
1077
1126
  /**
1078
1127
  * Recursively truncate large strings in an object for session persistence.
@@ -1104,10 +1153,138 @@ function isImageBlock(value: unknown): value is { type: "image"; data: string; m
1104
1153
  );
1105
1154
  }
1106
1155
 
1156
+ const RESIDENT_EXTERNALIZE_STRING_EXCLUDED_KEYS = new Set([
1157
+ "id",
1158
+ "type",
1159
+ "parentId",
1160
+ "timestamp",
1161
+ "role",
1162
+ "provider",
1163
+ "model",
1164
+ "api",
1165
+ "customType",
1166
+ "mode",
1167
+ "mimeType",
1168
+ "stopReason",
1169
+ "toolName",
1170
+ "targetId",
1171
+ "firstKeptEntryId",
1172
+ ]);
1173
+
1174
+ function shouldExternalizeResidentString(key: string | undefined): boolean {
1175
+ return !key || !RESIDENT_EXTERNALIZE_STRING_EXCLUDED_KEYS.has(key);
1176
+ }
1177
+
1178
+ function externalizeResidentValueSync(obj: unknown, blobStore: BlobStore, key?: string): unknown {
1179
+ if (obj === null || obj === undefined) return obj;
1180
+ if (typeof obj === "string") {
1181
+ if (key === "image_url" && isImageDataUrl(obj) && obj.length >= BLOB_EXTERNALIZE_THRESHOLD)
1182
+ return residentBlobSentinel("imageUrl", externalizeImageDataUrlSync(blobStore, obj));
1183
+ if (shouldExternalizeResidentString(key) && obj.length >= BLOB_EXTERNALIZE_THRESHOLD)
1184
+ return residentBlobSentinel("text", blobStore.putSync(Buffer.from(obj, "utf8")).ref);
1185
+ return obj;
1186
+ }
1187
+ if (Array.isArray(obj)) {
1188
+ let changed = false;
1189
+ const result: unknown[] = new Array(obj.length);
1190
+ for (let i = 0; i < obj.length; i++) {
1191
+ const item = obj[i];
1192
+ if (
1193
+ key === TEXT_CONTENT_KEY &&
1194
+ isImageBlock(item) &&
1195
+ !isBlobRef(item.data) &&
1196
+ item.data.length >= BLOB_EXTERNALIZE_THRESHOLD
1197
+ ) {
1198
+ changed = true;
1199
+ result[i] = {
1200
+ ...item,
1201
+ data: residentBlobSentinel("imageData", externalizeImageDataSync(blobStore, item.data)),
1202
+ };
1203
+ continue;
1204
+ }
1205
+ const newItem = externalizeResidentValueSync(item, blobStore, key);
1206
+ if (newItem !== item) changed = true;
1207
+ result[i] = newItem;
1208
+ }
1209
+ return changed ? result : obj;
1210
+ }
1211
+ if (typeof obj === "object") {
1212
+ let changed = false;
1213
+ const entries: Array<readonly [string, unknown]> = [];
1214
+ for (const [childKey, value] of Object.entries(obj)) {
1215
+ const newValue = externalizeResidentValueSync(value, blobStore, childKey);
1216
+ if (newValue !== value) changed = true;
1217
+ entries.push([childKey, newValue]);
1218
+ }
1219
+ return changed ? Object.fromEntries(entries) : obj;
1220
+ }
1221
+ return obj;
1222
+ }
1223
+
1224
+ function prepareEntryForResidentSync(entry: FileEntry, blobStore: BlobStore): FileEntry {
1225
+ return externalizeResidentValueSync(entry, blobStore) as FileEntry;
1226
+ }
1227
+
1228
+ function materializeResidentValueSync(
1229
+ obj: unknown,
1230
+ blobStore: BlobStore,
1231
+ key?: string,
1232
+ cache = new Map<string, string>(),
1233
+ ): unknown {
1234
+ if (obj === null || obj === undefined) return obj;
1235
+ if (typeof obj === "string") return obj;
1236
+ if (isResidentBlobSentinel(obj)) {
1237
+ const cacheKey = `${obj.kind}:${obj.ref}`;
1238
+ const cached = cache.get(cacheKey);
1239
+ if (cached !== undefined) return cached;
1240
+ const resolved =
1241
+ obj.kind === "imageUrl"
1242
+ ? resolveImageDataUrlSync(blobStore, obj.ref)
1243
+ : obj.kind === "imageData"
1244
+ ? resolveImageDataSync(blobStore, obj.ref)
1245
+ : resolveTextBlobSync(blobStore, obj.ref);
1246
+ cache.set(cacheKey, resolved);
1247
+ return resolved;
1248
+ }
1249
+ if (Array.isArray(obj)) {
1250
+ let changed = false;
1251
+ const result = obj.map(item => {
1252
+ const newItem = materializeResidentValueSync(item, blobStore, key, cache);
1253
+ if (newItem !== item) changed = true;
1254
+ return newItem;
1255
+ });
1256
+ return changed ? result : obj;
1257
+ }
1258
+ if (typeof obj === "object") {
1259
+ let changed = false;
1260
+ const entries = Object.entries(obj).map(([childKey, value]) => {
1261
+ const newValue = materializeResidentValueSync(value, blobStore, childKey, cache);
1262
+ if (newValue !== value) changed = true;
1263
+ return [childKey, newValue] as const;
1264
+ });
1265
+ return changed ? Object.fromEntries(entries) : obj;
1266
+ }
1267
+ return obj;
1268
+ }
1269
+
1270
+ function materializeResidentEntrySync<T extends FileEntry | SessionEntry>(
1271
+ entry: T,
1272
+ blobStore: BlobStore,
1273
+ cache: Map<string, string>,
1274
+ ): T {
1275
+ return materializeResidentValueSync(entry, blobStore, undefined, cache) as T;
1276
+ }
1277
+
1278
+ function materializeResidentEntriesSync<T extends FileEntry | SessionEntry>(entries: T[], blobStore: BlobStore): T[] {
1279
+ const cache = new Map<string, string>();
1280
+ return entries.map(entry => materializeResidentEntrySync(entry, blobStore, cache));
1281
+ }
1282
+
1107
1283
  async function truncateForPersistence(obj: FileEntry, blobStore: BlobStore, key?: string): Promise<FileEntry>;
1108
1284
  async function truncateForPersistence(obj: string, blobStore: BlobStore, key?: string): Promise<string>;
1109
1285
  async function truncateForPersistence(obj: unknown[], blobStore: BlobStore, key?: string): Promise<unknown[]>;
1110
1286
  async function truncateForPersistence(obj: object, blobStore: BlobStore, key?: string): Promise<object>;
1287
+ async function truncateForPersistence(obj: unknown, blobStore: BlobStore, key?: string): Promise<unknown>;
1111
1288
  async function truncateForPersistence(
1112
1289
  obj: null | undefined,
1113
1290
  blobStore: BlobStore,
@@ -1117,7 +1294,7 @@ async function truncateForPersistence(obj: unknown, blobStore: BlobStore, key?:
1117
1294
  if (obj === null || obj === undefined) return obj;
1118
1295
 
1119
1296
  if (typeof obj === "string") {
1120
- if (key === "image_url" && isImageDataUrl(obj)) {
1297
+ if ((key === "image_url" || key === "image_url.url") && isImageDataUrl(obj)) {
1121
1298
  return externalizeImageDataUrl(blobStore, obj);
1122
1299
  }
1123
1300
 
@@ -1139,7 +1316,9 @@ async function truncateForPersistence(obj: unknown, blobStore: BlobStore, key?:
1139
1316
  let changed = false;
1140
1317
  const result = await Promise.all(
1141
1318
  obj.map(async item => {
1142
- // Special handling: compress oversized images while preserving shape
1319
+ // Keep durable JSONL bounded and lossless for large images. Resident
1320
+ // sentinels are materialized before this serializer runs, so persistence
1321
+ // still owns the existing blob-ref-on-disk contract.
1143
1322
  if (key === TEXT_CONTENT_KEY && isImageBlock(item)) {
1144
1323
  if (!isBlobRef(item.data) && item.data.length >= BLOB_EXTERNALIZE_THRESHOLD) {
1145
1324
  changed = true;
@@ -1147,7 +1326,6 @@ async function truncateForPersistence(obj: unknown, blobStore: BlobStore, key?:
1147
1326
  return { ...item, data: blobRef };
1148
1327
  }
1149
1328
  }
1150
-
1151
1329
  const newItem = await truncateForPersistence(item, blobStore, key);
1152
1330
  if (newItem !== item) changed = true;
1153
1331
  return newItem;
@@ -1170,6 +1348,30 @@ async function truncateForPersistence(obj: unknown, blobStore: BlobStore, key?:
1170
1348
 
1171
1349
  return [
1172
1350
  (async () => {
1351
+ if (
1352
+ childKey === "image_url" &&
1353
+ typeof value === "object" &&
1354
+ value !== null &&
1355
+ typeof (value as { url?: unknown }).url === "string"
1356
+ ) {
1357
+ let imageUrlChanged = false;
1358
+ const imageUrlEntries = await Promise.all(
1359
+ Object.entries(value).map(async ([imageUrlKey, imageUrlValue]) => {
1360
+ const persistenceKey = imageUrlKey === "url" ? "image_url.url" : imageUrlKey;
1361
+ const newImageUrlValue = await truncateForPersistence(
1362
+ imageUrlValue,
1363
+ blobStore,
1364
+ persistenceKey,
1365
+ );
1366
+ if (newImageUrlValue !== imageUrlValue) imageUrlChanged = true;
1367
+ return [imageUrlKey, newImageUrlValue] as const;
1368
+ }),
1369
+ );
1370
+ if (imageUrlChanged) {
1371
+ changed = true;
1372
+ return [childKey, Object.fromEntries(imageUrlEntries)] as const;
1373
+ }
1374
+ }
1173
1375
  const newValue = await truncateForPersistence(value, blobStore, childKey);
1174
1376
  if (newValue !== value) changed = true;
1175
1377
  return [childKey, newValue] as const;
@@ -1219,7 +1421,7 @@ function truncateForPersistenceSync(obj: unknown, blobStore: BlobStore, key?: st
1219
1421
  if (obj === null || obj === undefined) return obj;
1220
1422
 
1221
1423
  if (typeof obj === "string") {
1222
- if (key === "image_url" && isImageDataUrl(obj)) {
1424
+ if ((key === "image_url" || key === "image_url.url") && isImageDataUrl(obj)) {
1223
1425
  return externalizeImageDataUrlSync(blobStore, obj);
1224
1426
  }
1225
1427
  if (obj.length > MAX_PERSIST_CHARS) {
@@ -1260,6 +1462,25 @@ function truncateForPersistenceSync(obj: unknown, blobStore: BlobStore, key?: st
1260
1462
  changed = true;
1261
1463
  continue;
1262
1464
  }
1465
+ if (
1466
+ childKey === "image_url" &&
1467
+ typeof value === "object" &&
1468
+ value !== null &&
1469
+ typeof (value as { url?: unknown }).url === "string"
1470
+ ) {
1471
+ let imageUrlChanged = false;
1472
+ const imageUrlEntries = Object.entries(value).map(([imageUrlKey, imageUrlValue]) => {
1473
+ const persistenceKey = imageUrlKey === "url" ? "image_url.url" : imageUrlKey;
1474
+ const newImageUrlValue = truncateForPersistenceSync(imageUrlValue, blobStore, persistenceKey);
1475
+ if (newImageUrlValue !== imageUrlValue) imageUrlChanged = true;
1476
+ return [imageUrlKey, newImageUrlValue] as const;
1477
+ });
1478
+ if (imageUrlChanged) {
1479
+ changed = true;
1480
+ entries.push([childKey, Object.fromEntries(imageUrlEntries)]);
1481
+ continue;
1482
+ }
1483
+ }
1263
1484
  const newValue = truncateForPersistenceSync(value, blobStore, childKey);
1264
1485
  if (newValue !== value) changed = true;
1265
1486
  entries.push([childKey, newValue]);
@@ -1430,6 +1651,13 @@ class NdjsonFileWriter {
1430
1651
  isOpen(): boolean {
1431
1652
  return !this.#closed && !this.#closing;
1432
1653
  }
1654
+
1655
+ closeSync(): void {
1656
+ if (this.#closed) return;
1657
+ this.#closed = true;
1658
+ this.#closing = true;
1659
+ this.#writer.close().catch(() => {});
1660
+ }
1433
1661
  }
1434
1662
 
1435
1663
  /** Get recent sessions for display in welcome screen */
@@ -1820,6 +2048,7 @@ export class SessionManager {
1820
2048
  #inMemoryArtifacts: Map<string, string> | null = null;
1821
2049
  #inMemoryArtifactCounter = 0;
1822
2050
  readonly #blobStore: BlobStore;
2051
+ readonly #residentBlobStore = new MemoryBlobStore();
1823
2052
 
1824
2053
  private constructor(
1825
2054
  private cwd: string,
@@ -1827,7 +2056,7 @@ export class SessionManager {
1827
2056
  private readonly persist: boolean,
1828
2057
  private readonly storage: SessionStorage,
1829
2058
  ) {
1830
- this.#blobStore = new BlobStore(getBlobsDir());
2059
+ this.#blobStore = persist ? new BlobStore(getBlobsDir()) : this.#residentBlobStore;
1831
2060
  if (persist && sessionDir) {
1832
2061
  this.storage.ensureDirSync(sessionDir);
1833
2062
  }
@@ -1900,8 +2129,11 @@ export class SessionManager {
1900
2129
  this.#titleSource = header?.titleSource;
1901
2130
 
1902
2131
  this.#needsFullRewriteOnNextPersist = migrateToCurrentVersion(this.#fileEntries);
1903
-
1904
2132
  await resolveBlobRefsInEntries(this.#fileEntries, this.#blobStore);
2133
+
2134
+ this.#fileEntries = this.#fileEntries.map(entry =>
2135
+ prepareEntryForResidentSync(entry, this.#residentBlobStore),
2136
+ );
1905
2137
  this.sanitizeLoadedOpenAIResponsesReplayMetadata();
1906
2138
 
1907
2139
  this.#buildIndex();
@@ -2212,6 +2444,14 @@ export class SessionManager {
2212
2444
  this.#persistWriterPath = undefined;
2213
2445
  }
2214
2446
 
2447
+ #closePersistWriterInternalSync(): void {
2448
+ if (this.#persistWriter) {
2449
+ this.#persistWriter.closeSync();
2450
+ this.#persistWriter = undefined;
2451
+ }
2452
+ this.#persistWriterPath = undefined;
2453
+ }
2454
+
2215
2455
  async #closePersistWriter(): Promise<void> {
2216
2456
  await this.#queuePersistTask(
2217
2457
  async () => {
@@ -2227,6 +2467,49 @@ export class SessionManager {
2227
2467
  // shared `*.bak` glob on both real and in-memory storage backends and promote it back to
2228
2468
  // the primary on the next session-dir scan.
2229
2469
 
2470
+ #replaceSessionFileAfterEpermSync(tempPath: string, targetPath: string, renameError: unknown): void {
2471
+ const dir = path.resolve(targetPath, "..");
2472
+ const backupPath = path.join(dir, `${path.basename(targetPath)}.${Snowflake.next()}.bak`);
2473
+ try {
2474
+ this.storage.renameSync(targetPath, backupPath);
2475
+ } catch (err) {
2476
+ if (isEnoent(err)) {
2477
+ this.storage.renameSync(tempPath, targetPath);
2478
+ return;
2479
+ }
2480
+ throw toError(renameError);
2481
+ }
2482
+
2483
+ try {
2484
+ this.storage.renameSync(tempPath, targetPath);
2485
+ } catch (err) {
2486
+ const replaceError = toError(err);
2487
+ const originalError = toError(renameError);
2488
+ try {
2489
+ this.storage.renameSync(backupPath, targetPath);
2490
+ } catch (rollbackErr) {
2491
+ const rollbackError = toError(rollbackErr);
2492
+ throw new Error(
2493
+ `Failed to replace session file after EPERM (original: ${originalError.message}; retry: ${replaceError.message}); rollback from ${backupPath} also failed: ${rollbackError.message}`,
2494
+ { cause: originalError },
2495
+ );
2496
+ }
2497
+ throw replaceError;
2498
+ }
2499
+
2500
+ try {
2501
+ this.storage.unlinkSync(backupPath);
2502
+ } catch (err) {
2503
+ if (!isEnoent(err)) {
2504
+ logger.warn("Failed to remove session rewrite backup", {
2505
+ sessionFile: targetPath,
2506
+ backupPath,
2507
+ error: toError(err).message,
2508
+ });
2509
+ }
2510
+ }
2511
+ }
2512
+
2230
2513
  async #replaceSessionFileAfterEperm(tempPath: string, targetPath: string, renameError: unknown): Promise<void> {
2231
2514
  const dir = path.resolve(targetPath, "..");
2232
2515
  const backupPath = path.join(dir, `${path.basename(targetPath)}.${Snowflake.next()}.bak`);
@@ -2278,6 +2561,37 @@ export class SessionManager {
2278
2561
  await this.#replaceSessionFileAfterEperm(tempPath, targetPath, err);
2279
2562
  }
2280
2563
  }
2564
+
2565
+ #replaceSessionFileSync(tempPath: string, targetPath: string): void {
2566
+ try {
2567
+ this.storage.renameSync(tempPath, targetPath);
2568
+ } catch (err) {
2569
+ if (hasFsCode(err, "EPERM")) {
2570
+ this.#replaceSessionFileAfterEpermSync(tempPath, targetPath, err);
2571
+ return;
2572
+ }
2573
+
2574
+ throw toError(err);
2575
+ }
2576
+ }
2577
+
2578
+ #writeEntriesAtomicallySync(entries: FileEntry[]): void {
2579
+ if (!this.#sessionFile) return;
2580
+ const dir = path.resolve(this.#sessionFile, "..");
2581
+ const tempPath = path.join(dir, `.${path.basename(this.#sessionFile)}.${Snowflake.next()}.tmp`);
2582
+ const writer = new NdjsonFileWriter(this.storage, tempPath, { flags: "w" });
2583
+ try {
2584
+ for (const entry of entries) {
2585
+ writer.writeSync(entry);
2586
+ }
2587
+ writer.closeSync();
2588
+ this.#replaceSessionFileSync(tempPath, this.#sessionFile);
2589
+ } catch (err) {
2590
+ writer.closeSync();
2591
+ void this.storage.unlink(tempPath).catch(() => {});
2592
+ throw toError(err);
2593
+ }
2594
+ }
2281
2595
  async #writeEntriesAtomically(entries: FileEntry[]): Promise<void> {
2282
2596
  if (!this.#sessionFile) return;
2283
2597
  const dir = path.resolve(this.#sessionFile, "..");
@@ -2311,14 +2625,29 @@ export class SessionManager {
2311
2625
  await this.#queuePersistTask(async () => {
2312
2626
  await this.#closePersistWriterInternal();
2313
2627
  const entries = await Promise.all(
2314
- this.#fileEntries.map(entry => prepareEntryForPersistence(entry, this.#blobStore)),
2628
+ materializeResidentEntriesSync(this.#fileEntries, this.#residentBlobStore).map(entry =>
2629
+ prepareEntryForPersistence(entry, this.#blobStore),
2630
+ ),
2315
2631
  );
2316
2632
  await this.#writeEntriesAtomically(entries);
2317
2633
  this.#needsFullRewriteOnNextPersist = false;
2318
2634
  this.#flushed = true;
2635
+ this.#ensuredOnDisk = true;
2319
2636
  });
2320
2637
  }
2321
2638
 
2639
+ #rewriteFileSync(): void {
2640
+ if (!this.persist || !this.#sessionFile) return;
2641
+ this.#closePersistWriterInternalSync();
2642
+ const entries = materializeResidentEntriesSync(this.#fileEntries, this.#residentBlobStore).map(entry =>
2643
+ prepareEntryForPersistenceSync(entry, this.#blobStore),
2644
+ );
2645
+ this.#writeEntriesAtomicallySync(entries);
2646
+ this.#needsFullRewriteOnNextPersist = false;
2647
+ this.#flushed = true;
2648
+ this.#ensuredOnDisk = true;
2649
+ }
2650
+
2322
2651
  isPersisted(): boolean {
2323
2652
  return this.persist;
2324
2653
  }
@@ -2580,6 +2909,7 @@ export class SessionManager {
2580
2909
  if (!hasAssistant) {
2581
2910
  // Mark as not flushed so when assistant arrives, all entries get written.
2582
2911
  this.#flushed = false;
2912
+ this.#ensuredOnDisk = false;
2583
2913
  return;
2584
2914
  }
2585
2915
  }
@@ -2590,7 +2920,12 @@ export class SessionManager {
2590
2920
  // `#persistChain` → `#recordPersistError`; we swallow the rejection
2591
2921
  // here to avoid an unhandled rejection when the persist dir races with
2592
2922
  // test-level tempDir cleanup.
2593
- this.#rewriteFile().catch(() => {});
2923
+ try {
2924
+ this.#rewriteFileSync();
2925
+ } catch (err) {
2926
+ this.#recordPersistError(err);
2927
+ throw this.#persistError ?? toError(err);
2928
+ }
2594
2929
  return;
2595
2930
  }
2596
2931
 
@@ -2609,18 +2944,21 @@ export class SessionManager {
2609
2944
  this.#rewriteFile().catch(() => {});
2610
2945
  return;
2611
2946
  }
2612
- const persistedEntry = prepareEntryForPersistenceSync(entry, this.#blobStore);
2947
+ const materializedEntry = materializeResidentEntrySync(entry, this.#residentBlobStore, new Map());
2948
+ const persistedEntry = prepareEntryForPersistenceSync(materializedEntry, this.#blobStore);
2613
2949
  writer.writeSync(persistedEntry);
2614
2950
  } catch (err) {
2615
2951
  this.#recordPersistError(err);
2952
+ throw this.#persistError ?? toError(err);
2616
2953
  }
2617
2954
  }
2618
2955
 
2619
2956
  #appendEntry(entry: SessionEntry): void {
2620
- this.#fileEntries.push(entry);
2621
- this.#byId.set(entry.id, entry);
2622
- this.#leafId = entry.id;
2623
- this._persist(entry);
2957
+ const residentEntry = prepareEntryForResidentSync(entry, this.#residentBlobStore) as SessionEntry;
2958
+ this.#fileEntries.push(residentEntry);
2959
+ this.#byId.set(residentEntry.id, residentEntry);
2960
+ this.#leafId = residentEntry.id;
2961
+ this._persist(residentEntry);
2624
2962
  if (entry.type === "message" && entry.message.role === "assistant") {
2625
2963
  const usage = entry.message.usage;
2626
2964
  this.#usageStatistics.input += usage.input;
@@ -2894,7 +3232,9 @@ export class SessionManager {
2894
3232
  }
2895
3233
 
2896
3234
  getLeafEntry(): SessionEntry | undefined {
2897
- return this.#leafId ? this.#byId.get(this.#leafId) : undefined;
3235
+ if (!this.#leafId) return undefined;
3236
+ const entry = this.#byId.get(this.#leafId);
3237
+ return entry ? materializeResidentEntrySync(entry, this.#residentBlobStore, new Map()) : undefined;
2898
3238
  }
2899
3239
 
2900
3240
  /**
@@ -2913,17 +3253,19 @@ export class SessionManager {
2913
3253
  }
2914
3254
 
2915
3255
  getEntry(id: string): SessionEntry | undefined {
2916
- return this.#byId.get(id);
3256
+ const entry = this.#byId.get(id);
3257
+ return entry ? materializeResidentEntrySync(entry, this.#residentBlobStore, new Map()) : undefined;
2917
3258
  }
2918
3259
 
2919
3260
  /**
2920
3261
  * Get all direct children of an entry.
2921
3262
  */
2922
3263
  getChildren(parentId: string): SessionEntry[] {
3264
+ const cache = new Map<string, string>();
2923
3265
  const children: SessionEntry[] = [];
2924
3266
  for (const entry of this.#byId.values()) {
2925
3267
  if (entry.parentId === parentId) {
2926
- children.push(entry);
3268
+ children.push(materializeResidentEntrySync(entry, this.#residentBlobStore, cache));
2927
3269
  }
2928
3270
  }
2929
3271
  return children;
@@ -2968,11 +3310,12 @@ export class SessionManager {
2968
3310
  * Use buildSessionContext() to get the resolved messages for the LLM.
2969
3311
  */
2970
3312
  getBranch(fromId?: string): SessionEntry[] {
3313
+ const cache = new Map<string, string>();
2971
3314
  const path: SessionEntry[] = [];
2972
3315
  const startId = fromId ?? this.#leafId;
2973
3316
  let current = startId ? this.#byId.get(startId) : undefined;
2974
3317
  while (current) {
2975
- path.unshift(current);
3318
+ path.unshift(materializeResidentEntrySync(current, this.#residentBlobStore, cache));
2976
3319
  current = current.parentId ? this.#byId.get(current.parentId) : undefined;
2977
3320
  }
2978
3321
  return path;
@@ -2983,7 +3326,7 @@ export class SessionManager {
2983
3326
  * Uses tree traversal from current leaf.
2984
3327
  */
2985
3328
  buildSessionContext(): SessionContext {
2986
- return buildSessionContext(this.getEntries(), this.#leafId, this.#byId);
3329
+ return buildSessionContext(this.getEntries(), this.#leafId);
2987
3330
  }
2988
3331
 
2989
3332
  /** Strip stale OpenAI Responses assistant replay metadata from loaded in-memory entries. */
@@ -3020,7 +3363,10 @@ export class SessionManager {
3020
3363
  * change the leaf pointer. Entries cannot be modified or deleted.
3021
3364
  */
3022
3365
  getEntries(): SessionEntry[] {
3023
- return this.#fileEntries.filter((e): e is SessionEntry => e.type !== "session");
3366
+ return materializeResidentEntriesSync(
3367
+ this.#fileEntries.filter((e): e is SessionEntry => e.type !== "session"),
3368
+ this.#residentBlobStore,
3369
+ );
3024
3370
  }
3025
3371
 
3026
3372
  /**
@@ -3159,7 +3505,7 @@ export class SessionManager {
3159
3505
  const lines: string[] = [];
3160
3506
  lines.push(JSON.stringify(header));
3161
3507
  for (const entry of pathWithoutLabels) {
3162
- lines.push(JSON.stringify(entry));
3508
+ lines.push(JSON.stringify(prepareEntryForPersistenceSync(entry, this.#blobStore)));
3163
3509
  }
3164
3510
  // Write fresh label entries at the end
3165
3511
  const lastEntryId = pathWithoutLabels[pathWithoutLabels.length - 1]?.id || null;
@@ -3174,13 +3520,19 @@ export class SessionManager {
3174
3520
  targetId,
3175
3521
  label,
3176
3522
  };
3177
- lines.push(JSON.stringify(labelEntry));
3523
+ lines.push(JSON.stringify(prepareEntryForPersistenceSync(labelEntry, this.#blobStore)));
3178
3524
  pathEntryIds.add(labelEntry.id);
3179
3525
  labelEntries.push(labelEntry);
3180
3526
  parentId = labelEntry.id;
3181
3527
  }
3182
3528
  this.storage.writeTextSync(newSessionFile, `${lines.join("\n")}\n`);
3183
- this.#fileEntries = [header, ...pathWithoutLabels, ...labelEntries];
3529
+ this.#fileEntries = [
3530
+ header,
3531
+ ...pathWithoutLabels.map(
3532
+ entry => prepareEntryForResidentSync(entry, this.#residentBlobStore) as SessionEntry,
3533
+ ),
3534
+ ...labelEntries,
3535
+ ];
3184
3536
  this.#sessionId = newSessionId;
3185
3537
  this.#sessionFile = newSessionFile;
3186
3538
  this.#flushed = true;
@@ -3203,7 +3555,11 @@ export class SessionManager {
3203
3555
  labelEntries.push(labelEntry);
3204
3556
  parentId = labelEntry.id;
3205
3557
  }
3206
- this.#fileEntries = [header, ...pathWithoutLabels, ...labelEntries];
3558
+ this.#fileEntries = [
3559
+ header,
3560
+ ...pathWithoutLabels.map(entry => prepareEntryForResidentSync(entry, this.#residentBlobStore) as SessionEntry),
3561
+ ...labelEntries,
3562
+ ];
3207
3563
  this.#sessionId = newSessionId;
3208
3564
  this.#buildIndex();
3209
3565
  return undefined;
@@ -3247,8 +3603,9 @@ export class SessionManager {
3247
3603
  const forkEntries = structuredClone(await loadEntriesFromFile(sourcePath, storage)) as FileEntry[];
3248
3604
  migrateToCurrentVersion(forkEntries);
3249
3605
  await resolveBlobRefsInEntries(forkEntries, manager.#blobStore);
3250
- const sourceHeader = forkEntries.find(e => e.type === "session") as SessionHeader | undefined;
3251
- const historyEntries = forkEntries.filter(entry => entry.type !== "session") as SessionEntry[];
3606
+ manager.#fileEntries = forkEntries.map(entry => prepareEntryForResidentSync(entry, manager.#residentBlobStore));
3607
+ const sourceHeader = manager.#fileEntries.find(e => e.type === "session") as SessionHeader | undefined;
3608
+ const historyEntries = manager.#fileEntries.filter(entry => entry.type !== "session") as SessionEntry[];
3252
3609
  manager.#newSessionSync({ parentSession: sourceHeader?.id });
3253
3610
  const newHeader = manager.#fileEntries[0] as SessionHeader;
3254
3611
  newHeader.title = sourceHeader?.title;