@gajae-code/coding-agent 0.7.2 → 0.7.4

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 (154) hide show
  1. package/CHANGELOG.md +86 -0
  2. package/bin/gjc.js +4 -0
  3. package/dist/types/cli/mcp-cli.d.ts +25 -0
  4. package/dist/types/cli/plugin-cli.d.ts +2 -0
  5. package/dist/types/cli.d.ts +6 -0
  6. package/dist/types/commands/mcp.d.ts +70 -0
  7. package/dist/types/commands/plugin.d.ts +6 -0
  8. package/dist/types/commands/session.d.ts +6 -0
  9. package/dist/types/config/keybindings.d.ts +2 -2
  10. package/dist/types/config/model-profile-activation.d.ts +8 -1
  11. package/dist/types/deep-interview/plaintext-gate-guard.d.ts +11 -0
  12. package/dist/types/extensibility/gjc-plugins/compiler.d.ts +19 -0
  13. package/dist/types/extensibility/gjc-plugins/constrained-hooks.d.ts +29 -0
  14. package/dist/types/extensibility/gjc-plugins/index.d.ts +9 -0
  15. package/dist/types/extensibility/gjc-plugins/injection.d.ts +9 -0
  16. package/dist/types/extensibility/gjc-plugins/installer.d.ts +13 -0
  17. package/dist/types/extensibility/gjc-plugins/mcp-policy.d.ts +26 -0
  18. package/dist/types/extensibility/gjc-plugins/observability.d.ts +27 -0
  19. package/dist/types/extensibility/gjc-plugins/prompt-appendix.d.ts +16 -0
  20. package/dist/types/extensibility/gjc-plugins/registry.d.ts +32 -0
  21. package/dist/types/extensibility/gjc-plugins/runtime-adapters.d.ts +64 -0
  22. package/dist/types/extensibility/gjc-plugins/session-validation.d.ts +42 -0
  23. package/dist/types/extensibility/gjc-plugins/types.d.ts +158 -2
  24. package/dist/types/extensibility/gjc-plugins/validation.d.ts +8 -1
  25. package/dist/types/gjc-runtime/launch-tmux.d.ts +1 -0
  26. package/dist/types/gjc-runtime/psmux-detect.d.ts +78 -0
  27. package/dist/types/gjc-runtime/team-runtime.d.ts +2 -0
  28. package/dist/types/gjc-runtime/tmux-common.d.ts +20 -1
  29. package/dist/types/gjc-runtime/tmux-sessions.d.ts +18 -0
  30. package/dist/types/main.d.ts +2 -0
  31. package/dist/types/modes/components/custom-editor.d.ts +1 -1
  32. package/dist/types/modes/components/model-selector.d.ts +8 -0
  33. package/dist/types/modes/components/status-line/git-utils.d.ts +6 -0
  34. package/dist/types/modes/theme/defaults/index.d.ts +99 -0
  35. package/dist/types/notifications/html-format.d.ts +11 -0
  36. package/dist/types/notifications/index.d.ts +149 -1
  37. package/dist/types/notifications/lifecycle-commands.d.ts +72 -0
  38. package/dist/types/notifications/lifecycle-control-runtime.d.ts +98 -0
  39. package/dist/types/notifications/lifecycle-orchestrator.d.ts +144 -0
  40. package/dist/types/notifications/operator-runtime.d.ts +52 -0
  41. package/dist/types/notifications/rate-limit-pool.d.ts +2 -0
  42. package/dist/types/notifications/recent-activity.d.ts +35 -0
  43. package/dist/types/notifications/telegram-daemon.d.ts +114 -16
  44. package/dist/types/notifications/telegram-reference.d.ts +3 -1
  45. package/dist/types/notifications/topic-registry.d.ts +12 -9
  46. package/dist/types/runtime-mcp/types.d.ts +7 -0
  47. package/dist/types/sdk.d.ts +2 -0
  48. package/dist/types/session/agent-session.d.ts +14 -4
  49. package/dist/types/session/blob-store.d.ts +25 -0
  50. package/dist/types/session/session-manager.d.ts +57 -0
  51. package/dist/types/slash-commands/helpers/fast-status-report.d.ts +6 -0
  52. package/dist/types/system-prompt.d.ts +2 -0
  53. package/dist/types/task/executor.d.ts +9 -1
  54. package/dist/types/tools/composer-bash-policy.d.ts +14 -0
  55. package/dist/types/tools/index.d.ts +3 -1
  56. package/dist/types/utils/changelog.d.ts +1 -0
  57. package/dist/types/web/insane/url-guard.d.ts +6 -3
  58. package/dist/types/web/scrapers/types.d.ts +5 -0
  59. package/dist/types/web/scrapers/utils.d.ts +7 -1
  60. package/package.json +11 -9
  61. package/scripts/g004-tmux-smoke.ts +100 -0
  62. package/scripts/g005-daemon-smoke.ts +181 -0
  63. package/scripts/g011-daemon-path-smoke.ts +153 -0
  64. package/src/cli/mcp-cli.ts +272 -0
  65. package/src/cli/plugin-cli.ts +66 -3
  66. package/src/cli.ts +27 -6
  67. package/src/commands/mcp.ts +117 -0
  68. package/src/commands/plugin.ts +4 -0
  69. package/src/commands/session.ts +18 -0
  70. package/src/config/keybindings.ts +2 -2
  71. package/src/config/model-profile-activation.ts +55 -7
  72. package/src/deep-interview/plaintext-gate-guard.ts +94 -0
  73. package/src/defaults/gjc/extensions/grok-cli-vendor/biome.json +1 -1
  74. package/src/defaults/gjc/skills/deep-interview/SKILL.md +7 -6
  75. package/src/defaults/gjc/skills/team/SKILL.md +5 -3
  76. package/src/defaults/gjc/skills/ultragoal/SKILL.md +41 -13
  77. package/src/export/html/index.ts +2 -2
  78. package/src/extensibility/extensions/runner.ts +1 -0
  79. package/src/extensibility/gjc-plugins/compiler.ts +351 -0
  80. package/src/extensibility/gjc-plugins/constrained-hooks.ts +170 -0
  81. package/src/extensibility/gjc-plugins/index.ts +9 -0
  82. package/src/extensibility/gjc-plugins/injection.ts +109 -0
  83. package/src/extensibility/gjc-plugins/installer.ts +434 -0
  84. package/src/extensibility/gjc-plugins/loader.ts +3 -1
  85. package/src/extensibility/gjc-plugins/mcp-policy.ts +239 -0
  86. package/src/extensibility/gjc-plugins/observability.ts +84 -0
  87. package/src/extensibility/gjc-plugins/paths.ts +1 -1
  88. package/src/extensibility/gjc-plugins/prompt-appendix.ts +109 -0
  89. package/src/extensibility/gjc-plugins/registry.ts +180 -0
  90. package/src/extensibility/gjc-plugins/runtime-adapters.ts +234 -0
  91. package/src/extensibility/gjc-plugins/schema.ts +250 -20
  92. package/src/extensibility/gjc-plugins/session-validation.ts +147 -0
  93. package/src/extensibility/gjc-plugins/types.ts +199 -3
  94. package/src/extensibility/gjc-plugins/validation.ts +80 -0
  95. package/src/extensibility/skills.ts +15 -0
  96. package/src/gjc-runtime/launch-tmux.ts +61 -7
  97. package/src/gjc-runtime/psmux-detect.ts +239 -0
  98. package/src/gjc-runtime/team-runtime.ts +56 -23
  99. package/src/gjc-runtime/tmux-common.ts +30 -3
  100. package/src/gjc-runtime/tmux-sessions.ts +51 -1
  101. package/src/gjc-runtime/ultragoal-guard.ts +25 -8
  102. package/src/gjc-runtime/ultragoal-runtime.ts +75 -15
  103. package/src/hooks/skill-state.ts +57 -0
  104. package/src/internal-urls/docs-index.generated.ts +12 -8
  105. package/src/main.ts +14 -3
  106. package/src/modes/bridge/bridge-mode.ts +11 -0
  107. package/src/modes/components/custom-editor.ts +2 -0
  108. package/src/modes/components/footer.ts +2 -3
  109. package/src/modes/components/hook-editor.ts +1 -1
  110. package/src/modes/components/hook-selector.ts +67 -43
  111. package/src/modes/components/model-selector.ts +56 -11
  112. package/src/modes/components/status-line/git-utils.ts +25 -0
  113. package/src/modes/components/status-line.ts +10 -11
  114. package/src/modes/components/welcome.ts +2 -3
  115. package/src/modes/controllers/extension-ui-controller.ts +0 -27
  116. package/src/modes/controllers/selector-controller.ts +53 -11
  117. package/src/modes/interactive-mode.ts +4 -1
  118. package/src/modes/shared/agent-wire/scopes.ts +1 -1
  119. package/src/modes/theme/defaults/gruvbox-dark.json +99 -0
  120. package/src/modes/theme/defaults/index.ts +2 -0
  121. package/src/modes/utils/hotkeys-markdown.ts +1 -1
  122. package/src/notifications/html-format.ts +38 -0
  123. package/src/notifications/index.ts +242 -12
  124. package/src/notifications/lifecycle-commands.ts +228 -0
  125. package/src/notifications/lifecycle-control-runtime.ts +400 -0
  126. package/src/notifications/lifecycle-orchestrator.ts +358 -0
  127. package/src/notifications/operator-runtime.ts +171 -0
  128. package/src/notifications/rate-limit-pool.ts +19 -0
  129. package/src/notifications/recent-activity.ts +132 -0
  130. package/src/notifications/telegram-daemon.ts +778 -257
  131. package/src/notifications/telegram-reference.ts +25 -7
  132. package/src/notifications/topic-registry.ts +23 -9
  133. package/src/prompts/agents/executor.md +2 -2
  134. package/src/runtime-mcp/transports/stdio.ts +38 -4
  135. package/src/runtime-mcp/types.ts +7 -0
  136. package/src/sdk.ts +157 -10
  137. package/src/session/agent-session.ts +166 -74
  138. package/src/session/blob-store.ts +196 -8
  139. package/src/session/session-manager.ts +678 -7
  140. package/src/slash-commands/builtin-registry.ts +23 -3
  141. package/src/slash-commands/helpers/fast-status-report.ts +13 -3
  142. package/src/slash-commands/helpers/parse.ts +2 -1
  143. package/src/system-prompt.ts +9 -0
  144. package/src/task/executor.ts +31 -7
  145. package/src/task/index.ts +2 -0
  146. package/src/tools/ask.ts +5 -1
  147. package/src/tools/bash.ts +9 -0
  148. package/src/tools/composer-bash-policy.ts +96 -0
  149. package/src/tools/fetch.ts +18 -2
  150. package/src/tools/index.ts +3 -1
  151. package/src/utils/changelog.ts +8 -0
  152. package/src/web/insane/url-guard.ts +18 -14
  153. package/src/web/scrapers/types.ts +143 -45
  154. package/src/web/scrapers/utils.ts +70 -19
