@gajae-code/coding-agent 0.7.3 → 0.7.5

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 (118) hide show
  1. package/CHANGELOG.md +58 -0
  2. package/bin/gjc.js +4 -0
  3. package/dist/types/cli/plugin-cli.d.ts +2 -0
  4. package/dist/types/commands/plugin.d.ts +6 -0
  5. package/dist/types/commands/session.d.ts +6 -0
  6. package/dist/types/config/model-profile-activation.d.ts +8 -1
  7. package/dist/types/extensibility/gjc-plugins/compiler.d.ts +19 -0
  8. package/dist/types/extensibility/gjc-plugins/constrained-hooks.d.ts +29 -0
  9. package/dist/types/extensibility/gjc-plugins/index.d.ts +9 -0
  10. package/dist/types/extensibility/gjc-plugins/injection.d.ts +9 -0
  11. package/dist/types/extensibility/gjc-plugins/installer.d.ts +13 -0
  12. package/dist/types/extensibility/gjc-plugins/mcp-policy.d.ts +26 -0
  13. package/dist/types/extensibility/gjc-plugins/observability.d.ts +27 -0
  14. package/dist/types/extensibility/gjc-plugins/prompt-appendix.d.ts +16 -0
  15. package/dist/types/extensibility/gjc-plugins/registry.d.ts +32 -0
  16. package/dist/types/extensibility/gjc-plugins/runtime-adapters.d.ts +64 -0
  17. package/dist/types/extensibility/gjc-plugins/session-validation.d.ts +42 -0
  18. package/dist/types/extensibility/gjc-plugins/types.d.ts +158 -2
  19. package/dist/types/extensibility/gjc-plugins/validation.d.ts +8 -1
  20. package/dist/types/gjc-runtime/launch-tmux.d.ts +1 -0
  21. package/dist/types/gjc-runtime/psmux-detect.d.ts +78 -0
  22. package/dist/types/gjc-runtime/team-runtime.d.ts +2 -0
  23. package/dist/types/gjc-runtime/tmux-common.d.ts +30 -2
  24. package/dist/types/gjc-runtime/tmux-sessions.d.ts +18 -0
  25. package/dist/types/main.d.ts +2 -0
  26. package/dist/types/modes/components/model-selector.d.ts +6 -0
  27. package/dist/types/notifications/html-format.d.ts +11 -0
  28. package/dist/types/notifications/index.d.ts +149 -1
  29. package/dist/types/notifications/lifecycle-commands.d.ts +72 -0
  30. package/dist/types/notifications/lifecycle-control-runtime.d.ts +98 -0
  31. package/dist/types/notifications/lifecycle-orchestrator.d.ts +144 -0
  32. package/dist/types/notifications/rate-limit-pool.d.ts +2 -0
  33. package/dist/types/notifications/recent-activity.d.ts +35 -0
  34. package/dist/types/notifications/telegram-daemon.d.ts +60 -0
  35. package/dist/types/notifications/telegram-reference.d.ts +3 -1
  36. package/dist/types/notifications/topic-registry.d.ts +10 -9
  37. package/dist/types/runtime-mcp/types.d.ts +7 -0
  38. package/dist/types/sdk.d.ts +2 -0
  39. package/dist/types/session/agent-session.d.ts +14 -4
  40. package/dist/types/session/blob-store.d.ts +25 -0
  41. package/dist/types/session/session-manager.d.ts +57 -0
  42. package/dist/types/slash-commands/helpers/fast-status-report.d.ts +6 -0
  43. package/dist/types/system-prompt.d.ts +2 -0
  44. package/dist/types/task/executor.d.ts +9 -1
  45. package/dist/types/tools/index.d.ts +3 -1
  46. package/dist/types/utils/changelog.d.ts +1 -0
  47. package/package.json +11 -9
  48. package/scripts/g004-tmux-smoke.ts +100 -0
  49. package/scripts/g005-daemon-smoke.ts +181 -0
  50. package/scripts/g011-daemon-path-smoke.ts +153 -0
  51. package/src/cli/plugin-cli.ts +66 -3
  52. package/src/cli.ts +21 -4
  53. package/src/commands/plugin.ts +4 -0
  54. package/src/commands/session.ts +18 -0
  55. package/src/config/model-profile-activation.ts +55 -7
  56. package/src/defaults/gjc/extensions/grok-cli-vendor/biome.json +1 -1
  57. package/src/defaults/gjc/skills/deep-interview/SKILL.md +3 -3
  58. package/src/defaults/gjc/skills/team/SKILL.md +5 -4
  59. package/src/defaults/gjc/skills/ultragoal/SKILL.md +41 -13
  60. package/src/export/html/index.ts +2 -2
  61. package/src/extensibility/gjc-plugins/compiler.ts +351 -0
  62. package/src/extensibility/gjc-plugins/constrained-hooks.ts +170 -0
  63. package/src/extensibility/gjc-plugins/index.ts +9 -0
  64. package/src/extensibility/gjc-plugins/injection.ts +109 -0
  65. package/src/extensibility/gjc-plugins/installer.ts +434 -0
  66. package/src/extensibility/gjc-plugins/loader.ts +3 -1
  67. package/src/extensibility/gjc-plugins/mcp-policy.ts +239 -0
  68. package/src/extensibility/gjc-plugins/observability.ts +84 -0
  69. package/src/extensibility/gjc-plugins/paths.ts +1 -1
  70. package/src/extensibility/gjc-plugins/prompt-appendix.ts +109 -0
  71. package/src/extensibility/gjc-plugins/registry.ts +180 -0
  72. package/src/extensibility/gjc-plugins/runtime-adapters.ts +234 -0
  73. package/src/extensibility/gjc-plugins/schema.ts +250 -20
  74. package/src/extensibility/gjc-plugins/session-validation.ts +147 -0
  75. package/src/extensibility/gjc-plugins/types.ts +199 -3
  76. package/src/extensibility/gjc-plugins/validation.ts +80 -0
  77. package/src/extensibility/skills.ts +15 -0
  78. package/src/gjc-runtime/launch-tmux.ts +58 -15
  79. package/src/gjc-runtime/psmux-detect.ts +239 -0
  80. package/src/gjc-runtime/team-runtime.ts +56 -23
  81. package/src/gjc-runtime/tmux-common.ts +85 -3
  82. package/src/gjc-runtime/tmux-sessions.ts +111 -9
  83. package/src/gjc-runtime/ultragoal-runtime.ts +75 -15
  84. package/src/internal-urls/docs-index.generated.ts +5 -4
  85. package/src/main.ts +14 -3
  86. package/src/modes/components/assistant-message.ts +49 -1
  87. package/src/modes/components/hook-editor.ts +1 -1
  88. package/src/modes/components/hook-selector.ts +67 -43
  89. package/src/modes/components/model-selector.ts +44 -11
  90. package/src/modes/controllers/extension-ui-controller.ts +0 -27
  91. package/src/modes/controllers/selector-controller.ts +50 -11
  92. package/src/modes/interactive-mode.ts +2 -0
  93. package/src/modes/utils/hotkeys-markdown.ts +1 -1
  94. package/src/notifications/html-format.ts +38 -0
  95. package/src/notifications/index.ts +242 -12
  96. package/src/notifications/lifecycle-commands.ts +228 -0
  97. package/src/notifications/lifecycle-control-runtime.ts +400 -0
  98. package/src/notifications/lifecycle-orchestrator.ts +358 -0
  99. package/src/notifications/rate-limit-pool.ts +19 -0
  100. package/src/notifications/recent-activity.ts +132 -0
  101. package/src/notifications/telegram-daemon.ts +433 -8
  102. package/src/notifications/telegram-reference.ts +25 -7
  103. package/src/notifications/topic-registry.ts +18 -9
  104. package/src/prompts/agents/executor.md +2 -2
  105. package/src/runtime-mcp/transports/stdio.ts +38 -4
  106. package/src/runtime-mcp/types.ts +7 -0
  107. package/src/sdk.ts +157 -10
  108. package/src/session/agent-session.ts +166 -74
  109. package/src/session/blob-store.ts +196 -8
  110. package/src/session/session-manager.ts +739 -12
  111. package/src/slash-commands/builtin-registry.ts +23 -3
  112. package/src/slash-commands/helpers/fast-status-report.ts +13 -3
  113. package/src/system-prompt.ts +9 -0
  114. package/src/task/executor.ts +31 -7
  115. package/src/task/index.ts +2 -0
  116. package/src/tools/ask.ts +5 -1
  117. package/src/tools/index.ts +3 -1
  118. package/src/utils/changelog.ts +8 -0