@@ -41,6 +41,7 @@ import {
41
41
  isBlobRef,
42
42
  isImageDataUrl,
43
43
  MemoryBlobStore,
44
+ parseBlobRef,
44
45
  ResidentBlobMissingError,
45
46
  resolveImageData,
46
47
  resolveImageDataUrl,
@@ -93,9 +94,55 @@ export interface SessionEntryBase {
93
94
  timestamp: string;
94
95
  }
95
96
 
97
+ export interface ColdSpillRef {
98
+ kind: "cold_spill";
99
+ ref: string;
100
+ encoding: "utf8" | "json";
101
+ originalChars: number;
102
+ sha256: string;
103
+ bytes: number;
104
+ }
105
+
106
+ export interface EvictedContentMarker {
107
+ evictedAt: number;
108
+ reason: "compacted_history";
109
+ compactionEntryId: string;
110
+ firstKeptEntryId: string;
111
+ payloads: Record<string, ColdSpillRef>;
112
+ }
113
+
114
+ export interface EvictCompactedContentResult {
115
+ evictedEntries: number;
116
+ hotCharsRemoved: number;
117
+ coldBlobBytes: number;
118
+ payloadRefs: number;
119
+ alreadyEvictedEntries: number;
120
+ coldSpillWriteCount: number;
121
+ coldSpillReadCount: number;
122
+ residentTextReadCount: number;
123
+ residentImageReadCount: number;
124
+ }
125
+
126
+ export interface SessionManagerObservabilityStats {
127
+ coldSpillWriteCount: number;
128
+ coldSpillReadCount: number;
129
+ residentTextReadCount: number;
130
+ residentImageReadCount: number;
131
+ publicMaterializerCallCount: number;
132
+ getEntryMaterializerCallCount: number;
133
+ getBranchMaterializerCallCount: number;
134
+ getEntriesMaterializerCallCount: number;
135
+ materializedEntriesCachePopulateCount: number;
136
+ pathOnlyContextBuildCount: number;
137
+ }
138
+
96
139
  export interface SessionMessageEntry extends SessionEntryBase {
97
140
  type: "message";
98
141
  message: AgentMessage;
142
+ /** Cold-spill marker: when present, heavy message content was moved to durable
143
+ * content-addressed blobs after compaction. The marker is entry-level session
144
+ * metadata (not a message field) so strict message types stay intact. */
145
+ evictedContent?: EvictedContentMarker;
99
146
  }
100
147
 
101
148
  export interface ThinkingLevelChangeEntry extends SessionEntryBase {
@@ -227,6 +274,8 @@ export interface CustomMessageEntry<T = unknown> extends SessionEntryBase {
227
274
  display: boolean;
228
275
  /** Who initiated this message for billing/attribution semantics. */
229
276
  attribution?: MessageAttribution;
277
+ /** Cold-spill marker for custom-message content evicted after compaction. */
278
+ evictedContent?: EvictedContentMarker;
230
279
  }
231
280
 
232
281
  /** Session entry - has id/parentId for tree structure (returned by "read" methods in SessionManager) */
@@ -319,6 +368,21 @@ function createSessionId(): string {
319
368
  return Bun.randomUUIDv7();
320
369
  }
321
370
 
371
+ /**
372
+ * A session id pre-allocated by the notifications lifecycle subsystem, when this
373
+ * process was spawned by `/session_create`. Gated by `GJC_LIFECYCLE_REQUEST_ID`
374
+ * so it ONLY applies to lifecycle-launched sessions (never normal launches): the
375
+ * daemon tags the tmux session, endpoint discovery, and its `/session_recent`
376
+ * id with this value, so the agent MUST adopt it as its header id or those ids
377
+ * diverge (breaking close/resume-by-id after the session is gone).
378
+ */
379
+ function lifecyclePreallocatedSessionId(): string | undefined {
380
+ if (!process.env.GJC_LIFECYCLE_REQUEST_ID) return undefined;
381
+ const id = process.env.GJC_SESSION_ID?.trim();
382
+ if (!id || !/^[A-Za-z0-9._-]{1,128}$/.test(id)) return undefined;
383
+ return id;
384
+ }
385
+
322
386
  /** Generate a unique short ID (8 hex chars, collision-checked) */
323
387
  function generateId(byId: { has(id: string): boolean }): string {
324
388
  for (let i = 0; i < 100; i++) {
@@ -1199,6 +1263,17 @@ function isResidentBlobSentinel(value: unknown): value is ResidentBlobSentinel {
1199
1263
  isBlobRef((value as { ref: string }).ref)
1200
1264
  );
1201
1265
  }
1266
+ function containsResidentSentinel(value: unknown, seen = new WeakSet<object>()): boolean {
1267
+ if (value === null || value === undefined || typeof value !== "object") return false;
1268
+ if ((value as { [RESIDENT_BLOB_SENTINEL_KEY]?: unknown })[RESIDENT_BLOB_SENTINEL_KEY] === true) return true;
1269
+ if (seen.has(value)) return false;
1270
+ seen.add(value);
1271
+ if (Array.isArray(value)) return value.some(item => containsResidentSentinel(item, seen));
1272
+ for (const child of Object.values(value)) {
1273
+ if (containsResidentSentinel(child, seen)) return true;
1274
+ }
1275
+ return false;
1276
+ }
1202
1277
 
1203
1278
  /**
1204
1279
  * Recursively truncate large strings in an object for session persistence.
@@ -1257,6 +1332,7 @@ interface ResidentBlobStores {
1257
1332
  imageStore: BlobStore;
1258
1333
  sessionId?: string;
1259
1334
  sessionFile?: string;
1335
+ onResidentBlobRead?: (kind: ResidentBlobKind) => void;
1260
1336
  }
1261
1337
 
1262
1338
  type ResidentBlobMissingPolicy = "throw" | "placeholder";
@@ -1344,6 +1420,7 @@ function materializeResidentValueSync(
1344
1420
  }
1345
1421
  }
1346
1422
  cache.set(cacheKey, resolved);
1423
+ stores.onResidentBlobRead?.(obj.kind);
1347
1424
  return resolved;
1348
1425
  }
1349
1426
  if (Array.isArray(obj)) {
@@ -1435,6 +1512,308 @@ function cloneSessionEntry(entry: SessionEntry): SessionEntry {
1435
1512
  return { ...entry, message: cloneAgentMessage(entry.message) } as SessionEntry;
1436
1513
  }
1437
1514
 
1515
+ const COLD_SPILL_NOTICE = "[Compacted history content evicted to durable cold storage]";
1516
+ const COLD_SPILL_ARGUMENTS_SENTINEL_KEY = "__gjcColdSpillArguments";
1517
+ const COLD_SPILL_MIN_CHARS = 1024;
1518
+
1519
+ type ColdSpillWrite = {
1520
+ path: string;
1521
+ encoding: "utf8" | "json";
1522
+ data: Buffer;
1523
+ originalChars: number;
1524
+ };
1525
+
1526
+ type ColdSpillResidentPromotion = {
1527
+ stores: ResidentBlobStores;
1528
+ };
1529
+
1530
+ function isRecord(value: unknown): value is Record<string, unknown> {
1531
+ return typeof value === "object" && value !== null && !Array.isArray(value);
1532
+ }
1533
+
1534
+ function isColdSpillRef(value: unknown): value is ColdSpillRef {
1535
+ return (
1536
+ isRecord(value) &&
1537
+ value.kind === "cold_spill" &&
1538
+ typeof value.ref === "string" &&
1539
+ (value.encoding === "utf8" || value.encoding === "json") &&
1540
+ typeof value.originalChars === "number" &&
1541
+ typeof value.sha256 === "string" &&
1542
+ typeof value.bytes === "number"
1543
+ );
1544
+ }
1545
+
1546
+ function isColdSpillArgumentsSentinel(value: unknown): value is Record<string, unknown> {
1547
+ return isRecord(value) && value[COLD_SPILL_ARGUMENTS_SENTINEL_KEY] === true;
1548
+ }
1549
+
1550
+ function residentBlobBytesForColdSpill(value: ResidentBlobSentinel, promotion: ColdSpillResidentPromotion): Buffer {
1551
+ const hash = parseBlobRef(value.ref);
1552
+ if (!hash)
1553
+ throw new ResidentBlobMissingError(
1554
+ value.ref,
1555
+ value.kind,
1556
+ promotion.stores.sessionId,
1557
+ promotion.stores.sessionFile,
1558
+ );
1559
+ const store = value.kind === "text" ? promotion.stores.textStore : promotion.stores.imageStore;
1560
+ const data = store.getSync(hash);
1561
+ if (!data)
1562
+ throw new ResidentBlobMissingError(hash, value.kind, promotion.stores.sessionId, promotion.stores.sessionFile);
1563
+ promotion.stores.onResidentBlobRead?.(value.kind);
1564
+ if (value.kind === "imageData") return Buffer.from(data.toString("base64"), "utf8");
1565
+ return Buffer.from(data);
1566
+ }
1567
+
1568
+ function coldSpillResidentValue(
1569
+ value: ResidentBlobSentinel,
1570
+ basePath: string,
1571
+ writes: ColdSpillWrite[],
1572
+ promotion: ColdSpillResidentPromotion,
1573
+ ): string {
1574
+ const data = residentBlobBytesForColdSpill(value, promotion);
1575
+ writes.push({ path: basePath, encoding: "utf8", data, originalChars: data.byteLength });
1576
+ return COLD_SPILL_NOTICE;
1577
+ }
1578
+
1579
+ function coldSpillTextValue(value: string, basePath: string, writes: ColdSpillWrite[]): string {
1580
+ writes.push({ path: basePath, encoding: "utf8", data: Buffer.from(value, "utf8"), originalChars: value.length });
1581
+ return COLD_SPILL_NOTICE;
1582
+ }
1583
+
1584
+ function coldSpillJsonValue(value: unknown, basePath: string, writes: ColdSpillWrite[]): Record<string, unknown> {
1585
+ const json = JSON.stringify(value);
1586
+ writes.push({ path: basePath, encoding: "json", data: Buffer.from(json, "utf8"), originalChars: json.length });
1587
+ return {
1588
+ [COLD_SPILL_ARGUMENTS_SENTINEL_KEY]: true,
1589
+ refPath: basePath,
1590
+ notice: COLD_SPILL_NOTICE,
1591
+ };
1592
+ }
1593
+
1594
+ function coldSpillSubtreeValue(
1595
+ value: unknown,
1596
+ basePath: string,
1597
+ writes: ColdSpillWrite[],
1598
+ promotion: ColdSpillResidentPromotion,
1599
+ ): unknown {
1600
+ if (isResidentBlobSentinel(value)) return coldSpillResidentValue(value, basePath, writes, promotion);
1601
+ if (isColdSpillArgumentsSentinel(value)) return value;
1602
+ if (typeof value === "string") {
1603
+ return value.length >= COLD_SPILL_MIN_CHARS ? coldSpillTextValue(value, basePath, writes) : value;
1604
+ }
1605
+ if (Array.isArray(value)) {
1606
+ if (!containsResidentSentinel(value)) {
1607
+ const json = JSON.stringify(value);
1608
+ return json.length >= COLD_SPILL_MIN_CHARS ? coldSpillJsonValue(value, basePath, writes) : value;
1609
+ }
1610
+ let changed = false;
1611
+ const next = value.map((child, index) => {
1612
+ const replaced = coldSpillSubtreeValue(child, `${basePath}.${index}`, writes, promotion);
1613
+ if (replaced !== child) changed = true;
1614
+ return replaced;
1615
+ });
1616
+ return changed ? next : value;
1617
+ }
1618
+ if (!isRecord(value)) return value;
1619
+ if (!containsResidentSentinel(value)) {
1620
+ const json = JSON.stringify(value);
1621
+ return json.length >= COLD_SPILL_MIN_CHARS ? coldSpillJsonValue(value, basePath, writes) : value;
1622
+ }
1623
+ let changed = false;
1624
+ const entries = Object.entries(value).map(([key, child]) => {
1625
+ const replaced = coldSpillSubtreeValue(child, `${basePath}.${key}`, writes, promotion);
1626
+ if (replaced !== child) changed = true;
1627
+ return [key, replaced] as const;
1628
+ });
1629
+ return changed ? Object.fromEntries(entries) : value;
1630
+ }
1631
+
1632
+ function coldSpillArgumentsValue(
1633
+ value: unknown,
1634
+ basePath: string,
1635
+ writes: ColdSpillWrite[],
1636
+ promotion: ColdSpillResidentPromotion,
1637
+ ): unknown {
1638
+ return coldSpillSubtreeValue(value, basePath, writes, promotion);
1639
+ }
1640
+
1641
+ function coldSpillContentBlock(
1642
+ block: unknown,
1643
+ basePath: string,
1644
+ writes: ColdSpillWrite[],
1645
+ promotion: ColdSpillResidentPromotion,
1646
+ ): unknown {
1647
+ if (!isRecord(block) || typeof block.type !== "string") return block;
1648
+ if (isResidentBlobSentinel(block)) return coldSpillResidentValue(block, basePath, writes, promotion);
1649
+ if (block.type === "image") return block;
1650
+ if (block.type === "text") {
1651
+ const text = block.text;
1652
+ if (isResidentBlobSentinel(text))
1653
+ return { ...block, text: coldSpillResidentValue(text, `${basePath}.text`, writes, promotion) };
1654
+ if (typeof text !== "string" || text.length < COLD_SPILL_MIN_CHARS) return block;
1655
+ return { ...block, text: coldSpillTextValue(text, `${basePath}.text`, writes) };
1656
+ }
1657
+ if (block.type === "thinking") {
1658
+ const thinking = block.thinking;
1659
+ if (typeof thinking !== "string" || thinking.length < COLD_SPILL_MIN_CHARS) return block;
1660
+ return { ...block, thinking: coldSpillTextValue(thinking, `${basePath}.thinking`, writes) };
1661
+ }
1662
+ if (block.type === "redactedThinking") {
1663
+ const data = block.data;
1664
+ if (typeof data !== "string" || data.length < COLD_SPILL_MIN_CHARS) return block;
1665
+ return { ...block, data: coldSpillTextValue(data, `${basePath}.data`, writes) };
1666
+ }
1667
+ if (block.type === "toolCall") {
1668
+ const args = block.arguments;
1669
+ if (isColdSpillArgumentsSentinel(args)) return block;
1670
+ const json = JSON.stringify(args);
1671
+ if (json.length < COLD_SPILL_MIN_CHARS && !containsResidentSentinel(args)) return block;
1672
+ const nextArgs = coldSpillArgumentsValue(args, `${basePath}.arguments`, writes, promotion);
1673
+ return nextArgs === args ? block : { ...block, arguments: nextArgs };
1674
+ }
1675
+ let changed = false;
1676
+ const entries = Object.entries(block).map(([key, child]) => {
1677
+ const replaced = key === "type" ? child : coldSpillSubtreeValue(child, `${basePath}.${key}`, writes, promotion);
1678
+ if (replaced !== child) changed = true;
1679
+ return [key, replaced] as const;
1680
+ });
1681
+ return changed ? Object.fromEntries(entries) : block;
1682
+ }
1683
+
1684
+ function coldSpillContentBlocks(
1685
+ value: unknown[],
1686
+ basePath: string,
1687
+ writes: ColdSpillWrite[],
1688
+ promotion: ColdSpillResidentPromotion,
1689
+ ): unknown {
1690
+ if (!containsResidentSentinel(value)) {
1691
+ let changedRuns = false;
1692
+ const merged: unknown[] = [];
1693
+ for (let index = 0; index < value.length; index++) {
1694
+ const block = value[index];
1695
+ if (isRecord(block) && block.type === "text" && typeof block.text === "string") {
1696
+ const start = index;
1697
+ const texts: string[] = [];
1698
+ while (index < value.length) {
1699
+ const runBlock = value[index];
1700
+ if (!isRecord(runBlock) || runBlock.type !== "text" || typeof runBlock.text !== "string") break;
1701
+ texts.push(runBlock.text);
1702
+ index++;
1703
+ }
1704
+ index--;
1705
+ const text = texts.join("");
1706
+ if (text.length >= COLD_SPILL_MIN_CHARS) {
1707
+ changedRuns = true;
1708
+ merged.push({ ...block, text: coldSpillTextValue(text, `${basePath}.${start}.text`, writes) });
1709
+ } else {
1710
+ merged.push(...value.slice(start, index + 1));
1711
+ }
1712
+ continue;
1713
+ }
1714
+ const replaced = coldSpillContentBlock(block, `${basePath}.${index}`, writes, promotion);
1715
+ if (replaced !== block) changedRuns = true;
1716
+ merged.push(replaced);
1717
+ }
1718
+ if (changedRuns) return merged;
1719
+ }
1720
+ let changed = false;
1721
+ const next = value.map((block, index) => {
1722
+ const replaced = coldSpillContentBlock(block, `${basePath}.${index}`, writes, promotion);
1723
+ if (replaced !== block) changed = true;
1724
+ return replaced;
1725
+ });
1726
+ return changed ? next : value;
1727
+ }
1728
+
1729
+ function coldSpillCustomMessageContent(
1730
+ content: CustomMessageEntry["content"],
1731
+ writes: ColdSpillWrite[],
1732
+ promotion: ColdSpillResidentPromotion,
1733
+ ): CustomMessageEntry["content"] {
1734
+ if (typeof content === "string") {
1735
+ return content.length >= COLD_SPILL_MIN_CHARS
1736
+ ? coldSpillTextValue(content, "custom_message.content", writes)
1737
+ : content;
1738
+ }
1739
+ if (Array.isArray(content))
1740
+ return coldSpillContentBlocks(
1741
+ content,
1742
+ "custom_message.content",
1743
+ writes,
1744
+ promotion,
1745
+ ) as CustomMessageEntry["content"];
1746
+ return content;
1747
+ }
1748
+
1749
+ function coldSpillUnavailable(ref: ColdSpillRef): string {
1750
+ return `[Cold-spill blob unavailable: ${ref.ref}; original ${ref.originalChars} chars unavailable]`;
1751
+ }
1752
+
1753
+ function rehydrateColdSpillRef(ref: ColdSpillRef, blobStore: BlobStore, residentStores?: ResidentBlobStores): unknown {
1754
+ const hash = ref.ref.startsWith("blob:sha256:") ? ref.ref.slice("blob:sha256:".length) : ref.sha256;
1755
+ const data = blobStore.getCheckedSync(hash);
1756
+ if (!data || hash !== ref.sha256) return coldSpillUnavailable(ref);
1757
+ const text = data.toString("utf8");
1758
+ if (ref.encoding === "json") {
1759
+ try {
1760
+ const parsed = JSON.parse(text) as unknown;
1761
+ return residentStores ? materializeResidentValueSync(parsed, residentStores) : parsed;
1762
+ } catch {
1763
+ return coldSpillUnavailable(ref);
1764
+ }
1765
+ }
1766
+ return text;
1767
+ }
1768
+
1769
+ function rehydrateColdSpillValue(
1770
+ value: unknown,
1771
+ marker: EvictedContentMarker | undefined,
1772
+ blobStore: BlobStore,
1773
+ basePath: string,
1774
+ residentStores?: ResidentBlobStores,
1775
+ ): unknown {
1776
+ const directRef = marker?.payloads[basePath];
1777
+ if (directRef) return rehydrateColdSpillRef(directRef, blobStore, residentStores);
1778
+ if (isColdSpillArgumentsSentinel(value) && typeof value.refPath === "string") {
1779
+ const ref = marker?.payloads[value.refPath];
1780
+ return ref ? rehydrateColdSpillRef(ref, blobStore, residentStores) : value;
1781
+ }
1782
+ if (Array.isArray(value))
1783
+ return value.map((item, index) =>
1784
+ rehydrateColdSpillValue(item, marker, blobStore, `${basePath}.${index}`, residentStores),
1785
+ );
1786
+ if (!isRecord(value)) return value;
1787
+ const entries = Object.entries(value).map(([key, child]) => {
1788
+ if (key === "evictedContent") return [key, child] as const;
1789
+ return [key, rehydrateColdSpillValue(child, marker, blobStore, `${basePath}.${key}`, residentStores)] as const;
1790
+ });
1791
+ return Object.fromEntries(entries);
1792
+ }
1793
+
1794
+ function rehydrateColdSpillEntry(
1795
+ entry: SessionEntry,
1796
+ blobStore: BlobStore,
1797
+ residentStores?: ResidentBlobStores,
1798
+ ): SessionEntry {
1799
+ if (entry.type === "message") {
1800
+ const marker = entry.evictedContent;
1801
+ const message = rehydrateColdSpillValue(
1802
+ entry.message,
1803
+ marker,
1804
+ blobStore,
1805
+ "message",
1806
+ residentStores,
1807
+ ) as AgentMessage;
1808
+ return { ...entry, message };
1809
+ }
1810
+ if (entry.type === "custom_message") {
1811
+ const marker = entry.evictedContent;
1812
+ return rehydrateColdSpillValue(entry, marker, blobStore, "custom_message", residentStores) as CustomMessageEntry;
1813
+ }
1814
+ return cloneSessionEntry(entry);
1815
+ }
1816
+
1438
1817
  async function truncateForPersistence(obj: FileEntry, blobStore: BlobStore, key?: string): Promise<FileEntry>;
1439
1818
  async function truncateForPersistence(obj: string, blobStore: BlobStore, key?: string): Promise<string>;
1440
1819
  async function truncateForPersistence(obj: unknown[], blobStore: BlobStore, key?: string): Promise<unknown[]>;
@@ -2171,6 +2550,8 @@ interface SessionManagerStateSnapshot {
2171
2550
 
2172
2551
  export class SessionManager {
2173
2552
  #sessionId: string = "";
2553
+ /** True once a lifecycle pre-allocated id has been adopted (consume-once). */
2554
+ #lifecycleIdAdopted: boolean = false;
2174
2555
  #sessionName: string | undefined;
2175
2556
  #titleSource: "auto" | "user" | undefined;
2176
2557
  #sessionFile: string | undefined;
@@ -2220,6 +2601,16 @@ export class SessionManager {
2220
2601
  #sessionContextEntryRevision = -1;
2221
2602
  #sessionContextLeafRevision = -1;
2222
2603
  #sessionContextReplayMetadataRevision = -1;
2604
+ #coldSpillWriteCount = 0;
2605
+ #coldSpillReadCount = 0;
2606
+ #residentTextReadCount = 0;
2607
+ #residentImageReadCount = 0;
2608
+ #publicMaterializerCallCount = 0;
2609
+ #getEntryMaterializerCallCount = 0;
2610
+ #getBranchMaterializerCallCount = 0;
2611
+ #getEntriesMaterializerCallCount = 0;
2612
+ #materializedEntriesCachePopulateCount = 0;
2613
+ #pathOnlyContextBuildCount = 0;
2223
2614
 
2224
2615
  private constructor(
2225
2616
  private cwd: string,
@@ -2244,6 +2635,19 @@ export class SessionManager {
2244
2635
  };
2245
2636
  }
2246
2637
 
2638
+ #residentBlobStoresForColdRehydrate(): ResidentBlobStores {
2639
+ return {
2640
+ ...this.#residentBlobStores(),
2641
+ onResidentBlobRead: kind => {
2642
+ if (kind === "text") {
2643
+ this.#residentTextReadCount++;
2644
+ } else {
2645
+ this.#residentImageReadCount++;
2646
+ }
2647
+ },
2648
+ };
2649
+ }
2650
+
2247
2651
  #residentCacheDir(sessionFile: string): string {
2248
2652
  const instance = ++residentCacheInstanceCounter;
2249
2653
  return path.join(
@@ -2615,7 +3019,12 @@ export class SessionManager {
2615
3019
  this.#persistChain = Promise.resolve();
2616
3020
  this.#persistError = undefined;
2617
3021
  this.#persistErrorReported = false;
2618
- this.#sessionId = createSessionId();
3022
+ // Adopt a lifecycle pre-allocated id exactly once (the initial session of a
3023
+ // /session_create child); later new-session paths (/new, fork, branch) get
3024
+ // fresh ids so they cannot reuse the original GJC_SESSION_ID.
3025
+ const preallocated = this.#lifecycleIdAdopted ? undefined : lifecyclePreallocatedSessionId();
3026
+ if (preallocated) this.#lifecycleIdAdopted = true;
3027
+ this.#sessionId = preallocated ?? createSessionId();
2619
3028
  this.#sessionName = undefined;
2620
3029
  this.#titleSource = undefined;
2621
3030
  const timestamp = new Date().toISOString();
@@ -3589,7 +3998,214 @@ export class SessionManager {
3589
3998
  return undefined;
3590
3999
  }
3591
4000
 
4001
+ evictCompactedContent(firstKeptEntryId: string, compactionEntryId: string): EvictCompactedContentResult {
4002
+ const firstKept = this.#byId.get(firstKeptEntryId);
4003
+ const compaction = this.#byId.get(compactionEntryId);
4004
+ if (!firstKept) throw new Error(`Entry ${firstKeptEntryId} not found`);
4005
+ if (!compaction || compaction.type !== "compaction")
4006
+ throw new Error(`Compaction entry ${compactionEntryId} not found`);
4007
+ const ids: string[] = [];
4008
+ let current: SessionEntry | undefined = compaction;
4009
+ while (current) {
4010
+ ids.push(current.id);
4011
+ current = current.parentId ? this.#byId.get(current.parentId) : undefined;
4012
+ }
4013
+ ids.reverse();
4014
+ let evictedEntries = 0;
4015
+ let hotCharsRemoved = 0;
4016
+ let coldBlobBytes = 0;
4017
+ let payloadRefs = 0;
4018
+ let alreadyEvictedEntries = 0;
4019
+ let mutated = false;
4020
+ try {
4021
+ for (const id of ids) {
4022
+ if (id === firstKeptEntryId) break;
4023
+ const entry = this.#byId.get(id);
4024
+ if (!entry || entry.type === "compaction") continue;
4025
+ if (entry.type !== "message" && entry.type !== "custom_message") continue;
4026
+ if (entry.evictedContent?.reason === "compacted_history") {
4027
+ alreadyEvictedEntries++;
4028
+ continue;
4029
+ }
4030
+ const beforeChars = JSON.stringify(entry).length;
4031
+ const writes: ColdSpillWrite[] = [];
4032
+ const nextEntry = this.#coldSpillClone(entry, writes);
4033
+ if (writes.length === 0 || nextEntry === entry) continue;
4034
+ const payloads: Record<string, ColdSpillRef> = {};
4035
+ for (const write of writes) {
4036
+ const put = this.#blobStore.putImmutableSync(write.data);
4037
+ this.#coldSpillWriteCount++;
4038
+ payloads[write.path] = {
4039
+ kind: "cold_spill",
4040
+ ref: put.ref,
4041
+ encoding: write.encoding,
4042
+ originalChars: write.originalChars,
4043
+ sha256: put.hash,
4044
+ bytes: put.bytes,
4045
+ };
4046
+ coldBlobBytes += put.bytes;
4047
+ }
4048
+ const marker: EvictedContentMarker = {
4049
+ evictedAt: Date.now(),
4050
+ reason: "compacted_history",
4051
+ compactionEntryId,
4052
+ firstKeptEntryId,
4053
+ payloads,
4054
+ };
4055
+ // Store the marker at the ENTRY level (session metadata), not on the
4056
+ // strict message type, so message shapes stay type-clean.
4057
+ if (nextEntry.type === "message" || nextEntry.type === "custom_message") {
4058
+ nextEntry.evictedContent = marker;
4059
+ }
4060
+ this.#replaceCanonicalEntry(nextEntry);
4061
+ mutated = true;
4062
+ evictedEntries++;
4063
+ payloadRefs += writes.length;
4064
+ hotCharsRemoved += Math.max(0, beforeChars - JSON.stringify(nextEntry).length);
4065
+ }
4066
+ } finally {
4067
+ if (mutated) {
4068
+ this.#needsFullRewriteOnNextPersist = true;
4069
+ this.#bumpEntryRevision();
4070
+ this.#replayMetadataRevision++;
4071
+ this.#materializedEntriesCache = undefined;
4072
+ this.#materializedEntriesRevision = -1;
4073
+ this.#sessionContextCache = undefined;
4074
+ }
4075
+ }
4076
+ return {
4077
+ evictedEntries,
4078
+ hotCharsRemoved,
4079
+ coldBlobBytes,
4080
+ payloadRefs,
4081
+ alreadyEvictedEntries,
4082
+ coldSpillWriteCount: this.#coldSpillWriteCount,
4083
+ coldSpillReadCount: this.#coldSpillReadCount,
4084
+ residentTextReadCount: this.#residentTextReadCount,
4085
+ residentImageReadCount: this.#residentImageReadCount,
4086
+ };
4087
+ }
4088
+
4089
+ #coldSpillClone(entry: SessionEntry, writes: ColdSpillWrite[]): SessionEntry {
4090
+ if (entry.type === "message") {
4091
+ const content = "content" in entry.message ? entry.message.content : undefined;
4092
+ if (!Array.isArray(content)) return entry;
4093
+ const nextContent = coldSpillContentBlocks(content, "message.content", writes, {
4094
+ stores: this.#residentBlobStoresForColdRehydrate(),
4095
+ });
4096
+ return nextContent === content
4097
+ ? entry
4098
+ : { ...entry, message: { ...entry.message, content: nextContent } as AgentMessage };
4099
+ }
4100
+ if (entry.type === "custom_message") {
4101
+ const content = coldSpillCustomMessageContent(entry.content, writes, {
4102
+ stores: this.#residentBlobStoresForColdRehydrate(),
4103
+ });
4104
+ return content === entry.content ? entry : { ...entry, content };
4105
+ }
4106
+ return entry;
4107
+ }
4108
+
4109
+ #replaceCanonicalEntry(entry: SessionEntry): void {
4110
+ this.#byId.set(entry.id, entry);
4111
+ const index = this.#fileEntries.findIndex(candidate => candidate.type !== "session" && candidate.id === entry.id);
4112
+ if (index >= 0) this.#fileEntries[index] = entry;
4113
+ }
4114
+
4115
+ getObservabilityStatsForTests(): SessionManagerObservabilityStats {
4116
+ return {
4117
+ coldSpillWriteCount: this.#coldSpillWriteCount,
4118
+ coldSpillReadCount: this.#coldSpillReadCount,
4119
+ residentTextReadCount: this.#residentTextReadCount,
4120
+ residentImageReadCount: this.#residentImageReadCount,
4121
+ publicMaterializerCallCount: this.#publicMaterializerCallCount,
4122
+ getEntryMaterializerCallCount: this.#getEntryMaterializerCallCount,
4123
+ getBranchMaterializerCallCount: this.#getBranchMaterializerCallCount,
4124
+ getEntriesMaterializerCallCount: this.#getEntriesMaterializerCallCount,
4125
+ materializedEntriesCachePopulateCount: this.#materializedEntriesCachePopulateCount,
4126
+ pathOnlyContextBuildCount: this.#pathOnlyContextBuildCount,
4127
+ };
4128
+ }
4129
+
4130
+ hotRetainedMessageCharsForTests(): number {
4131
+ let total = 0;
4132
+ for (const entry of this.#fileEntries) {
4133
+ if (entry.type !== "message" && entry.type !== "custom_message") continue;
4134
+ total += JSON.stringify(entry).length;
4135
+ }
4136
+ return total;
4137
+ }
4138
+
4139
+ getCanonicalEntryForTests(id: string): SessionEntry | undefined {
4140
+ const entry = this.#byId.get(id);
4141
+ return entry ? cloneSessionEntry(entry) : undefined;
4142
+ }
4143
+
4144
+ getEntryForFidelity(id: string): SessionEntry | undefined {
4145
+ const entry = this.#byId.get(id);
4146
+ return entry
4147
+ ? rehydrateColdSpillEntry(
4148
+ materializeResidentEntrySync(entry, this.#residentBlobStores(), new Map()),
4149
+ this.#blobStore,
4150
+ this.#residentBlobStoresForColdRehydrate(),
4151
+ )
4152
+ : undefined;
4153
+ }
4154
+
4155
+ getBranchForFidelity(fromId?: string): SessionEntry[] {
4156
+ const cache = new Map<string, string>();
4157
+ const path: SessionEntry[] = [];
4158
+ let current = (fromId ?? this.#leafId) ? this.#byId.get(fromId ?? this.#leafId ?? "") : undefined;
4159
+ while (current) {
4160
+ path.push(
4161
+ rehydrateColdSpillEntry(
4162
+ materializeResidentEntrySync(current, this.#residentBlobStores(), cache),
4163
+ this.#blobStore,
4164
+ this.#residentBlobStoresForColdRehydrate(),
4165
+ ),
4166
+ );
4167
+ current = current.parentId ? this.#byId.get(current.parentId) : undefined;
4168
+ }
4169
+ path.reverse();
4170
+ return path;
4171
+ }
4172
+
4173
+ #getCanonicalBranchClones(fromId?: string): SessionEntry[] {
4174
+ const path: SessionEntry[] = [];
4175
+ let current = (fromId ?? this.#leafId) ? this.#byId.get(fromId ?? this.#leafId ?? "") : undefined;
4176
+ while (current) {
4177
+ path.push(cloneSessionEntry(current));
4178
+ current = current.parentId ? this.#byId.get(current.parentId) : undefined;
4179
+ }
4180
+ path.reverse();
4181
+ return path;
4182
+ }
4183
+
4184
+ /**
4185
+ * Walk the active branch without materializing resident blobs or rehydrating
4186
+ * cold-spill payloads. Intended for metadata-only scans such as todo-phase
4187
+ * sync; callers must not mutate returned entries.
4188
+ */
4189
+ getActivePathEntriesCanonical(fromId?: string): SessionEntry[] {
4190
+ return this.#getCanonicalBranchClones(fromId);
4191
+ }
4192
+
4193
+ getEntriesForExport(): SessionEntry[] {
4194
+ const cache = new Map<string, string>();
4195
+ return this.#fileEntries
4196
+ .filter((entry): entry is SessionEntry => entry.type !== "session")
4197
+ .map(entry =>
4198
+ rehydrateColdSpillEntry(
4199
+ materializeResidentEntrySync(entry, this.#residentBlobStores(), cache),
4200
+ this.#blobStore,
4201
+ this.#residentBlobStoresForColdRehydrate(),
4202
+ ),
4203
+ );
4204
+ }
4205
+
3592
4206
  getEntry(id: string): SessionEntry | undefined {
4207
+ this.#publicMaterializerCallCount++;
4208
+ this.#getEntryMaterializerCallCount++;
3593
4209
  const entry = this.#byId.get(id);
3594
4210
  return entry ? materializeResidentEntrySync(entry, this.#residentBlobStores(), new Map()) : undefined;
3595
4211
  }
@@ -3647,6 +4263,8 @@ export class SessionManager {
3647
4263
  * Use buildSessionContext() to get the resolved messages for the LLM.
3648
4264
  */
3649
4265
  getBranch(fromId?: string): SessionEntry[] {
4266
+ this.#publicMaterializerCallCount++;
4267
+ this.#getBranchMaterializerCallCount++;
3650
4268
  const cache = new Map<string, string>();
3651
4269
  const path: SessionEntry[] = [];
3652
4270
  const startId = fromId ?? this.#leafId;
@@ -3673,13 +4291,60 @@ export class SessionManager {
3673
4291
  ) {
3674
4292
  return cloneSessionContext(cached);
3675
4293
  }
3676
- const context = buildSessionContext(this.#getMaterializedEntriesInternal(), this.#leafId);
4294
+ this.#pathOnlyContextBuildCount++;
4295
+ const context = buildSessionContext(this.#getActivePathEntriesForProviderContext(), this.#leafId);
3677
4296
  this.#sessionContextCache = new WeakRef(context);
3678
4297
  this.#sessionContextEntryRevision = this.#entryRevision;
3679
4298
  this.#sessionContextLeafRevision = this.#leafRevision;
3680
4299
  this.#sessionContextReplayMetadataRevision = this.#replayMetadataRevision;
3681
4300
  return cloneSessionContext(context);
3682
4301
  }
4302
+
4303
+ #getActivePathEntriesForProviderContext(fromId?: string | null): SessionEntry[] {
4304
+ if (fromId === null || (fromId === undefined && this.#leafId === null)) return [];
4305
+ const ids: string[] = [];
4306
+ let current = this.#byId.get(fromId ?? this.#leafId ?? "");
4307
+ while (current) {
4308
+ ids.push(current.id);
4309
+ current = current.parentId ? this.#byId.get(current.parentId) : undefined;
4310
+ }
4311
+ ids.reverse();
4312
+ const pathEntries = ids
4313
+ .map(id => this.#byId.get(id))
4314
+ .filter((entry): entry is SessionEntry => entry !== undefined);
4315
+ let compaction: CompactionEntry | undefined;
4316
+ for (const entry of pathEntries) if (entry.type === "compaction") compaction = entry;
4317
+ if (!compaction) return pathEntries.map(entry => this.#entryForProviderContext(entry, undefined));
4318
+ const compactionIndex = pathEntries.findIndex(entry => entry.id === compaction.id);
4319
+ const firstKeptIndex = pathEntries.findIndex(entry => entry.id === compaction.firstKeptEntryId);
4320
+ const remote = compaction.preserveData?.openaiRemoteCompaction;
4321
+ const hasRemoteReplacement = isRecord(remote) && Array.isArray(remote.replacementHistory);
4322
+ return pathEntries.map((entry, index) => {
4323
+ const covered =
4324
+ index < compactionIndex && (hasRemoteReplacement || (firstKeptIndex >= 0 && index < firstKeptIndex));
4325
+ return this.#entryForProviderContext(entry, covered ? "covered" : undefined);
4326
+ });
4327
+ }
4328
+
4329
+ #entryForProviderContext(entry: SessionEntry, coldSpillPolicy: "covered" | undefined): SessionEntry {
4330
+ if (coldSpillPolicy === "covered" && (entry.type === "message" || entry.type === "custom_message")) {
4331
+ return cloneSessionEntry(entry);
4332
+ }
4333
+ if (entry.type !== "message" && entry.type !== "custom_message") return cloneSessionEntry(entry);
4334
+ const materialized = materializeResidentEntrySync(entry, this.#residentBlobStores(), new Map());
4335
+ const rehydrated = rehydrateColdSpillEntry(
4336
+ materialized,
4337
+ this.#blobStore,
4338
+ this.#residentBlobStoresForColdRehydrate(),
4339
+ );
4340
+ if (rehydrated !== materialized) this.#coldSpillReadCount += this.#countColdSpillPayloads(entry);
4341
+ return rehydrated;
4342
+ }
4343
+
4344
+ #countColdSpillPayloads(entry: SessionEntry): number {
4345
+ const marker = entry.type === "message" || entry.type === "custom_message" ? entry.evictedContent : undefined;
4346
+ return marker ? Object.keys(marker.payloads ?? {}).length : 0;
4347
+ }
3683
4348
  /** Strip stale OpenAI Responses assistant replay metadata from loaded in-memory entries. */
3684
4349
  sanitizeLoadedOpenAIResponsesReplayMetadata(): boolean {
3685
4350
  let didSanitize = false;
@@ -3721,6 +4386,7 @@ export class SessionManager {
3721
4386
  if (this.#materializedEntriesRevision === this.#entryRevision && this.#materializedEntriesCache) {
3722
4387
  return this.#materializedEntriesCache;
3723
4388
  }
4389
+ this.#materializedEntriesCachePopulateCount++;
3724
4390
  const resolvedTextBlobCache = new Map<string, string>();
3725
4391
  const materializedEntries = this.#fileEntries
3726
4392
  .filter((e): e is SessionEntry => e.type !== "session")
@@ -3731,6 +4397,8 @@ export class SessionManager {
3731
4397
  }
3732
4398
 
3733
4399
  getEntries(): SessionEntry[] {
4400
+ this.#publicMaterializerCallCount++;
4401
+ this.#getEntriesMaterializerCallCount++;
3734
4402
  return this.#getMaterializedEntriesInternal().map(entry => cloneSessionEntry(entry));
3735
4403
  }
3736
4404
 
@@ -3837,14 +4505,17 @@ export class SessionManager {
3837
4505
  */
3838
4506
  createBranchedSession(leafId: string): string | undefined {
3839
4507
  const previousSessionFile = this.#sessionFile;
3840
- const branchPath = this.getBranch(leafId);
4508
+ const branchPath = this.#getCanonicalBranchClones(leafId);
3841
4509
  if (branchPath.length === 0) {
3842
4510
  throw new Error(`Entry ${leafId} not found`);
3843
4511
  }
3844
4512
 
3845
4513
  // Filter out LabelEntry from path - we'll recreate them from the resolved map
3846
4514
  const pathWithoutLabels = branchPath.filter(e => e.type !== "label");
3847
-
4515
+ const materializedPathWithoutLabels = materializeResidentEntriesSync(
4516
+ pathWithoutLabels,
4517
+ this.#residentBlobStores(),
4518
+ );
3848
4519
  const newSessionId = createSessionId();
3849
4520
  const timestamp = new Date().toISOString();
3850
4521
  const fileTimestamp = timestamp.replace(/[:.]/g, "-");
@@ -3871,7 +4542,7 @@ export class SessionManager {
3871
4542
  if (this.persist) {
3872
4543
  const lines: string[] = [];
3873
4544
  lines.push(JSON.stringify(header));
3874
- for (const entry of pathWithoutLabels) {
4545
+ for (const entry of materializedPathWithoutLabels) {
3875
4546
  lines.push(JSON.stringify(prepareEntryForPersistenceSync(entry, this.#blobStore)));
3876
4547
  }
3877
4548
  // Write fresh label entries at the end
@@ -3898,7 +4569,7 @@ export class SessionManager {
3898
4569
  this.#resetResidentTextBlobStore();
3899
4570
  this.#fileEntries = [
3900
4571
  header,
3901
- ...pathWithoutLabels.map(
4572
+ ...materializedPathWithoutLabels.map(
3902
4573
  entry => prepareEntryForResidentSync(entry, this.#residentBlobStores()) as SessionEntry,
3903
4574
  ),
3904
4575
  ...labelEntries,
@@ -3928,7 +4599,7 @@ export class SessionManager {
3928
4599
  this.#resetResidentTextBlobStore();
3929
4600
  this.#fileEntries = [
3930
4601
  header,
3931
- ...pathWithoutLabels.map(
4602
+ ...materializedPathWithoutLabels.map(
3932
4603
  entry => prepareEntryForResidentSync(entry, this.#residentBlobStores()) as SessionEntry,
3933
4604
  ),
3934
4605
  ...labelEntries,