@@ -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++) {
@@ -576,8 +640,11 @@ export function buildSessionContext(
576
640
 
577
641
  // Walk from leaf to root, then reverse once to avoid repeated front insertions on long branches.
578
642
  const path: SessionEntry[] = [];
643
+ const visited = new Set<string>();
579
644
  let current: SessionEntry | undefined = leaf;
580
645
  while (current) {
646
+ if (visited.has(current.id)) break;
647
+ visited.add(current.id);
581
648
  path.push(current);
582
649
  current = current.parentId ? byId.get(current.parentId) : undefined;
583
650
  }
@@ -1199,6 +1266,17 @@ function isResidentBlobSentinel(value: unknown): value is ResidentBlobSentinel {
1199
1266
  isBlobRef((value as { ref: string }).ref)
1200
1267
  );
1201
1268
  }
1269
+ function containsResidentSentinel(value: unknown, seen = new WeakSet<object>()): boolean {
1270
+ if (value === null || value === undefined || typeof value !== "object") return false;
1271
+ if ((value as { [RESIDENT_BLOB_SENTINEL_KEY]?: unknown })[RESIDENT_BLOB_SENTINEL_KEY] === true) return true;
1272
+ if (seen.has(value)) return false;
1273
+ seen.add(value);
1274
+ if (Array.isArray(value)) return value.some(item => containsResidentSentinel(item, seen));
1275
+ for (const child of Object.values(value)) {
1276
+ if (containsResidentSentinel(child, seen)) return true;
1277
+ }
1278
+ return false;
1279
+ }
1202
1280
 
1203
1281
  /**
1204
1282
  * Recursively truncate large strings in an object for session persistence.
@@ -1257,6 +1335,7 @@ interface ResidentBlobStores {
1257
1335
  imageStore: BlobStore;
1258
1336
  sessionId?: string;
1259
1337
  sessionFile?: string;
1338
+ onResidentBlobRead?: (kind: ResidentBlobKind) => void;
1260
1339
  }
1261
1340
 
1262
1341
  type ResidentBlobMissingPolicy = "throw" | "placeholder";
@@ -1344,6 +1423,7 @@ function materializeResidentValueSync(
1344
1423
  }
1345
1424
  }
1346
1425
  cache.set(cacheKey, resolved);
1426
+ stores.onResidentBlobRead?.(obj.kind);
1347
1427
  return resolved;
1348
1428
  }
1349
1429
  if (Array.isArray(obj)) {
@@ -1435,6 +1515,308 @@ function cloneSessionEntry(entry: SessionEntry): SessionEntry {
1435
1515
  return { ...entry, message: cloneAgentMessage(entry.message) } as SessionEntry;
1436
1516
  }
1437
1517
 
1518
+ const COLD_SPILL_NOTICE = "[Compacted history content evicted to durable cold storage]";
1519
+ const COLD_SPILL_ARGUMENTS_SENTINEL_KEY = "__gjcColdSpillArguments";
1520
+ const COLD_SPILL_MIN_CHARS = 1024;
1521
+
1522
+ type ColdSpillWrite = {
1523
+ path: string;
1524
+ encoding: "utf8" | "json";
1525
+ data: Buffer;
1526
+ originalChars: number;
1527
+ };
1528
+
1529
+ type ColdSpillResidentPromotion = {
1530
+ stores: ResidentBlobStores;
1531
+ };
1532
+
1533
+ function isRecord(value: unknown): value is Record<string, unknown> {
1534
+ return typeof value === "object" && value !== null && !Array.isArray(value);
1535
+ }
1536
+
1537
+ function isColdSpillRef(value: unknown): value is ColdSpillRef {
1538
+ return (
1539
+ isRecord(value) &&
1540
+ value.kind === "cold_spill" &&
1541
+ typeof value.ref === "string" &&
1542
+ (value.encoding === "utf8" || value.encoding === "json") &&
1543
+ typeof value.originalChars === "number" &&
1544
+ typeof value.sha256 === "string" &&
1545
+ typeof value.bytes === "number"
1546
+ );
1547
+ }
1548
+
1549
+ function isColdSpillArgumentsSentinel(value: unknown): value is Record<string, unknown> {
1550
+ return isRecord(value) && value[COLD_SPILL_ARGUMENTS_SENTINEL_KEY] === true;
1551
+ }
1552
+
1553
+ function residentBlobBytesForColdSpill(value: ResidentBlobSentinel, promotion: ColdSpillResidentPromotion): Buffer {
1554
+ const hash = parseBlobRef(value.ref);
1555
+ if (!hash)
1556
+ throw new ResidentBlobMissingError(
1557
+ value.ref,
1558
+ value.kind,
1559
+ promotion.stores.sessionId,
1560
+ promotion.stores.sessionFile,
1561
+ );
1562
+ const store = value.kind === "text" ? promotion.stores.textStore : promotion.stores.imageStore;
1563
+ const data = store.getSync(hash);
1564
+ if (!data)
1565
+ throw new ResidentBlobMissingError(hash, value.kind, promotion.stores.sessionId, promotion.stores.sessionFile);
1566
+ promotion.stores.onResidentBlobRead?.(value.kind);
1567
+ if (value.kind === "imageData") return Buffer.from(data.toString("base64"), "utf8");
1568
+ return Buffer.from(data);
1569
+ }
1570
+
1571
+ function coldSpillResidentValue(
1572
+ value: ResidentBlobSentinel,
1573
+ basePath: string,
1574
+ writes: ColdSpillWrite[],
1575
+ promotion: ColdSpillResidentPromotion,
1576
+ ): string {
1577
+ const data = residentBlobBytesForColdSpill(value, promotion);
1578
+ writes.push({ path: basePath, encoding: "utf8", data, originalChars: data.byteLength });
1579
+ return COLD_SPILL_NOTICE;
1580
+ }
1581
+
1582
+ function coldSpillTextValue(value: string, basePath: string, writes: ColdSpillWrite[]): string {
1583
+ writes.push({ path: basePath, encoding: "utf8", data: Buffer.from(value, "utf8"), originalChars: value.length });
1584
+ return COLD_SPILL_NOTICE;
1585
+ }
1586
+
1587
+ function coldSpillJsonValue(value: unknown, basePath: string, writes: ColdSpillWrite[]): Record<string, unknown> {
1588
+ const json = JSON.stringify(value);
1589
+ writes.push({ path: basePath, encoding: "json", data: Buffer.from(json, "utf8"), originalChars: json.length });
1590
+ return {
1591
+ [COLD_SPILL_ARGUMENTS_SENTINEL_KEY]: true,
1592
+ refPath: basePath,
1593
+ notice: COLD_SPILL_NOTICE,
1594
+ };
1595
+ }
1596
+
1597
+ function coldSpillSubtreeValue(
1598
+ value: unknown,
1599
+ basePath: string,
1600
+ writes: ColdSpillWrite[],
1601
+ promotion: ColdSpillResidentPromotion,
1602
+ ): unknown {
1603
+ if (isResidentBlobSentinel(value)) return coldSpillResidentValue(value, basePath, writes, promotion);
1604
+ if (isColdSpillArgumentsSentinel(value)) return value;
1605
+ if (typeof value === "string") {
1606
+ return value.length >= COLD_SPILL_MIN_CHARS ? coldSpillTextValue(value, basePath, writes) : value;
1607
+ }
1608
+ if (Array.isArray(value)) {
1609
+ if (!containsResidentSentinel(value)) {
1610
+ const json = JSON.stringify(value);
1611
+ return json.length >= COLD_SPILL_MIN_CHARS ? coldSpillJsonValue(value, basePath, writes) : value;
1612
+ }
1613
+ let changed = false;
1614
+ const next = value.map((child, index) => {
1615
+ const replaced = coldSpillSubtreeValue(child, `${basePath}.${index}`, writes, promotion);
1616
+ if (replaced !== child) changed = true;
1617
+ return replaced;
1618
+ });
1619
+ return changed ? next : value;
1620
+ }
1621
+ if (!isRecord(value)) return value;
1622
+ if (!containsResidentSentinel(value)) {
1623
+ const json = JSON.stringify(value);
1624
+ return json.length >= COLD_SPILL_MIN_CHARS ? coldSpillJsonValue(value, basePath, writes) : value;
1625
+ }
1626
+ let changed = false;
1627
+ const entries = Object.entries(value).map(([key, child]) => {
1628
+ const replaced = coldSpillSubtreeValue(child, `${basePath}.${key}`, writes, promotion);
1629
+ if (replaced !== child) changed = true;
1630
+ return [key, replaced] as const;
1631
+ });
1632
+ return changed ? Object.fromEntries(entries) : value;
1633
+ }
1634
+
1635
+ function coldSpillArgumentsValue(
1636
+ value: unknown,
1637
+ basePath: string,
1638
+ writes: ColdSpillWrite[],
1639
+ promotion: ColdSpillResidentPromotion,
1640
+ ): unknown {
1641
+ return coldSpillSubtreeValue(value, basePath, writes, promotion);
1642
+ }
1643
+
1644
+ function coldSpillContentBlock(
1645
+ block: unknown,
1646
+ basePath: string,
1647
+ writes: ColdSpillWrite[],
1648
+ promotion: ColdSpillResidentPromotion,
1649
+ ): unknown {
1650
+ if (!isRecord(block) || typeof block.type !== "string") return block;
1651
+ if (isResidentBlobSentinel(block)) return coldSpillResidentValue(block, basePath, writes, promotion);
1652
+ if (block.type === "image") return block;
1653
+ if (block.type === "text") {
1654
+ const text = block.text;
1655
+ if (isResidentBlobSentinel(text))
1656
+ return { ...block, text: coldSpillResidentValue(text, `${basePath}.text`, writes, promotion) };
1657
+ if (typeof text !== "string" || text.length < COLD_SPILL_MIN_CHARS) return block;
1658
+ return { ...block, text: coldSpillTextValue(text, `${basePath}.text`, writes) };
1659
+ }
1660
+ if (block.type === "thinking") {
1661
+ const thinking = block.thinking;
1662
+ if (typeof thinking !== "string" || thinking.length < COLD_SPILL_MIN_CHARS) return block;
1663
+ return { ...block, thinking: coldSpillTextValue(thinking, `${basePath}.thinking`, writes) };
1664
+ }
1665
+ if (block.type === "redactedThinking") {
1666
+ const data = block.data;
1667
+ if (typeof data !== "string" || data.length < COLD_SPILL_MIN_CHARS) return block;
1668
+ return { ...block, data: coldSpillTextValue(data, `${basePath}.data`, writes) };
1669
+ }
1670
+ if (block.type === "toolCall") {
1671
+ const args = block.arguments;
1672
+ if (isColdSpillArgumentsSentinel(args)) return block;
1673
+ const json = JSON.stringify(args);
1674
+ if (json.length < COLD_SPILL_MIN_CHARS && !containsResidentSentinel(args)) return block;
1675
+ const nextArgs = coldSpillArgumentsValue(args, `${basePath}.arguments`, writes, promotion);
1676
+ return nextArgs === args ? block : { ...block, arguments: nextArgs };
1677
+ }
1678
+ let changed = false;
1679
+ const entries = Object.entries(block).map(([key, child]) => {
1680
+ const replaced = key === "type" ? child : coldSpillSubtreeValue(child, `${basePath}.${key}`, writes, promotion);
1681
+ if (replaced !== child) changed = true;
1682
+ return [key, replaced] as const;
1683
+ });
1684
+ return changed ? Object.fromEntries(entries) : block;
1685
+ }
1686
+
1687
+ function coldSpillContentBlocks(
1688
+ value: unknown[],
1689
+ basePath: string,
1690
+ writes: ColdSpillWrite[],
1691
+ promotion: ColdSpillResidentPromotion,
1692
+ ): unknown {
1693
+ if (!containsResidentSentinel(value)) {
1694
+ let changedRuns = false;
1695
+ const merged: unknown[] = [];
1696
+ for (let index = 0; index < value.length; index++) {
1697
+ const block = value[index];
1698
+ if (isRecord(block) && block.type === "text" && typeof block.text === "string") {
1699
+ const start = index;
1700
+ const texts: string[] = [];
1701
+ while (index < value.length) {
1702
+ const runBlock = value[index];
1703
+ if (!isRecord(runBlock) || runBlock.type !== "text" || typeof runBlock.text !== "string") break;
1704
+ texts.push(runBlock.text);
1705
+ index++;
1706
+ }
1707
+ index--;
1708
+ const text = texts.join("");
1709
+ if (text.length >= COLD_SPILL_MIN_CHARS) {
1710
+ changedRuns = true;
1711
+ merged.push({ ...block, text: coldSpillTextValue(text, `${basePath}.${start}.text`, writes) });
1712
+ } else {
1713
+ merged.push(...value.slice(start, index + 1));
1714
+ }
1715
+ continue;
1716
+ }
1717
+ const replaced = coldSpillContentBlock(block, `${basePath}.${index}`, writes, promotion);
1718
+ if (replaced !== block) changedRuns = true;
1719
+ merged.push(replaced);
1720
+ }
1721
+ if (changedRuns) return merged;
1722
+ }
1723
+ let changed = false;
1724
+ const next = value.map((block, index) => {
1725
+ const replaced = coldSpillContentBlock(block, `${basePath}.${index}`, writes, promotion);
1726
+ if (replaced !== block) changed = true;
1727
+ return replaced;
1728
+ });
1729
+ return changed ? next : value;
1730
+ }
1731
+
1732
+ function coldSpillCustomMessageContent(
1733
+ content: CustomMessageEntry["content"],
1734
+ writes: ColdSpillWrite[],
1735
+ promotion: ColdSpillResidentPromotion,
1736
+ ): CustomMessageEntry["content"] {
1737
+ if (typeof content === "string") {
1738
+ return content.length >= COLD_SPILL_MIN_CHARS
1739
+ ? coldSpillTextValue(content, "custom_message.content", writes)
1740
+ : content;
1741
+ }
1742
+ if (Array.isArray(content))
1743
+ return coldSpillContentBlocks(
1744
+ content,
1745
+ "custom_message.content",
1746
+ writes,
1747
+ promotion,
1748
+ ) as CustomMessageEntry["content"];
1749
+ return content;
1750
+ }
1751
+
1752
+ function coldSpillUnavailable(ref: ColdSpillRef): string {
1753
+ return `[Cold-spill blob unavailable: ${ref.ref}; original ${ref.originalChars} chars unavailable]`;
1754
+ }
1755
+
1756
+ function rehydrateColdSpillRef(ref: ColdSpillRef, blobStore: BlobStore, residentStores?: ResidentBlobStores): unknown {
1757
+ const hash = ref.ref.startsWith("blob:sha256:") ? ref.ref.slice("blob:sha256:".length) : ref.sha256;
1758
+ const data = blobStore.getCheckedSync(hash);
1759
+ if (!data || hash !== ref.sha256) return coldSpillUnavailable(ref);
1760
+ const text = data.toString("utf8");
1761
+ if (ref.encoding === "json") {
1762
+ try {
1763
+ const parsed = JSON.parse(text) as unknown;
1764
+ return residentStores ? materializeResidentValueSync(parsed, residentStores) : parsed;
1765
+ } catch {
1766
+ return coldSpillUnavailable(ref);
1767
+ }
1768
+ }
1769
+ return text;
1770
+ }
1771
+
1772
+ function rehydrateColdSpillValue(
1773
+ value: unknown,
1774
+ marker: EvictedContentMarker | undefined,
1775
+ blobStore: BlobStore,
1776
+ basePath: string,
1777
+ residentStores?: ResidentBlobStores,
1778
+ ): unknown {
1779
+ const directRef = marker?.payloads[basePath];
1780
+ if (directRef) return rehydrateColdSpillRef(directRef, blobStore, residentStores);
1781
+ if (isColdSpillArgumentsSentinel(value) && typeof value.refPath === "string") {
1782
+ const ref = marker?.payloads[value.refPath];
1783
+ return ref ? rehydrateColdSpillRef(ref, blobStore, residentStores) : value;
1784
+ }
1785
+ if (Array.isArray(value))
1786
+ return value.map((item, index) =>
1787
+ rehydrateColdSpillValue(item, marker, blobStore, `${basePath}.${index}`, residentStores),
1788
+ );
1789
+ if (!isRecord(value)) return value;
1790
+ const entries = Object.entries(value).map(([key, child]) => {
1791
+ if (key === "evictedContent") return [key, child] as const;
1792
+ return [key, rehydrateColdSpillValue(child, marker, blobStore, `${basePath}.${key}`, residentStores)] as const;
1793
+ });
1794
+ return Object.fromEntries(entries);
1795
+ }
1796
+
1797
+ function rehydrateColdSpillEntry(
1798
+ entry: SessionEntry,
1799
+ blobStore: BlobStore,
1800
+ residentStores?: ResidentBlobStores,
1801
+ ): SessionEntry {
1802
+ if (entry.type === "message") {
1803
+ const marker = entry.evictedContent;
1804
+ const message = rehydrateColdSpillValue(
1805
+ entry.message,
1806
+ marker,
1807
+ blobStore,
1808
+ "message",
1809
+ residentStores,
1810
+ ) as AgentMessage;
1811
+ return { ...entry, message };
1812
+ }
1813
+ if (entry.type === "custom_message") {
1814
+ const marker = entry.evictedContent;
1815
+ return rehydrateColdSpillValue(entry, marker, blobStore, "custom_message", residentStores) as CustomMessageEntry;
1816
+ }
1817
+ return cloneSessionEntry(entry);
1818
+ }
1819
+
1438
1820
  async function truncateForPersistence(obj: FileEntry, blobStore: BlobStore, key?: string): Promise<FileEntry>;
1439
1821
  async function truncateForPersistence(obj: string, blobStore: BlobStore, key?: string): Promise<string>;
1440
1822
  async function truncateForPersistence(obj: unknown[], blobStore: BlobStore, key?: string): Promise<unknown[]>;
@@ -2171,6 +2553,8 @@ interface SessionManagerStateSnapshot {
2171
2553
 
2172
2554
  export class SessionManager {
2173
2555
  #sessionId: string = "";
2556
+ /** True once a lifecycle pre-allocated id has been adopted (consume-once). */
2557
+ #lifecycleIdAdopted: boolean = false;
2174
2558
  #sessionName: string | undefined;
2175
2559
  #titleSource: "auto" | "user" | undefined;
2176
2560
  #sessionFile: string | undefined;
@@ -2220,6 +2604,16 @@ export class SessionManager {
2220
2604
  #sessionContextEntryRevision = -1;
2221
2605
  #sessionContextLeafRevision = -1;
2222
2606
  #sessionContextReplayMetadataRevision = -1;
2607
+ #coldSpillWriteCount = 0;
2608
+ #coldSpillReadCount = 0;
2609
+ #residentTextReadCount = 0;
2610
+ #residentImageReadCount = 0;
2611
+ #publicMaterializerCallCount = 0;
2612
+ #getEntryMaterializerCallCount = 0;
2613
+ #getBranchMaterializerCallCount = 0;
2614
+ #getEntriesMaterializerCallCount = 0;
2615
+ #materializedEntriesCachePopulateCount = 0;
2616
+ #pathOnlyContextBuildCount = 0;
2223
2617
 
2224
2618
  private constructor(
2225
2619
  private cwd: string,
@@ -2244,6 +2638,19 @@ export class SessionManager {
2244
2638
  };
2245
2639
  }
2246
2640
 
2641
+ #residentBlobStoresForColdRehydrate(): ResidentBlobStores {
2642
+ return {
2643
+ ...this.#residentBlobStores(),
2644
+ onResidentBlobRead: kind => {
2645
+ if (kind === "text") {
2646
+ this.#residentTextReadCount++;
2647
+ } else {
2648
+ this.#residentImageReadCount++;
2649
+ }
2650
+ },
2651
+ };
2652
+ }
2653
+
2247
2654
  #residentCacheDir(sessionFile: string): string {
2248
2655
  const instance = ++residentCacheInstanceCounter;
2249
2656
  return path.join(
@@ -2615,7 +3022,12 @@ export class SessionManager {
2615
3022
  this.#persistChain = Promise.resolve();
2616
3023
  this.#persistError = undefined;
2617
3024
  this.#persistErrorReported = false;
2618
- this.#sessionId = createSessionId();
3025
+ // Adopt a lifecycle pre-allocated id exactly once (the initial session of a
3026
+ // /session_create child); later new-session paths (/new, fork, branch) get
3027
+ // fresh ids so they cannot reuse the original GJC_SESSION_ID.
3028
+ const preallocated = this.#lifecycleIdAdopted ? undefined : lifecyclePreallocatedSessionId();
3029
+ if (preallocated) this.#lifecycleIdAdopted = true;
3030
+ this.#sessionId = preallocated ?? createSessionId();
2619
3031
  this.#sessionName = undefined;
2620
3032
  this.#titleSource = undefined;
2621
3033
  const timestamp = new Date().toISOString();
@@ -3579,8 +3991,11 @@ export class SessionManager {
3579
3991
  * Returns undefined if no model change has been recorded.
3580
3992
  */
3581
3993
  getLastModelChangeRole(): string | undefined {
3994
+ const visited = new Set<string>();
3582
3995
  let current = this.getLeafEntry();
3583
3996
  while (current) {
3997
+ if (visited.has(current.id)) break;
3998
+ visited.add(current.id);
3584
3999
  if (current.type === "model_change") {
3585
4000
  return current.role ?? "default";
3586
4001
  }
@@ -3589,7 +4004,223 @@ export class SessionManager {
3589
4004
  return undefined;
3590
4005
  }
3591
4006
 
4007
+ evictCompactedContent(firstKeptEntryId: string, compactionEntryId: string): EvictCompactedContentResult {
4008
+ const firstKept = this.#byId.get(firstKeptEntryId);
4009
+ const compaction = this.#byId.get(compactionEntryId);
4010
+ if (!firstKept) throw new Error(`Entry ${firstKeptEntryId} not found`);
4011
+ if (!compaction || compaction.type !== "compaction")
4012
+ throw new Error(`Compaction entry ${compactionEntryId} not found`);
4013
+ const ids: string[] = [];
4014
+ const visited = new Set<string>();
4015
+ let current: SessionEntry | undefined = compaction;
4016
+ while (current) {
4017
+ if (visited.has(current.id)) break;
4018
+ visited.add(current.id);
4019
+ ids.push(current.id);
4020
+ current = current.parentId ? this.#byId.get(current.parentId) : undefined;
4021
+ }
4022
+ ids.reverse();
4023
+ let evictedEntries = 0;
4024
+ let hotCharsRemoved = 0;
4025
+ let coldBlobBytes = 0;
4026
+ let payloadRefs = 0;
4027
+ let alreadyEvictedEntries = 0;
4028
+ let mutated = false;
4029
+ try {
4030
+ for (const id of ids) {
4031
+ if (id === firstKeptEntryId) break;
4032
+ const entry = this.#byId.get(id);
4033
+ if (!entry || entry.type === "compaction") continue;
4034
+ if (entry.type !== "message" && entry.type !== "custom_message") continue;
4035
+ if (entry.evictedContent?.reason === "compacted_history") {
4036
+ alreadyEvictedEntries++;
4037
+ continue;
4038
+ }
4039
+ const beforeChars = JSON.stringify(entry).length;
4040
+ const writes: ColdSpillWrite[] = [];
4041
+ const nextEntry = this.#coldSpillClone(entry, writes);
4042
+ if (writes.length === 0 || nextEntry === entry) continue;
4043
+ const payloads: Record<string, ColdSpillRef> = {};
4044
+ for (const write of writes) {
4045
+ const put = this.#blobStore.putImmutableSync(write.data);
4046
+ this.#coldSpillWriteCount++;
4047
+ payloads[write.path] = {
4048
+ kind: "cold_spill",
4049
+ ref: put.ref,
4050
+ encoding: write.encoding,
4051
+ originalChars: write.originalChars,
4052
+ sha256: put.hash,
4053
+ bytes: put.bytes,
4054
+ };
4055
+ coldBlobBytes += put.bytes;
4056
+ }
4057
+ const marker: EvictedContentMarker = {
4058
+ evictedAt: Date.now(),
4059
+ reason: "compacted_history",
4060
+ compactionEntryId,
4061
+ firstKeptEntryId,
4062
+ payloads,
4063
+ };
4064
+ // Store the marker at the ENTRY level (session metadata), not on the
4065
+ // strict message type, so message shapes stay type-clean.
4066
+ if (nextEntry.type === "message" || nextEntry.type === "custom_message") {
4067
+ nextEntry.evictedContent = marker;
4068
+ }
4069
+ this.#replaceCanonicalEntry(nextEntry);
4070
+ mutated = true;
4071
+ evictedEntries++;
4072
+ payloadRefs += writes.length;
4073
+ hotCharsRemoved += Math.max(0, beforeChars - JSON.stringify(nextEntry).length);
4074
+ }
4075
+ } finally {
4076
+ if (mutated) {
4077
+ this.#needsFullRewriteOnNextPersist = true;
4078
+ this.#bumpEntryRevision();
4079
+ this.#replayMetadataRevision++;
4080
+ this.#materializedEntriesCache = undefined;
4081
+ this.#materializedEntriesRevision = -1;
4082
+ this.#sessionContextCache = undefined;
4083
+ }
4084
+ }
4085
+ return {
4086
+ evictedEntries,
4087
+ hotCharsRemoved,
4088
+ coldBlobBytes,
4089
+ payloadRefs,
4090
+ alreadyEvictedEntries,
4091
+ coldSpillWriteCount: this.#coldSpillWriteCount,
4092
+ coldSpillReadCount: this.#coldSpillReadCount,
4093
+ residentTextReadCount: this.#residentTextReadCount,
4094
+ residentImageReadCount: this.#residentImageReadCount,
4095
+ };
4096
+ }
4097
+
4098
+ #coldSpillClone(entry: SessionEntry, writes: ColdSpillWrite[]): SessionEntry {
4099
+ if (entry.type === "message") {
4100
+ const content = "content" in entry.message ? entry.message.content : undefined;
4101
+ if (!Array.isArray(content)) return entry;
4102
+ const nextContent = coldSpillContentBlocks(content, "message.content", writes, {
4103
+ stores: this.#residentBlobStoresForColdRehydrate(),
4104
+ });
4105
+ return nextContent === content
4106
+ ? entry
4107
+ : { ...entry, message: { ...entry.message, content: nextContent } as AgentMessage };
4108
+ }
4109
+ if (entry.type === "custom_message") {
4110
+ const content = coldSpillCustomMessageContent(entry.content, writes, {
4111
+ stores: this.#residentBlobStoresForColdRehydrate(),
4112
+ });
4113
+ return content === entry.content ? entry : { ...entry, content };
4114
+ }
4115
+ return entry;
4116
+ }
4117
+
4118
+ #replaceCanonicalEntry(entry: SessionEntry): void {
4119
+ this.#byId.set(entry.id, entry);
4120
+ const index = this.#fileEntries.findIndex(candidate => candidate.type !== "session" && candidate.id === entry.id);
4121
+ if (index >= 0) this.#fileEntries[index] = entry;
4122
+ }
4123
+
4124
+ getObservabilityStatsForTests(): SessionManagerObservabilityStats {
4125
+ return {
4126
+ coldSpillWriteCount: this.#coldSpillWriteCount,
4127
+ coldSpillReadCount: this.#coldSpillReadCount,
4128
+ residentTextReadCount: this.#residentTextReadCount,
4129
+ residentImageReadCount: this.#residentImageReadCount,
4130
+ publicMaterializerCallCount: this.#publicMaterializerCallCount,
4131
+ getEntryMaterializerCallCount: this.#getEntryMaterializerCallCount,
4132
+ getBranchMaterializerCallCount: this.#getBranchMaterializerCallCount,
4133
+ getEntriesMaterializerCallCount: this.#getEntriesMaterializerCallCount,
4134
+ materializedEntriesCachePopulateCount: this.#materializedEntriesCachePopulateCount,
4135
+ pathOnlyContextBuildCount: this.#pathOnlyContextBuildCount,
4136
+ };
4137
+ }
4138
+
4139
+ hotRetainedMessageCharsForTests(): number {
4140
+ let total = 0;
4141
+ for (const entry of this.#fileEntries) {
4142
+ if (entry.type !== "message" && entry.type !== "custom_message") continue;
4143
+ total += JSON.stringify(entry).length;
4144
+ }
4145
+ return total;
4146
+ }
4147
+
4148
+ getCanonicalEntryForTests(id: string): SessionEntry | undefined {
4149
+ const entry = this.#byId.get(id);
4150
+ return entry ? cloneSessionEntry(entry) : undefined;
4151
+ }
4152
+
4153
+ getEntryForFidelity(id: string): SessionEntry | undefined {
4154
+ const entry = this.#byId.get(id);
4155
+ return entry
4156
+ ? rehydrateColdSpillEntry(
4157
+ materializeResidentEntrySync(entry, this.#residentBlobStores(), new Map()),
4158
+ this.#blobStore,
4159
+ this.#residentBlobStoresForColdRehydrate(),
4160
+ )
4161
+ : undefined;
4162
+ }
4163
+
4164
+ getBranchForFidelity(fromId?: string): SessionEntry[] {
4165
+ const cache = new Map<string, string>();
4166
+ const path: SessionEntry[] = [];
4167
+ const visited = new Set<string>();
4168
+ let current = (fromId ?? this.#leafId) ? this.#byId.get(fromId ?? this.#leafId ?? "") : undefined;
4169
+ while (current) {
4170
+ if (visited.has(current.id)) break;
4171
+ visited.add(current.id);
4172
+ path.push(
4173
+ rehydrateColdSpillEntry(
4174
+ materializeResidentEntrySync(current, this.#residentBlobStores(), cache),
4175
+ this.#blobStore,
4176
+ this.#residentBlobStoresForColdRehydrate(),
4177
+ ),
4178
+ );
4179
+ current = current.parentId ? this.#byId.get(current.parentId) : undefined;
4180
+ }
4181
+ path.reverse();
4182
+ return path;
4183
+ }
4184
+
4185
+ #getCanonicalBranchClones(fromId?: string): SessionEntry[] {
4186
+ const path: SessionEntry[] = [];
4187
+ const visited = new Set<string>();
4188
+ let current = (fromId ?? this.#leafId) ? this.#byId.get(fromId ?? this.#leafId ?? "") : undefined;
4189
+ while (current) {
4190
+ if (visited.has(current.id)) break;
4191
+ visited.add(current.id);
4192
+ path.push(cloneSessionEntry(current));
4193
+ current = current.parentId ? this.#byId.get(current.parentId) : undefined;
4194
+ }
4195
+ path.reverse();
4196
+ return path;
4197
+ }
4198
+
4199
+ /**
4200
+ * Walk the active branch without materializing resident blobs or rehydrating
4201
+ * cold-spill payloads. Intended for metadata-only scans such as todo-phase
4202
+ * sync; callers must not mutate returned entries.
4203
+ */
4204
+ getActivePathEntriesCanonical(fromId?: string): SessionEntry[] {
4205
+ return this.#getCanonicalBranchClones(fromId);
4206
+ }
4207
+
4208
+ getEntriesForExport(): SessionEntry[] {
4209
+ const cache = new Map<string, string>();
4210
+ return this.#fileEntries
4211
+ .filter((entry): entry is SessionEntry => entry.type !== "session")
4212
+ .map(entry =>
4213
+ rehydrateColdSpillEntry(
4214
+ materializeResidentEntrySync(entry, this.#residentBlobStores(), cache),
4215
+ this.#blobStore,
4216
+ this.#residentBlobStoresForColdRehydrate(),
4217
+ ),
4218
+ );
4219
+ }
4220
+
3592
4221
  getEntry(id: string): SessionEntry | undefined {
4222
+ this.#publicMaterializerCallCount++;
4223
+ this.#getEntryMaterializerCallCount++;
3593
4224
  const entry = this.#byId.get(id);
3594
4225
  return entry ? materializeResidentEntrySync(entry, this.#residentBlobStores(), new Map()) : undefined;
3595
4226
  }
@@ -3647,11 +4278,16 @@ export class SessionManager {
3647
4278
  * Use buildSessionContext() to get the resolved messages for the LLM.
3648
4279
  */
3649
4280
  getBranch(fromId?: string): SessionEntry[] {
4281
+ this.#publicMaterializerCallCount++;
4282
+ this.#getBranchMaterializerCallCount++;
3650
4283
  const cache = new Map<string, string>();
3651
4284
  const path: SessionEntry[] = [];
4285
+ const visited = new Set<string>();
3652
4286
  const startId = fromId ?? this.#leafId;
3653
4287
  let current = startId ? this.#byId.get(startId) : undefined;
3654
4288
  while (current) {
4289
+ if (visited.has(current.id)) break;
4290
+ visited.add(current.id);
3655
4291
  path.push(materializeResidentEntrySync(current, this.#residentBlobStores(), cache));
3656
4292
  current = current.parentId ? this.#byId.get(current.parentId) : undefined;
3657
4293
  }
@@ -3673,13 +4309,63 @@ export class SessionManager {
3673
4309
  ) {
3674
4310
  return cloneSessionContext(cached);
3675
4311
  }
3676
- const context = buildSessionContext(this.#getMaterializedEntriesInternal(), this.#leafId);
4312
+ this.#pathOnlyContextBuildCount++;
4313
+ const context = buildSessionContext(this.#getActivePathEntriesForProviderContext(), this.#leafId);
3677
4314
  this.#sessionContextCache = new WeakRef(context);
3678
4315
  this.#sessionContextEntryRevision = this.#entryRevision;
3679
4316
  this.#sessionContextLeafRevision = this.#leafRevision;
3680
4317
  this.#sessionContextReplayMetadataRevision = this.#replayMetadataRevision;
3681
4318
  return cloneSessionContext(context);
3682
4319
  }
4320
+
4321
+ #getActivePathEntriesForProviderContext(fromId?: string | null): SessionEntry[] {
4322
+ if (fromId === null || (fromId === undefined && this.#leafId === null)) return [];
4323
+ const ids: string[] = [];
4324
+ const visited = new Set<string>();
4325
+ let current = this.#byId.get(fromId ?? this.#leafId ?? "");
4326
+ while (current) {
4327
+ if (visited.has(current.id)) break;
4328
+ visited.add(current.id);
4329
+ ids.push(current.id);
4330
+ current = current.parentId ? this.#byId.get(current.parentId) : undefined;
4331
+ }
4332
+ ids.reverse();
4333
+ const pathEntries = ids
4334
+ .map(id => this.#byId.get(id))
4335
+ .filter((entry): entry is SessionEntry => entry !== undefined);
4336
+ let compaction: CompactionEntry | undefined;
4337
+ for (const entry of pathEntries) if (entry.type === "compaction") compaction = entry;
4338
+ if (!compaction) return pathEntries.map(entry => this.#entryForProviderContext(entry, undefined));
4339
+ const compactionIndex = pathEntries.findIndex(entry => entry.id === compaction.id);
4340
+ const firstKeptIndex = pathEntries.findIndex(entry => entry.id === compaction.firstKeptEntryId);
4341
+ const remote = compaction.preserveData?.openaiRemoteCompaction;
4342
+ const hasRemoteReplacement = isRecord(remote) && Array.isArray(remote.replacementHistory);
4343
+ return pathEntries.map((entry, index) => {
4344
+ const covered =
4345
+ index < compactionIndex && (hasRemoteReplacement || (firstKeptIndex >= 0 && index < firstKeptIndex));
4346
+ return this.#entryForProviderContext(entry, covered ? "covered" : undefined);
4347
+ });
4348
+ }
4349
+
4350
+ #entryForProviderContext(entry: SessionEntry, coldSpillPolicy: "covered" | undefined): SessionEntry {
4351
+ if (coldSpillPolicy === "covered" && (entry.type === "message" || entry.type === "custom_message")) {
4352
+ return cloneSessionEntry(entry);
4353
+ }
4354
+ if (entry.type !== "message" && entry.type !== "custom_message") return cloneSessionEntry(entry);
4355
+ const materialized = materializeResidentEntrySync(entry, this.#residentBlobStores(), new Map());
4356
+ const rehydrated = rehydrateColdSpillEntry(
4357
+ materialized,
4358
+ this.#blobStore,
4359
+ this.#residentBlobStoresForColdRehydrate(),
4360
+ );
4361
+ if (rehydrated !== materialized) this.#coldSpillReadCount += this.#countColdSpillPayloads(entry);
4362
+ return rehydrated;
4363
+ }
4364
+
4365
+ #countColdSpillPayloads(entry: SessionEntry): number {
4366
+ const marker = entry.type === "message" || entry.type === "custom_message" ? entry.evictedContent : undefined;
4367
+ return marker ? Object.keys(marker.payloads ?? {}).length : 0;
4368
+ }
3683
4369
  /** Strip stale OpenAI Responses assistant replay metadata from loaded in-memory entries. */
3684
4370
  sanitizeLoadedOpenAIResponsesReplayMetadata(): boolean {
3685
4371
  let didSanitize = false;
@@ -3721,6 +4407,7 @@ export class SessionManager {
3721
4407
  if (this.#materializedEntriesRevision === this.#entryRevision && this.#materializedEntriesCache) {
3722
4408
  return this.#materializedEntriesCache;
3723
4409
  }
4410
+ this.#materializedEntriesCachePopulateCount++;
3724
4411
  const resolvedTextBlobCache = new Map<string, string>();
3725
4412
  const materializedEntries = this.#fileEntries
3726
4413
  .filter((e): e is SessionEntry => e.type !== "session")
@@ -3731,6 +4418,8 @@ export class SessionManager {
3731
4418
  }
3732
4419
 
3733
4420
  getEntries(): SessionEntry[] {
4421
+ this.#publicMaterializerCallCount++;
4422
+ this.#getEntriesMaterializerCallCount++;
3734
4423
  return this.#getMaterializedEntriesInternal().map(entry => cloneSessionEntry(entry));
3735
4424
  }
3736
4425
 
@@ -3750,18 +4439,48 @@ export class SessionManager {
3750
4439
  nodeMap.set(entry.id, { entry, children: [], label });
3751
4440
  }
3752
4441
 
3753
- // Build tree
4442
+ const addRoot = (node: SessionTreeNode): void => {
4443
+ if (!roots.includes(node)) {
4444
+ roots.push(node);
4445
+ }
4446
+ };
4447
+ const removeRoot = (node: SessionTreeNode): void => {
4448
+ const index = roots.indexOf(node);
4449
+ if (index !== -1) {
4450
+ roots.splice(index, 1);
4451
+ }
4452
+ };
4453
+ const wouldCreateChildCycle = (parent: SessionTreeNode, child: SessionTreeNode): boolean => {
4454
+ const stack: SessionTreeNode[] = [child];
4455
+ const visited = new Set<SessionTreeNode>();
4456
+ while (stack.length > 0) {
4457
+ const current = stack.pop()!;
4458
+ if (current === parent) {
4459
+ return true;
4460
+ }
4461
+ if (visited.has(current)) {
4462
+ continue;
4463
+ }
4464
+ visited.add(current);
4465
+ stack.push(...current.children);
4466
+ }
4467
+ return false;
4468
+ };
4469
+
4470
+ // Build tree. Corrupt session files can contain duplicate IDs or parentId
4471
+ // cycles; reject only the edge that would make the returned tree cyclic.
3754
4472
  for (const entry of entries) {
3755
4473
  const node = nodeMap.get(entry.id)!;
3756
4474
  if (entry.parentId === null || entry.parentId === entry.id) {
3757
- roots.push(node);
4475
+ addRoot(node);
3758
4476
  } else {
3759
4477
  const parent = nodeMap.get(entry.parentId);
3760
- if (parent) {
4478
+ if (parent && !wouldCreateChildCycle(parent, node)) {
3761
4479
  parent.children.push(node);
4480
+ removeRoot(node);
3762
4481
  } else {
3763
- // Orphan - treat as root
3764
- roots.push(node);
4482
+ // Orphan or cycle-closing edge - treat as root
4483
+ addRoot(node);
3765
4484
  }
3766
4485
  }
3767
4486
  }
@@ -3769,8 +4488,13 @@ export class SessionManager {
3769
4488
  // Sort children by timestamp (oldest first, newest at bottom)
3770
4489
  // Use iterative approach to avoid stack overflow on deep trees
3771
4490
  const stack: SessionTreeNode[] = [...roots];
4491
+ const sorted = new Set<SessionTreeNode>();
3772
4492
  while (stack.length > 0) {
3773
4493
  const node = stack.pop()!;
4494
+ if (sorted.has(node)) {
4495
+ continue;
4496
+ }
4497
+ sorted.add(node);
3774
4498
  node.children.sort((a, b) => new Date(a.entry.timestamp).getTime() - new Date(b.entry.timestamp).getTime());
3775
4499
  stack.push(...node.children);
3776
4500
  }
@@ -3837,14 +4561,17 @@ export class SessionManager {
3837
4561
  */
3838
4562
  createBranchedSession(leafId: string): string | undefined {
3839
4563
  const previousSessionFile = this.#sessionFile;
3840
- const branchPath = this.getBranch(leafId);
4564
+ const branchPath = this.#getCanonicalBranchClones(leafId);
3841
4565
  if (branchPath.length === 0) {
3842
4566
  throw new Error(`Entry ${leafId} not found`);
3843
4567
  }
3844
4568
 
3845
4569
  // Filter out LabelEntry from path - we'll recreate them from the resolved map
3846
4570
  const pathWithoutLabels = branchPath.filter(e => e.type !== "label");
3847
-
4571
+ const materializedPathWithoutLabels = materializeResidentEntriesSync(
4572
+ pathWithoutLabels,
4573
+ this.#residentBlobStores(),
4574
+ );
3848
4575
  const newSessionId = createSessionId();
3849
4576
  const timestamp = new Date().toISOString();
3850
4577
  const fileTimestamp = timestamp.replace(/[:.]/g, "-");
@@ -3871,7 +4598,7 @@ export class SessionManager {
3871
4598
  if (this.persist) {
3872
4599
  const lines: string[] = [];
3873
4600
  lines.push(JSON.stringify(header));
3874
- for (const entry of pathWithoutLabels) {
4601
+ for (const entry of materializedPathWithoutLabels) {
3875
4602
  lines.push(JSON.stringify(prepareEntryForPersistenceSync(entry, this.#blobStore)));
3876
4603
  }
3877
4604
  // Write fresh label entries at the end
@@ -3898,7 +4625,7 @@ export class SessionManager {
3898
4625
  this.#resetResidentTextBlobStore();
3899
4626
  this.#fileEntries = [
3900
4627
  header,
3901
- ...pathWithoutLabels.map(
4628
+ ...materializedPathWithoutLabels.map(
3902
4629
  entry => prepareEntryForResidentSync(entry, this.#residentBlobStores()) as SessionEntry,
3903
4630
  ),
3904
4631
  ...labelEntries,
@@ -3928,7 +4655,7 @@ export class SessionManager {
3928
4655
  this.#resetResidentTextBlobStore();
3929
4656
  this.#fileEntries = [
3930
4657
  header,
3931
- ...pathWithoutLabels.map(
4658
+ ...materializedPathWithoutLabels.map(
3932
4659
  entry => prepareEntryForResidentSync(entry, this.#residentBlobStores()) as SessionEntry,
3933
4660
  ),
3934
4661
  ...labelEntries,