cc-viewer 1.6.348 → 1.7.1

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 (222) hide show
  1. package/README.md +13 -1
  2. package/cli.js +116 -37
  3. package/dist/assets/App-gUSmU6Ny.js +2 -0
  4. package/dist/assets/{MdxEditorPanel-ualoRFOL.js → MdxEditorPanel-DAR39A3S.js} +1 -1
  5. package/dist/assets/Mobile-qfRsRth0.js +1 -0
  6. package/dist/assets/{_baseUniq-CG81mgN_.js → _baseUniq-D9_E5ZDp.js} +1 -1
  7. package/dist/assets/{arc-C161J4f2.js → arc-D4iFrlhb.js} +1 -1
  8. package/dist/assets/{architectureDiagram-Q4EWVU46-BmbDMdn9.js → architectureDiagram-Q4EWVU46-f1neo7hg.js} +1 -1
  9. package/dist/assets/{blockDiagram-DXYQGD6D-DXpMvdxd.js → blockDiagram-DXYQGD6D-DWi9e9tG.js} +1 -1
  10. package/dist/assets/{c4Diagram-AHTNJAMY-8Mypg0IU.js → c4Diagram-AHTNJAMY-DAdj49A2.js} +1 -1
  11. package/dist/assets/{channel-B2khdmvc.js → channel-B3VthT0m.js} +1 -1
  12. package/dist/assets/{chunk-4BX2VUAB-I61r1g6z.js → chunk-4BX2VUAB-BrPcAHV3.js} +1 -1
  13. package/dist/assets/{chunk-4TB4RGXK-VFPlRE5E.js → chunk-4TB4RGXK-Bvopc1V9.js} +1 -1
  14. package/dist/assets/{chunk-55IACEB6-DvCamg_6.js → chunk-55IACEB6-Juw-7Z3i.js} +1 -1
  15. package/dist/assets/{chunk-EDXVE4YY-Dj4Q8RwR.js → chunk-EDXVE4YY-DVYP9tpB.js} +1 -1
  16. package/dist/assets/{chunk-FMBD7UC4-_bgQuuJh.js → chunk-FMBD7UC4-VQ7VOXl3.js} +1 -1
  17. package/dist/assets/{chunk-OYMX7WX6-3to38LyN.js → chunk-OYMX7WX6-F8Rj4ZUZ.js} +1 -1
  18. package/dist/assets/{chunk-QZHKN3VN-Zl26wKKz.js → chunk-QZHKN3VN-Dn7K3yi9.js} +1 -1
  19. package/dist/assets/{chunk-YZCP3GAM-RLYPrlh0.js → chunk-YZCP3GAM-UjO-KTsn.js} +1 -1
  20. package/dist/assets/classDiagram-6PBFFD2Q-DmSozxoO.js +1 -0
  21. package/dist/assets/classDiagram-v2-HSJHXN6E-DmSozxoO.js +1 -0
  22. package/dist/assets/clone-BQtvy9Ae.js +1 -0
  23. package/dist/assets/{cose-bilkent-S5V4N54A-CsYOjJ0V.js → cose-bilkent-S5V4N54A-BDhNj8rq.js} +1 -1
  24. package/dist/assets/{dagre-KV5264BT-C3YXXaFU.js → dagre-KV5264BT-BzdcDuyt.js} +1 -1
  25. package/dist/assets/{diagram-5BDNPKRD-DUCOg1ld.js → diagram-5BDNPKRD-DP_SX65N.js} +1 -1
  26. package/dist/assets/{diagram-G4DWMVQ6-BtlLl_zX.js → diagram-G4DWMVQ6-B-pBFw8Q.js} +1 -1
  27. package/dist/assets/{diagram-MMDJMWI5-1Qp9xfOk.js → diagram-MMDJMWI5-6XSfN1eN.js} +1 -1
  28. package/dist/assets/{diagram-TYMM5635-Cj7VMp3L.js → diagram-TYMM5635-DJvGxvpE.js} +1 -1
  29. package/dist/assets/{erDiagram-SMLLAGMA-CVZVn541.js → erDiagram-SMLLAGMA-CpqdpGYj.js} +1 -1
  30. package/dist/assets/{flowDiagram-DWJPFMVM-CiU9Fkkv.js → flowDiagram-DWJPFMVM-C9m7wjwI.js} +1 -1
  31. package/dist/assets/{ganttDiagram-T4ZO3ILL-aRaG97ID.js → ganttDiagram-T4ZO3ILL-CU0XOfY3.js} +1 -1
  32. package/dist/assets/{gitGraphDiagram-UUTBAWPF-Cr2LJlvX.js → gitGraphDiagram-UUTBAWPF-ipyGULnP.js} +1 -1
  33. package/dist/assets/{graph-B59lDyMn.js → graph-BR2KmES9.js} +1 -1
  34. package/dist/assets/{index-C1ptnCpq.js → index--wgRW_OB.js} +1 -1
  35. package/dist/assets/index-B51Ci0GL.js +2 -0
  36. package/dist/assets/{index-CRAWA8PC.js → index-C-be29ey.js} +1 -1
  37. package/dist/assets/{index-CSM1Kw8l.js → index-C16fiVhv.js} +1 -1
  38. package/dist/assets/{index-Ddk-kg22.js → index-D5YruOKD.js} +1 -1
  39. package/dist/assets/{index-BnhNIjbj.js → index-D5tSeMU7.js} +1 -1
  40. package/dist/assets/{index-vFEQq-NB.js → index-DuZQ6FqR.js} +1 -1
  41. package/dist/assets/{index-CO2hZ1s4.js → index-OWzSX2T8.js} +1 -1
  42. package/dist/assets/{infoDiagram-42DDH7IO-CnO22bw8.js → infoDiagram-42DDH7IO-BQWQjfFX.js} +1 -1
  43. package/dist/assets/{ishikawaDiagram-UXIWVN3A-ByNzpScH.js → ishikawaDiagram-UXIWVN3A-Df-KH_Hz.js} +1 -1
  44. package/dist/assets/{journeyDiagram-VCZTEJTY-yaVaCWPq.js → journeyDiagram-VCZTEJTY-CMbB_4G9.js} +1 -1
  45. package/dist/assets/{jszip.min-BdVFP3OW.js → jszip.min-B6k2t9Ch.js} +1 -1
  46. package/dist/assets/{kanban-definition-6JOO6SKY-RIPR_RQ3.js → kanban-definition-6JOO6SKY-CImdlKN3.js} +1 -1
  47. package/dist/assets/{layout-0rzel0v_.js → layout-DQl4aw09.js} +1 -1
  48. package/dist/assets/{linear-sktN0tB6.js → linear-CLD3aPlK.js} +1 -1
  49. package/dist/assets/mermaid.core-CN35dvKJ.js +7 -0
  50. package/dist/assets/{min-rspGOkz_.js → min-_D86henJ.js} +1 -1
  51. package/dist/assets/{mindmap-definition-QFDTVHPH-BsVzZzTz.js → mindmap-definition-QFDTVHPH-2JVcu1nk.js} +1 -1
  52. package/dist/assets/{pieDiagram-DEJITSTG-CsoWsw4f.js → pieDiagram-DEJITSTG-Dik-m5u7.js} +1 -1
  53. package/dist/assets/{quadrantDiagram-34T5L4WZ-CTMKuCAI.js → quadrantDiagram-34T5L4WZ-Bm5l40Jq.js} +1 -1
  54. package/dist/assets/{requirementDiagram-MS252O5E-C67256te.js → requirementDiagram-MS252O5E-Ck9w5rTj.js} +1 -1
  55. package/dist/assets/{sankeyDiagram-XADWPNL6-X8Y7KnW0.js → sankeyDiagram-XADWPNL6-BbGVCFEf.js} +1 -1
  56. package/dist/assets/{seqResourceLoaders-B5Zy_CC_.css → seqResourceLoaders-DFrHmgnK.css} +1 -1
  57. package/dist/assets/seqResourceLoaders-DZJxMMaR.js +2 -0
  58. package/dist/assets/{sequenceDiagram-FGHM5R23-Cils0TAp.js → sequenceDiagram-FGHM5R23-vjvEdksk.js} +1 -1
  59. package/dist/assets/{stateDiagram-FHFEXIEX-DFnohvpG.js → stateDiagram-FHFEXIEX-DTqMoiB2.js} +1 -1
  60. package/dist/assets/{stateDiagram-v2-QKLJ7IA2-BTX_4o81.js → stateDiagram-v2-QKLJ7IA2-CNbAwnhu.js} +1 -1
  61. package/dist/assets/{timeline-definition-GMOUNBTQ-DrIYFrzZ.js → timeline-definition-GMOUNBTQ-Cw8O-52d.js} +1 -1
  62. package/dist/assets/{vendor-antd-D5HmSDSg.js → vendor-antd-DI7JL-mE.js} +1 -1
  63. package/dist/assets/{vendor-codemirror-C5yuj7ze.js → vendor-codemirror-B9c49dtM.js} +1 -1
  64. package/dist/assets/{vendor-mdxeditor-BcEooTvb.js → vendor-mdxeditor-B6cpBtIE.js} +2 -2
  65. package/dist/assets/{vendor-qrcode-d9LCn0-9.js → vendor-qrcode-Dn90-s-u.js} +1 -1
  66. package/dist/assets/{vendor-virtuoso-K9m4MN6a.js → vendor-virtuoso-CpdSKD_6.js} +1 -1
  67. package/dist/assets/{vennDiagram-DHZGUBPP-lRqBYTr9.js → vennDiagram-DHZGUBPP-DCuqymb7.js} +1 -1
  68. package/dist/assets/{wardley-RL74JXVD-e8YfxpOe.js → wardley-RL74JXVD-BJ20wJUU.js} +1 -1
  69. package/dist/assets/{wardleyDiagram-NUSXRM2D-Bxrg774h.js → wardleyDiagram-NUSXRM2D-Vz6US3qt.js} +1 -1
  70. package/dist/assets/{xychartDiagram-5P7HB3ND-DWhZqhq8.js → xychartDiagram-5P7HB3ND-CzkLKrmi.js} +1 -1
  71. package/dist/index.html +4 -4
  72. package/findcc.js +4 -3
  73. package/package.json +3 -1
  74. package/server/i18n.js +21 -141
  75. package/server/interceptor.js +211 -403
  76. package/server/lib/async-write-queue.js +12 -1
  77. package/server/lib/delta-reconstructor.js +3 -3
  78. package/server/lib/error-report.js +45 -0
  79. package/server/lib/im-log-watcher.js +68 -8
  80. package/server/lib/interceptor-core.js +19 -174
  81. package/server/lib/log-management.js +214 -247
  82. package/server/lib/log-stream.js +59 -108
  83. package/server/lib/log-watcher.js +62 -274
  84. package/server/lib/log-zip.js +148 -0
  85. package/server/lib/session-boundary.js +266 -0
  86. package/server/lib/session-pin-store.js +10 -19
  87. package/server/lib/stats-worker.js +138 -267
  88. package/server/lib/user-prompt-extract.js +250 -0
  89. package/server/lib/v2/adapter.js +1124 -0
  90. package/server/lib/v2/blob-store.js +44 -0
  91. package/server/lib/v2/conversation-store.js +217 -0
  92. package/server/lib/v2/convert-manager.js +79 -0
  93. package/server/lib/v2/convert-worker.js +42 -0
  94. package/server/lib/v2/convert.js +409 -0
  95. package/server/lib/v2/identity.js +204 -0
  96. package/server/lib/v2/journal.js +62 -0
  97. package/server/lib/v2/layout.js +167 -0
  98. package/server/lib/v2/live-feed.js +558 -0
  99. package/server/lib/v2/meta-rows.js +360 -0
  100. package/server/lib/v2/migrate-prompt.js +54 -0
  101. package/server/lib/v2/replay.js +191 -0
  102. package/server/lib/v2/session-select.js +222 -0
  103. package/server/lib/v2/singleflight.js +104 -0
  104. package/server/lib/v2/v2-writer.js +528 -0
  105. package/server/lib/v2/verify.js +233 -0
  106. package/server/lib/wire-compress.js +167 -0
  107. package/server/routes/events.js +120 -150
  108. package/server/routes/files-fs.js +4 -2
  109. package/server/routes/im.js +11 -2
  110. package/server/routes/logs.js +209 -164
  111. package/server/routes/preferences.js +0 -3
  112. package/server/routes/project-meta.js +2 -3
  113. package/server/routes/session-pin.js +7 -8
  114. package/server/routes/v2.js +60 -0
  115. package/server/routes/workspaces.js +50 -14
  116. package/server/server.js +70 -66
  117. package/server/workspace-registry.js +24 -4
  118. package/src/utils/apiUrl.js +26 -0
  119. package/src/utils/askFallback.js +21 -0
  120. package/src/utils/askOptionDesc.js +55 -0
  121. package/src/utils/askPortalMatcher.js +54 -0
  122. package/src/utils/autoApproveOptions.js +27 -0
  123. package/src/utils/avatarAnimationPostPass.js +49 -0
  124. package/src/utils/builtinPresets.js +18 -0
  125. package/src/utils/clearCheckpoint.js +7 -0
  126. package/src/utils/commandValidator.js +45 -0
  127. package/src/utils/contentFilter.js +620 -0
  128. package/src/utils/contextRaw.js +18 -0
  129. package/src/utils/contextSidebarNavigation.js +16 -0
  130. package/src/utils/contextTurns.js +117 -0
  131. package/src/utils/displayScaleHelper.js +47 -0
  132. package/src/utils/effectiveModel.js +13 -0
  133. package/src/utils/entry-slim.js +563 -0
  134. package/src/utils/entryCache.js +144 -0
  135. package/src/utils/errorReport.js +6 -0
  136. package/src/utils/fileExpandedPathsStorage.js +70 -0
  137. package/src/utils/fileIcons.jsx +36 -0
  138. package/src/utils/fileOpen.js +37 -0
  139. package/src/utils/formatters.js +48 -0
  140. package/src/utils/gitApi.js +50 -0
  141. package/src/utils/gitTreeBuilder.js +19 -0
  142. package/src/utils/helpers.js +846 -0
  143. package/src/utils/identityHeal.js +99 -0
  144. package/src/utils/imConnState.js +59 -0
  145. package/src/utils/imOrigin.js +24 -0
  146. package/src/utils/imTr.js +7 -0
  147. package/src/utils/imageCompress.js +83 -0
  148. package/src/utils/imageDownscale.js +28 -0
  149. package/src/utils/imageResize.js +112 -0
  150. package/src/utils/ingestPipeline.js +47 -0
  151. package/src/utils/lazyWithReload.js +53 -0
  152. package/src/utils/markdown.js +30 -0
  153. package/src/utils/markdownIncremental.js +39 -0
  154. package/src/utils/markdownProfiler.js +113 -0
  155. package/src/utils/mdExtensionDetect.js +64 -0
  156. package/src/utils/memoryLinkParser.js +44 -0
  157. package/src/utils/modalMask.js +15 -0
  158. package/src/utils/monotime.js +6 -0
  159. package/src/utils/pinnedMenu.js +55 -0
  160. package/src/utils/presetShortcuts.js +14 -0
  161. package/src/utils/projectAlias.js +135 -0
  162. package/src/utils/promptClassifier.js +112 -0
  163. package/src/utils/promptDetect.js +196 -0
  164. package/src/utils/promptNav.js +60 -0
  165. package/src/utils/ptyChunkBuilder.js +251 -0
  166. package/src/utils/quickMenuHoverIntent.js +35 -0
  167. package/src/utils/rateLimitParser.js +119 -0
  168. package/src/utils/readResultPool.js +106 -0
  169. package/src/utils/refreshCachedItemProp.js +30 -0
  170. package/src/utils/requestType.js +208 -0
  171. package/src/utils/resizeCalc.js +21 -0
  172. package/src/utils/resolveLocalized.js +17 -0
  173. package/src/utils/searchApi.js +47 -0
  174. package/src/utils/searchReplace.js +51 -0
  175. package/src/utils/seqResourceLoaders.js +79 -0
  176. package/src/utils/sessionManager.js +471 -0
  177. package/src/utils/sessionMerge.js +180 -0
  178. package/src/utils/skillModalController.js +111 -0
  179. package/src/utils/skillsParser.js +116 -0
  180. package/src/utils/slashCommandLabels.js +80 -0
  181. package/src/utils/splitDragCalc.js +73 -0
  182. package/src/utils/stickyBottomController.js +643 -0
  183. package/src/utils/svgSanitize.js +48 -0
  184. package/src/utils/systemTags.js +47 -0
  185. package/src/utils/tClaude.js +20 -0
  186. package/src/utils/teamModalBuilder.js +351 -0
  187. package/src/utils/teamSessionParser.js +190 -0
  188. package/src/utils/teammateAvatars.js +194 -0
  189. package/src/utils/teammateDetector.js +121 -0
  190. package/src/utils/termDiag.js +114 -0
  191. package/src/utils/terminalClipboard.js +117 -0
  192. package/src/utils/terminalWriteQueue.js +398 -0
  193. package/src/utils/thinkingMerge.js +49 -0
  194. package/src/utils/toolCatalog.js +29 -0
  195. package/src/utils/toolResultBuilder.js +393 -0
  196. package/src/utils/toolResultClassifier.js +16 -0
  197. package/src/utils/toolResultCore.js +167 -0
  198. package/src/utils/toolsDiff.js +44 -0
  199. package/src/utils/toolsXmlFormatter.js +3 -0
  200. package/src/utils/ultraplanController.js +109 -0
  201. package/src/utils/ultraplanExperts.js +86 -0
  202. package/src/utils/ultraplanTemplates.js +154 -0
  203. package/src/utils/userImageRefs.js +49 -0
  204. package/src/utils/v3Assembler.js +112 -0
  205. package/src/utils/v3Rows.js +48 -0
  206. package/src/utils/voicePackPlayer.js +233 -0
  207. package/src/utils/webSearchGrouping.js +111 -0
  208. package/src/utils/workflowFormat.js +52 -0
  209. package/src/utils/workflowRuns.js +118 -0
  210. package/src/utils/workflowStore.js +94 -0
  211. package/dist/assets/App-VTo4NVdo.js +0 -2
  212. package/dist/assets/Mobile-CcmYvMm_.js +0 -1
  213. package/dist/assets/classDiagram-6PBFFD2Q-BXnyUgsj.js +0 -1
  214. package/dist/assets/classDiagram-v2-HSJHXN6E-BXnyUgsj.js +0 -1
  215. package/dist/assets/clone-md-Mc1-T.js +0 -1
  216. package/dist/assets/index-DCo650PG.js +0 -2
  217. package/dist/assets/mermaid.core-C2Parj1q.js +0 -7
  218. package/dist/assets/seqResourceLoaders-BVozq68i.js +0 -2
  219. package/server/lib/instance-registry.js +0 -57
  220. package/server/lib/jsonl-archive.js +0 -180
  221. package/server/lib/teammate-detect.js +0 -32
  222. package/server/lib/zip-safety.js +0 -68
@@ -0,0 +1,1124 @@
1
+ // Wire Format v2 — v2→v1 adapter read layer (docs/refactor/WIRE_FORMAT_V2.md §11).
2
+ //
3
+ // Synthesizes a v1-shape raw-entry stream from a v2 session directory so the
4
+ // existing log-stream consumers (SSE cold load, tail read, paging, download)
5
+ // work unchanged. The synthesis is MECHANICAL replay (same semantics as
6
+ // replay.js): conversation events are applied exactly as the write side derived
7
+ // them; no reverse-anchor/merge inference happens here — the client applies its
8
+ // own sessionMerge to the synthesized stream exactly like it does to v1 logs.
9
+ //
10
+ // Envelope equivalence to v1 (per request, main conversation):
11
+ // snapshot event → checkpoint (_isCheckpoint:true, full state)
12
+ // ctl replace-tail → checkpoint + _inPlaceReplaceDetected (paired signal)
13
+ // append event → delta (_isCheckpoint:false, the appended slice)
14
+ // no event (wire unchanged) → empty delta (v1 wrote those too)
15
+ // Entries are yielded in journal seq order (initiation order), so the client's
16
+ // completion-order guard never fires: _staleReorder/_reconstructBroken are
17
+ // structurally impossible on this stream (spec §11 "绝不输出").
18
+ //
19
+ // SINGLE SYNTHESIS PATH (S6b): SessionSynthesizer below is the one and only
20
+ // implementation of the per-event envelope synthesis. The cold generators feed
21
+ // it eagerly (all lines up front, defer disabled); the live feed
22
+ // (server/lib/v2/live-feed.js) feeds it incrementally from file cursors, so
23
+ // cold reads and live emission are byte-identical by construction.
24
+ //
25
+ // Teammate re-join (spec §10): reading a leader session merges in every sibling
26
+ // session whose meta.leader.parentSessionId points at it, ordered by ts with
27
+ // (sessionId, seq) tie-break — field-equivalent to v1's "teammate writes the
28
+ // leader's file".
29
+
30
+ import { existsSync, readdirSync, readFileSync, statSync, openSync, readSync, closeSync } from 'node:fs';
31
+ import { join, dirname, basename } from 'node:path';
32
+ import { reportSwallowed } from '../error-report.js';
33
+ import { isMainAgentRequest } from '../interceptor-core.js';
34
+ import { readPromptsHead, collectPromptsFromEvents } from '../user-prompt-extract.js';
35
+ import { readSession, readJsonlTolerant, listSessionIds } from './replay.js';
36
+ import { blobPath, isSupportedWireFormat, dirSizeSync } from './layout.js';
37
+ import { SingleFlight } from './singleflight.js';
38
+
39
+ // Same stamping rules as the v1 interceptor (KEEP IN SYNC: server/interceptor.js
40
+ // requestEntry construction) — recomputed from the journal's url, not from kind,
41
+ // so edge cases (a countTokens body that also looks main-agent) fold the same way.
42
+ const HEARTBEAT_URL_RE = /\/api\/eval\/sdk-/;
43
+ const COUNT_TOKENS_URL_RE = /\/messages\/count_tokens/;
44
+
45
+ /** A v2 read source is a session DIRECTORY containing a journal.jsonl. */
46
+ export function isV2SessionDir(p) {
47
+ if (typeof p !== 'string' || p === '') return false;
48
+ try {
49
+ if (!statSync(p).isDirectory()) return false;
50
+ } catch {
51
+ return false;
52
+ }
53
+ return existsSync(join(p, 'journal.jsonl'));
54
+ }
55
+
56
+ /** responses.jsonl folded by seq (first line wins, mirroring §14 done-folding).
57
+ * Values stay RAW strings — parsed one at a time at yield to keep peak memory
58
+ * at "one response", not "all responses" (plan F16). */
59
+ function readResponsesRaw(sessionDir) {
60
+ const bySeq = new Map();
61
+ const p = join(sessionDir, 'responses.jsonl');
62
+ if (!existsSync(p)) return bySeq;
63
+ let raw = '';
64
+ try { raw = readFileSync(p, 'utf-8'); } catch { return bySeq; }
65
+ for (const line of raw.split('\n')) {
66
+ const t = line.trim();
67
+ if (!t) continue;
68
+ const m = t.match(/"seq":\s*(\d+)/);
69
+ if (!m) continue;
70
+ const seq = Number(m[1]);
71
+ if (!bySeq.has(seq)) bySeq.set(seq, t);
72
+ }
73
+ return bySeq;
74
+ }
75
+
76
+ /** Parsed-blob cache, one per synthesizer: refs repeat across most requests
77
+ * (that is the whole point of the CAS), so each distinct blob is read+parsed
78
+ * once. Objects are shared by reference — safe, entries are stringified
79
+ * immediately and never mutated. */
80
+ function makeBlobLoader(paths, sessionId) {
81
+ const cache = new Map();
82
+ return (ref) => {
83
+ if (!ref) return undefined;
84
+ if (cache.has(ref)) return cache.get(ref);
85
+ let value;
86
+ try {
87
+ value = JSON.parse(readFileSync(blobPath(paths, ref), 'utf-8'));
88
+ } catch (err) {
89
+ // Orphan journal reference (spec §14): tolerate, surface, keep reading.
90
+ reportSwallowed('v2-read.blob-missing', new Error(`${sessionId}/${ref}: ${err.message}`));
91
+ value = undefined;
92
+ }
93
+ cache.set(ref, value);
94
+ return value;
95
+ };
96
+ }
97
+
98
+ /** Whether an applied event set makes its request a v1 CHECKPOINT (vs a delta):
99
+ * an applied snapshot or a ctl replace-tail is present; an append-only or
100
+ * wire-unchanged (empty) event set is a delta. Single predicate shared by the
101
+ * descriptor Pass A and the full envelope branch in _emit so they cannot drift
102
+ * (KEEP the envelope's `_isCheckpoint` assignment routed through this). */
103
+ function appliedIsCheckpoint(applied) {
104
+ return applied.some((e) => e.t === 'snapshot' || (e.t === 'ctl' && e.op === 'replace-tail'));
105
+ }
106
+
107
+ /**
108
+ * Incremental per-event synthesis engine — the single synthesis path shared by
109
+ * the cold generators and the live feed.
110
+ *
111
+ * Feeding contract (mirrors the writer's per-file append order, which is
112
+ * seq-ascending for journal req lines and file order for conversation event
113
+ * lines):
114
+ * ingestJournalLine(parsedLine) — 'meta' | 'req' | 'done' phases
115
+ * ingestConvLine(convKey, event) — conversation event lines, file order
116
+ * ingestResponseLine(rawLine) — responses.jsonl lines (raw strings)
117
+ * Synthesized items accumulate internally; callers collect them via drain().
118
+ *
119
+ * Item shape (full synthesis): { ts, sessionId, seq, phase:
120
+ * 'placeholder'|'completed', entry, isMain, stateRef }
121
+ * `entry` is the v1-shape object (stringify on receipt: a live placeholder's
122
+ * entry object is MUTATED in place into its completed form when the done
123
+ * arrives, exactly so both stringify byte-identical to a cold read).
124
+ * `stateRef` is the conversation state array as of this seq (immutably
125
+ * replaced by the replay, so the reference stays valid) — used by the window
126
+ * mode to synthesize a baseline checkpoint at an arbitrary start seq.
127
+ * Descriptor mode (S10a Pass A, opts.descriptorOnly) instead emits the light
128
+ * shape { ts, sessionId, seq, url, isMain, isMainDelta } — no entry/stateRef.
129
+ *
130
+ * Live-mode ordering hazards handled here (S6b review):
131
+ * - A journal req line can land before its own conversation event line
132
+ * (write-order window, spec §14). Cold reads retry for free on the next full
133
+ * read; an incremental cursor has already consumed the journal line, so the
134
+ * request is parked per-conversation and retried when the event arrives.
135
+ * deferMs=0 (cold) degrades to the historical skip-immediately behavior.
136
+ * - A done line can land before its responses.jsonl line (cross-path queue
137
+ * groups). Every completion writes a responses line, so the done is parked
138
+ * until the line arrives, with a deadline fallback to a null response.
139
+ * - Orphan safety: journal req lines are strictly seq-ascending within one
140
+ * journal file, so at the time req N is processed every req ≤ N that will
141
+ * ever exist is known — the crash-orphan check is exact even incrementally.
142
+ */
143
+ export class SessionSynthesizer {
144
+ /**
145
+ * @param {string} sessionDir - absolute session directory
146
+ * @param {object} [opts]
147
+ * @param {object|null} [opts.teammateOf] - meta.leader of this session when
148
+ * it is being re-joined into (or read as) a teammate stream
149
+ * @param {number} [opts.deferMs] - how long live mode parks a request whose
150
+ * conversation event (or a done whose response line) has not landed yet.
151
+ * 0 = cold semantics: skip immediately, next full read self-heals.
152
+ * @param {Function} [opts.now] - clock injection (tests)
153
+ * @param {boolean} [opts.descriptorOnly] - S10a window Pass A: emit light
154
+ * descriptors ({ts,sessionId,seq,url,isMain,isMainDelta}) under the SAME
155
+ * pump/gate/replay semantics, skipping blob backfill / message assembly /
156
+ * entry construction. Membership decisions (crash-orphan skips, msgTo
157
+ * gates) are replay-dependent, so a journal-only scan would diverge.
158
+ * @param {(sessionId:string, seq:number)=>boolean} [opts.materialize]
159
+ * S10a window Pass B: predicate deciding whether a request is synthesized
160
+ * at all. Falsy → state still advances (in _pumpConv) but no entry is
161
+ * built or retained. Baseline promotion happens on the CONSUMER side over
162
+ * item.stateRef, so the ring keeps the un-promoted raw (onScan parity).
163
+ */
164
+ constructor(sessionDir, opts = {}) {
165
+ this.sessionDir = sessionDir;
166
+ this.projectDir = dirname(dirname(sessionDir));
167
+ this._deferMs = typeof opts.deferMs === 'number' ? opts.deferMs : 0;
168
+ this._now = opts.now || Date.now;
169
+ this._descriptorOnly = !!opts.descriptorOnly;
170
+ this._materialize = typeof opts.materialize === 'function' ? opts.materialize : null;
171
+ let meta = null;
172
+ try { meta = JSON.parse(readFileSync(join(sessionDir, 'meta.json'), 'utf-8')); } catch { /* tolerated — journal is self-describing */ }
173
+ this._meta = meta || {};
174
+ // Task C: session IDENTITY = meta.sessionId (the UUID), NOT the dir basename
175
+ // (which is now `<ts>_<uuid>`). This feeds _seqEpoch, k-way tie-break and
176
+ // item dedup — all of which must stay stable across the dir rename and match
177
+ // the wire UUID (teammate parentSessionId). basename is a path token only.
178
+ // Fallback to basename on a torn/missing meta (self-consistent within a load).
179
+ this.sessionId = (meta && meta.sessionId) || basename(sessionDir);
180
+ this._leader = opts.teammateOf || this._meta.leader || null;
181
+ this._loadBlob = makeBlobLoader({ blobsDir: join(sessionDir, 'blobs') }, this.sessionId);
182
+ this.unsupported = false;
183
+ if (meta && meta.wireFormat != null && !isSupportedWireFormat(meta.wireFormat)) {
184
+ this._markUnsupported(meta.wireFormat);
185
+ }
186
+ this._reqs = new Map(); // seq → journal req line
187
+ this._dones = new Map(); // seq → journal done line (first wins, §14)
188
+ this._responses = new Map(); // seq → raw responses.jsonl line (first wins)
189
+ this._convs = new Map(); // convKey → {events, ptr, state, queue, deferSince}
190
+ this._await = new Map(); // seq → item built, waiting for its done
191
+ this._doneWaitingResp = new Map(); // seq → parkedAtMs (done before responses line)
192
+ this._out = [];
193
+ }
194
+
195
+ _markUnsupported(v) {
196
+ if (this.unsupported) return;
197
+ // Reader version gate (spec §14): never interpret a session stamped with a
198
+ // version this build doesn't understand — refuse loudly, emit nothing.
199
+ this.unsupported = true;
200
+ reportSwallowed('v2-read.unsupported-wire-format', new Error(`${this.sessionId}: wireFormat=${v}`));
201
+ }
202
+
203
+ _conv(key) {
204
+ let c = this._convs.get(key);
205
+ if (!c) {
206
+ c = { events: [], ptr: 0, state: [], queue: [], deferSince: null };
207
+ this._convs.set(key, c);
208
+ }
209
+ return c;
210
+ }
211
+
212
+ /** Feed one parsed journal line ('meta' sentinel, 'req', or 'done'). */
213
+ ingestJournalLine(line) {
214
+ if (this.unsupported || !line || typeof line !== 'object') return;
215
+ if (line.ph === 'meta') {
216
+ if (typeof line.wireFormat === 'number' && !isSupportedWireFormat(line.wireFormat)) {
217
+ this._markUnsupported(line.wireFormat);
218
+ }
219
+ return;
220
+ }
221
+ if (line.ph === 'req') {
222
+ if (this._reqs.has(line.seq)) return;
223
+ this._reqs.set(line.seq, line);
224
+ if (line.conv) {
225
+ const c = this._conv(line.conv);
226
+ c.queue.push(line.seq);
227
+ this._pumpConv(line.conv, c);
228
+ } else {
229
+ // Conversation-less request (heartbeat): nothing to gate on.
230
+ this._emit(line, null, []);
231
+ }
232
+ return;
233
+ }
234
+ if (line.ph === 'done') {
235
+ if (this._dones.has(line.seq)) return; // fold duplicate done lines (§14)
236
+ this._dones.set(line.seq, line);
237
+ this._tryComplete(line.seq);
238
+ }
239
+ }
240
+
241
+ /** Feed one conversation event line. Events are kept SEQ-SORTED on insert:
242
+ * file order across epoch files is NOT globally seq-ordered (a restarted
243
+ * writer historically appended newer seqs into an older epoch file, and the
244
+ * cold reader has always sorted globally — replay.js). The monotonic pump
245
+ * pointer requires the sorted invariant or a late-lower-seq event strands
246
+ * behind it forever (2026-07-15 fix: live missing-conv-event /
247
+ * state-count-mismatch false alarms). In-order arrival stays O(1). */
248
+ ingestConvLine(convKey, ev) {
249
+ if (this.unsupported || !ev || typeof ev !== 'object') return;
250
+ const c = this._conv(convKey);
251
+ const events = c.events;
252
+ if (events.length === 0 || ev.seq >= events[events.length - 1].seq) {
253
+ events.push(ev);
254
+ } else {
255
+ let i = events.length - 1;
256
+ while (i >= c.ptr && events[i].seq > ev.seq) i--;
257
+ events.splice(i + 1, 0, ev);
258
+ }
259
+ this._pumpConv(convKey, c);
260
+ }
261
+
262
+ /** Feed one raw responses.jsonl line. */
263
+ ingestResponseLine(rawLine) {
264
+ if (this.unsupported) return;
265
+ const t = String(rawLine).trim();
266
+ const m = t.match(/"seq":\s*(\d+)/);
267
+ if (!m) return;
268
+ const seq = Number(m[1]);
269
+ if (!this._responses.has(seq)) this._responses.set(seq, t);
270
+ if (this._doneWaitingResp.has(seq)) {
271
+ this._doneWaitingResp.delete(seq);
272
+ this._complete(seq);
273
+ }
274
+ }
275
+
276
+ /** Collect synthesized items accumulated since the last drain. */
277
+ drain() {
278
+ if (this._out.length === 0) return [];
279
+ const out = this._out;
280
+ this._out = [];
281
+ return out;
282
+ }
283
+
284
+ /** True when a deferred request or done is parked (live watcher uses this to
285
+ * keep its safety poll armed). */
286
+ hasPending() {
287
+ if (this._doneWaitingResp.size > 0) return true;
288
+ for (const c of this._convs.values()) if (c.deferSince != null) return true;
289
+ return false;
290
+ }
291
+
292
+ /** Live mode: force progress past deferrals older than deferMs. */
293
+ sweepDeadlines() {
294
+ const now = this._now();
295
+ for (const [key, c] of this._convs) {
296
+ if (c.deferSince != null && now - c.deferSince >= this._deferMs) {
297
+ this._pumpConv(key, c, { forceHead: true });
298
+ }
299
+ }
300
+ for (const [seq, parkedAt] of [...this._doneWaitingResp]) {
301
+ if (now - parkedAt >= this._deferMs) {
302
+ this._doneWaitingResp.delete(seq);
303
+ this._complete(seq);
304
+ }
305
+ }
306
+ }
307
+
308
+ /** Process this conversation's request queue as far as the arrived events
309
+ * allow. forceHead skips the (expired) head request cold-style. */
310
+ _pumpConv(convKey, c, { forceHead = false } = {}) {
311
+ while (c.queue.length > 0) {
312
+ const seq = c.queue[0];
313
+ const req = this._reqs.get(seq);
314
+ // Apply this conversation's events up to and including this seq.
315
+ const applied = [];
316
+ while (c.ptr < c.events.length && c.events[c.ptr].seq <= seq) {
317
+ const ev = c.events[c.ptr++];
318
+ if (!this._reqs.has(ev.seq)) continue; // crash orphan (spec §14)
319
+ if (ev.t === 'snapshot') {
320
+ c.state = Array.isArray(ev.msgs) ? ev.msgs : [];
321
+ } else if (ev.t === 'append') {
322
+ if (Array.isArray(ev.msgs) && ev.msgs.length > 0) c.state = c.state.concat(ev.msgs);
323
+ } else if (ev.t === 'ctl' && ev.op === 'replace-tail' && ev.msg && c.state.length > 0) {
324
+ c.state = c.state.slice(0, -1).concat([ev.msg]);
325
+ } // ctl compact: marker only — the same request's snapshot carries the state
326
+ if (ev.seq === seq) applied.push(ev);
327
+ }
328
+
329
+ // Gate: journal says a conversation event exists but the line is not on
330
+ // disk yet (write-order window, §14 pendingTail) or is lost. Emitting a
331
+ // delta against a state missing that slice would poison the client's
332
+ // reconstruction — park (live) or skip (cold/expired); a skip is safe
333
+ // because the next cold read retries with the line present.
334
+ let gap = null;
335
+ if (req.evt && applied.length === 0) {
336
+ gap = () => reportSwallowed('v2-read.missing-conv-event', new Error(`${this.sessionId}/${req.conv} seq=${seq} evt=${req.evt}`));
337
+ } else if (!req.evt && typeof req.msgTo === 'number' && req.msgTo !== c.state.length) {
338
+ // No-event request whose recorded wire count disagrees with the
339
+ // replayed state — an earlier line of this conv is missing.
340
+ gap = () => reportSwallowed('v2-read.state-count-mismatch', new Error(`${this.sessionId}/${req.conv} seq=${seq} msgTo=${req.msgTo} replayed=${c.state.length}`));
341
+ }
342
+ if (gap) {
343
+ if (this._deferMs > 0 && !forceHead) {
344
+ if (c.deferSince == null) c.deferSince = this._now();
345
+ return; // parked — a later ingest or sweepDeadlines resumes the pump
346
+ }
347
+ gap();
348
+ c.queue.shift();
349
+ c.deferSince = null;
350
+ forceHead = false;
351
+ continue;
352
+ }
353
+
354
+ c.deferSince = null;
355
+ c.queue.shift();
356
+ forceHead = false;
357
+ this._emit(req, c, applied);
358
+ }
359
+ // Live-mode memory bound (review P2): applied events are never re-read —
360
+ // drop the consumed prefix so a long session doesn't keep every slice
361
+ // resident. Thresholded so the cold path's per-req pumping doesn't turn
362
+ // this into an O(n²) shift storm. (State arrays are shared immutably;
363
+ // this only trims the event-line objects.)
364
+ if (c.ptr > 64 && c.queue.length === 0) {
365
+ c.events.splice(0, c.ptr);
366
+ c.ptr = 0;
367
+ }
368
+ }
369
+
370
+ /** Build the v1-shape entry for one request and emit it (placeholder now /
371
+ * completed when the done is already known). */
372
+ _emit(req, conv, applied) {
373
+ const seq = req.seq;
374
+ const meta = this._meta;
375
+ const leader = this._leader;
376
+ const isTeammate = !!leader;
377
+ const isMain = req.kind === 'main' && !isTeammate;
378
+ // Single source for the checkpoint/delta decision — consumed by BOTH the
379
+ // descriptor Pass A (below) and the full envelope branch, so the two can
380
+ // never drift (adding a checkpoint-triggering event type updates one place).
381
+ const isCheckpoint = appliedIsCheckpoint(applied);
382
+
383
+ // ---- S10a window passes ------------------------------------------------
384
+ // Pass A (descriptor): same gates/replay as full synthesis, no entry build.
385
+ if (this._descriptorOnly) {
386
+ this._out.push({
387
+ ts: req.ts || '',
388
+ sessionId: this.sessionId,
389
+ seq,
390
+ url: req.url,
391
+ isMain,
392
+ isMainDelta: isMain && !!conv && !isCheckpoint,
393
+ });
394
+ return;
395
+ }
396
+ // Pass B (materialize gate): conversation state was already advanced by
397
+ // _pumpConv, so skipping here skips ONLY blob backfill + entry build +
398
+ // retention — exactly the per-request memory cost the window excludes.
399
+ const mat = this._materialize ? this._materialize(this.sessionId, seq) : true;
400
+ if (!mat) return;
401
+
402
+ // ---- body (blob backfill is per-request by journal ref — never carried) --
403
+ const tools = this._loadBlob(req.blobs && req.blobs.tools);
404
+ const system = this._loadBlob(req.blobs && req.blobs.sys);
405
+ // req.params carries every residual body field (params-era journals);
406
+ // dedicated fields override it so precedence is deterministic. user_id is
407
+ // ALWAYS meta.userIdRaw — the client does equality-based session-boundary
408
+ // detection on it, and a `-c` adopted folder mixes real user_ids that
409
+ // would otherwise split the timeline mid-session.
410
+ const params = req.params && typeof req.params === 'object' ? req.params : null;
411
+ const body = {
412
+ ...params,
413
+ ...(req.model && { model: req.model }),
414
+ ...(system !== undefined && { system }),
415
+ ...(tools !== undefined && { tools }),
416
+ };
417
+ if (meta.userIdRaw) {
418
+ const extra = body.metadata && typeof body.metadata === 'object' ? body.metadata : null;
419
+ body.metadata = { ...extra, user_id: meta.userIdRaw };
420
+ } else if (body.metadata && typeof body.metadata === 'object' && body.metadata.user_id !== undefined) {
421
+ // noid session (no parseable user_id at write time): a raw params
422
+ // user_id may vary per request, and surfacing it would re-open the
423
+ // spurious client boundary-split this block exists to prevent. Drop
424
+ // ONLY user_id; other metadata keys keep full fidelity.
425
+ const { user_id, ...restMeta } = body.metadata;
426
+ if (Object.keys(restMeta).length > 0) body.metadata = restMeta;
427
+ else delete body.metadata;
428
+ }
429
+
430
+ const isTeammateEntry = isTeammate;
431
+ const isMainKind = isMain;
432
+ const entry = {
433
+ timestamp: req.ts,
434
+ project: meta.project || basename(this.projectDir),
435
+ url: req.url,
436
+ method: req.method || 'POST',
437
+ ...(req.headers && { headers: req.headers }),
438
+ body,
439
+ response: null,
440
+ duration: 0,
441
+ isStream: !!req.isStream,
442
+ isHeartbeat: HEARTBEAT_URL_RE.test(req.url || ''),
443
+ isCountTokens: COUNT_TOKENS_URL_RE.test(req.url || ''),
444
+ mainAgent: false, // finalized below once messages are attached
445
+ ...(isTeammateEntry && { teammate: leader.agentName || true, ...(leader.teamName && { teamName: leader.teamName }) }),
446
+ ...(req.proxy && { proxyProfile: req.proxy.profile, ...(req.proxy.url && { proxyUrl: req.proxy.url }) }),
447
+ };
448
+
449
+ // ---- messages + envelope ------------------------------------------------
450
+ if (conv) {
451
+ if (isMainKind) {
452
+ // v1 delta envelope synthesis. The stream is seq-ordered, so the client
453
+ // seq guard sees a strictly increasing sequence in one epoch.
454
+ entry._seq = seq;
455
+ entry._seqEpoch = `v2:${this.sessionId}`;
456
+ entry._deltaFormat = 1;
457
+ entry._totalMessageCount = conv.state.length;
458
+ entry._conversationId = 'mainAgent';
459
+ const snapshot = applied.find((e) => e.t === 'snapshot');
460
+ const replaceTail = applied.find((e) => e.t === 'ctl' && e.op === 'replace-tail');
461
+ const append = applied.find((e) => e.t === 'append');
462
+ if (snapshot || replaceTail || !append) {
463
+ entry._isCheckpoint = isCheckpoint; // = !!(snapshot||replaceTail); empty-wire delta is false
464
+ if (replaceTail && !snapshot) {
465
+ entry._isCheckpoint = true;
466
+ // Paired signal (KEEP IN SYNC: src/utils/sessionManager.js
467
+ // applyInPlaceLastMsgReplace) — same protocol as the v1 writer.
468
+ entry._inPlaceReplaceDetected = true;
469
+ }
470
+ entry.body.messages = entry._isCheckpoint ? conv.state : [];
471
+ } else {
472
+ entry._isCheckpoint = false;
473
+ entry.body.messages = Array.isArray(append.msgs) ? append.msgs : [];
474
+ }
475
+ } else {
476
+ // sub / misc / teammate streams are full-messages in v1 (no envelope).
477
+ entry.body.messages = conv.state;
478
+ }
479
+ }
480
+
481
+ // v1 stamps mainAgent = isMainAgentRequest(body) at request time; kind
482
+ // 'main' was CLASSIFIED by that same predicate, so it maps back to true
483
+ // directly (its delta body would defeat a recompute). Everything else —
484
+ // teammate dual-tag included — recomputes over the backfilled body.
485
+ entry.mainAgent = isMainKind || isMainAgentRequest(entry.body);
486
+
487
+ const item = {
488
+ ts: req.ts || '',
489
+ sessionId: this.sessionId,
490
+ seq,
491
+ phase: 'placeholder',
492
+ entry,
493
+ isMain: isMainKind,
494
+ stateRef: conv ? conv.state : null,
495
+ };
496
+ this._await.set(seq, item);
497
+
498
+ if (this._dones.has(seq)) {
499
+ this._tryComplete(seq);
500
+ } else {
501
+ // req without done — in-flight or the process died (spec §4). This IS the
502
+ // v1 placeholder, so it wears the same flags.
503
+ entry.inProgress = true;
504
+ entry.requestId = req.rid || `${seq}`;
505
+ this._out.push(item);
506
+ }
507
+ }
508
+
509
+ /** A done is known for this seq — complete now, or park until its responses
510
+ * line lands (every completion writes one; cross-path flush can reorder). */
511
+ _tryComplete(seq) {
512
+ if (!this._await.has(seq)) return; // req not synthesized yet — _emit retries
513
+ if (this._deferMs > 0 && !this._responses.has(seq) && !this._doneWaitingResp.has(seq)) {
514
+ this._doneWaitingResp.set(seq, this._now());
515
+ return;
516
+ }
517
+ this._doneWaitingResp.delete(seq);
518
+ this._complete(seq);
519
+ }
520
+
521
+ _complete(seq) {
522
+ const item = this._await.get(seq);
523
+ const done = this._dones.get(seq);
524
+ if (!item || !done) return;
525
+ this._await.delete(seq);
526
+ // If the placeholder is still queued (req and done landed in the same
527
+ // batch), drop it — its entry object is about to be mutated into the
528
+ // completed form and emitting both would just send the same frame twice.
529
+ const queued = this._out.indexOf(item);
530
+ if (queued !== -1) this._out.splice(queued, 1);
531
+ const entry = item.entry;
532
+ // A live placeholder was already emitted for this entry object; strip its
533
+ // in-flight markers so the completed form stringifies byte-identical to a
534
+ // cold read of the same session.
535
+ delete entry.inProgress;
536
+ delete entry.requestId;
537
+ const respRaw = this._responses.get(seq);
538
+ let resp = null;
539
+ if (respRaw) {
540
+ try { resp = JSON.parse(respRaw); } catch { resp = null; }
541
+ }
542
+ entry.response = {
543
+ ...(typeof done.http === 'number' && { status: done.http }),
544
+ ...(resp && resp.statusText && { statusText: resp.statusText }),
545
+ ...(resp && resp.headers && { headers: resp.headers }),
546
+ body: resp ? resp.body : null,
547
+ };
548
+ if (typeof done.dur === 'number') entry.duration = done.dur;
549
+ // Live-mode memory bound (review P2): completed seqs never need their raw
550
+ // response line or done line again — a duplicate done re-arriving is a
551
+ // no-op above (the awaited item is gone). Cold reads drain immediately,
552
+ // so this is pure win there too.
553
+ this._responses.delete(seq);
554
+ this._dones.delete(seq);
555
+ this._out.push({ ...item, phase: 'completed' });
556
+ }
557
+ }
558
+
559
+ /**
560
+ * Cold generator over ONE session dir: yields items (see SessionSynthesizer)
561
+ * in seq order — the eager feeding of the shared synthesis engine.
562
+ */
563
+ function* iterateSessionItems(sessionDir, opts = {}) {
564
+ const projectDir = dirname(dirname(sessionDir));
565
+ const sessionId = basename(sessionDir);
566
+ const session = readSession(projectDir, sessionId);
567
+ if (session.unsupported) {
568
+ reportSwallowed('v2-read.unsupported-wire-format', new Error(`${sessionId}: wireFormat=${session.wireFormat}`));
569
+ return;
570
+ }
571
+ const synth = new SessionSynthesizer(sessionDir, {
572
+ teammateOf: opts.teammateOf,
573
+ deferMs: 0,
574
+ descriptorOnly: opts.descriptorOnly,
575
+ materialize: opts.materialize,
576
+ });
577
+ if (synth.unsupported) return; // meta gate already reported by the constructor
578
+ // Eager seed: all conversation events, responses, and done lines are known
579
+ // before any req is pumped — every gate then resolves exactly like the
580
+ // historical whole-file read.
581
+ for (const [key, events] of session.convEvents) synth._conv(key).events = events;
582
+ synth._responses = readResponsesRaw(sessionDir);
583
+ synth._dones = session.dones;
584
+ const seqs = [...session.reqs.keys()].sort((a, b) => a - b);
585
+ for (const seq of seqs) {
586
+ synth.ingestJournalLine(session.reqs.get(seq));
587
+ yield* synth.drain();
588
+ }
589
+ }
590
+
591
+ /** Sibling sessions whose meta.leader points at this session (spec §10).
592
+ *
593
+ * Leaderless fallback (S6b review P1): native team mode spawns teammates with
594
+ * `--agent-name` but possibly WITHOUT `--parent-session-id`, so meta.leader
595
+ * carries no sid. Those sessions are attributed to the leader session that was
596
+ * most recently started at (or before) the teammate's own start — the exact
597
+ * semantics of v1's findRecentLog ("append to the newest leader log"). */
598
+ export function findTeammateSessionDirs(sessionDir, leaderUuid) {
599
+ const sessionsRoot = dirname(sessionDir);
600
+ const leaderSid = basename(sessionDir); // dir NAME — for the self-skip + leaderless tie-break
601
+ // Task C: teammates record the leader's UUID in meta.leader.parentSessionId
602
+ // (from the CLI `--parent-session-id`, immutable). Since the leader dir is now
603
+ // `<ts>_<uuid>`, we match against the leader's meta.sessionId (the UUID), NOT
604
+ // basename. Read it ONCE up-front (callers pass it; fall back to a direct
605
+ // read) — a mid-loop capture would miss when readdir visits a teammate before
606
+ // the leader's own dir.
607
+ if (!leaderUuid) {
608
+ try { leaderUuid = JSON.parse(readFileSync(join(sessionDir, 'meta.json'), 'utf-8')).sessionId || leaderSid; }
609
+ catch { leaderUuid = leaderSid; }
610
+ }
611
+ const out = [];
612
+ let names = [];
613
+ try {
614
+ names = readdirSync(sessionsRoot, { withFileTypes: true }).filter((e) => e.isDirectory()).map((e) => e.name);
615
+ } catch {
616
+ return out;
617
+ }
618
+ const orphans = []; // teammate sessions without a recorded leader sid
619
+ const leaderStarts = []; // {sid, startTs} of every non-teammate session
620
+ let ownStartTs = '';
621
+ for (const name of names) {
622
+ const dir = join(sessionsRoot, name);
623
+ try {
624
+ const meta = JSON.parse(readFileSync(join(dir, 'meta.json'), 'utf-8'));
625
+ const l = meta && meta.leader;
626
+ if (name === leaderSid) {
627
+ ownStartTs = (meta && meta.startTs) || '';
628
+ continue;
629
+ }
630
+ if (!l) {
631
+ leaderStarts.push({ sid: name, startTs: (meta && meta.startTs) || '' });
632
+ continue;
633
+ }
634
+ // The writer records parentSessionId (interceptor.js); tolerate the spec's
635
+ // original sessionId spelling for hand-built or future dirs. Match the
636
+ // leader's UUID (meta.sessionId), not its dir name (task C).
637
+ if (l.parentSessionId === leaderUuid || l.sessionId === leaderUuid) {
638
+ out.push({ dir, leader: l });
639
+ } else if (!l.parentSessionId && !l.sessionId) {
640
+ orphans.push({ dir, leader: l, startTs: (meta && meta.startTs) || '' });
641
+ }
642
+ } catch { /* not a readable session dir — skip */ }
643
+ }
644
+ for (const o of orphans) {
645
+ // Best temporal match: the latest leader started at or before the teammate.
646
+ // Ties break on sid so exactly ONE leader ever claims an orphan — a strict
647
+ // ">" alone let two leaders sharing a startTs both claim it, duplicating
648
+ // the teammate's traffic into both streams (review P2).
649
+ let best = null; // {startTs, sid}
650
+ if (ownStartTs && ownStartTs <= o.startTs) best = { startTs: ownStartTs, sid: leaderSid };
651
+ for (const cand of leaderStarts) {
652
+ if (!cand.startTs || cand.startTs > o.startTs) continue;
653
+ if (best == null
654
+ || cand.startTs > best.startTs
655
+ || (cand.startTs === best.startTs && cand.sid > best.sid)) {
656
+ best = cand;
657
+ }
658
+ }
659
+ if (best && best.sid === leaderSid) out.push({ dir: o.dir, leader: o.leader });
660
+ }
661
+ return out;
662
+ }
663
+
664
+ /**
665
+ * Item-level iteration of one v2 session with teammate re-join — the shared
666
+ * core of the raw-string generators and the window reader below.
667
+ */
668
+ export function* iterateV2Items(sessionDir, opts = {}) {
669
+ const streams = [iterateSessionItems(sessionDir, opts)];
670
+ const ownMeta = (() => {
671
+ try { return JSON.parse(readFileSync(join(sessionDir, 'meta.json'), 'utf-8')); } catch { return null; }
672
+ })();
673
+ // A teammate session read directly renders itself (tagged via its own meta.
674
+ // leader inside iterateSessionItems); only a LEADER pulls in siblings.
675
+ if (!ownMeta || !ownMeta.leader) {
676
+ for (const tm of findTeammateSessionDirs(sessionDir, ownMeta && ownMeta.sessionId)) {
677
+ streams.push(iterateSessionItems(tm.dir, { ...opts, teammateOf: tm.leader }));
678
+ }
679
+ }
680
+ if (streams.length === 1) {
681
+ yield* streams[0];
682
+ return;
683
+ }
684
+ // k-way merge by (ts, sessionId, seq) — ISO timestamps compare lexically;
685
+ // cross-process seqs are not comparable so sessionId breaks ts ties (§10).
686
+ const heads = streams.map((s) => ({ s, cur: s.next() }));
687
+ for (;;) {
688
+ let best = -1;
689
+ for (let i = 0; i < heads.length; i++) {
690
+ if (heads[i].cur.done) continue;
691
+ if (best === -1) { best = i; continue; }
692
+ const a = heads[i].cur.value;
693
+ const b = heads[best].cur.value;
694
+ if (a.ts < b.ts || (a.ts === b.ts && (a.sessionId < b.sessionId || (a.sessionId === b.sessionId && a.seq < b.seq)))) {
695
+ best = i;
696
+ }
697
+ }
698
+ if (best === -1) return;
699
+ yield heads[best].cur.value;
700
+ heads[best].cur = heads[best].s.next();
701
+ }
702
+ }
703
+
704
+ /**
705
+ * Main entry: iterate one v2 session as a v1-shape raw-entry stream, teammate
706
+ * sessions re-joined. Yields raw JSON strings (same contract as
707
+ * log-stream.iterateRawEntries).
708
+ * @param {string} sessionDir - absolute LOG_DIR/<project>/sessions/<sid>
709
+ */
710
+ export function* iterateV2RawEntries(sessionDir) {
711
+ for (const item of iterateV2Items(sessionDir)) yield JSON.stringify(item.entry);
712
+ }
713
+
714
+ /** Async wrapper with periodic event-loop yields (the synthesis itself is CPU
715
+ * work over small v2 files; the yields keep a live proxy responsive while a
716
+ * big session is being adapted). */
717
+ export async function* iterateV2RawEntriesAsync(sessionDir) {
718
+ let n = 0;
719
+ for (const raw of iterateV2RawEntries(sessionDir)) {
720
+ yield raw;
721
+ if (++n % 20 === 0) await new Promise((resolve) => setImmediate(resolve));
722
+ }
723
+ }
724
+
725
+ // Depth of the newest-mainAgent ring folded into window results (S10a). The
726
+ // /events kv-cache/context-window scan consumed every raw via onScan before;
727
+ // the two-pass window no longer stringifies out-of-window entries, so Pass A
728
+ // tracks the newest ≤3 main descriptors and Pass B force-materializes them.
729
+ // KEEP IN SYNC: server/routes/events.js MAINAGENT_SCAN_RING.
730
+ const MAINAGENT_RING_DEPTH = 3;
731
+
732
+ /** Window pass key: seqs are unique per session (journal folds duplicates),
733
+ * so (sessionId, seq) identifies one synthesized item across the k-way merge. */
734
+ const itemKey = (sessionId, seq) => `${sessionId}\x00${seq}`;
735
+
736
+ // S10b: two-level in-flight coalescing (spec §14.1). Level 1 shares the Pass A
737
+ // descriptor scan per sessionDir (reconnect storms are all the same dir);
738
+ // level 2 shares the full-window Pass B materialization per (dir,limit,before)
739
+ // — since-bearing (incremental) callers stream their own cheap Pass B instead.
740
+ const _scanFlight = new SingleFlight({ ttlMs: 500 });
741
+ const _windowFlight = new SingleFlight({ ttlMs: 500 });
742
+ const _windowStats = { scanRuns: 0, materializeRuns: 0 };
743
+ export function _v2WindowStatsForTest(reset = false) {
744
+ const snap = { ..._windowStats };
745
+ if (reset) { _windowStats.scanRuns = 0; _windowStats.materializeRuns = 0; }
746
+ return snap;
747
+ }
748
+
749
+ /**
750
+ * S10a Pass A: descriptor scan — runs the SAME synthesis gates as Pass B
751
+ * (a journal-only scan would count crash-orphans the synthesizer skips, P0-1)
752
+ * but builds no entries, loads no blobs, stringifies nothing: memory is
753
+ * O(request count) light records + the replayed conversation event lines.
754
+ * The result is IMMUTABLE and shared across coalesced callers — window
755
+ * selection must not mutate `recs`/`ring`.
756
+ */
757
+ export async function scanV2Descriptors(sessionDir) {
758
+ _windowStats.scanRuns++;
759
+ const dedup = new Map(); // key → descriptor (insertion order = output order)
760
+ const ring = []; // newest ≤3 isMain descriptors, pre-dedup iteration order
761
+ let nokey = 0;
762
+ let n = 0;
763
+ for (const d of iterateV2Items(sessionDir, { descriptorOnly: true })) {
764
+ const key = d.ts && d.url ? `${d.ts}|${d.url}` : `__nokey_${nokey++}`;
765
+ dedup.set(key, d);
766
+ // Ring criterion is item.isMain (kind==='main' on a non-teammate session):
767
+ // the old substring filter (`"mainAgent":true` && !`"teammate"`) also
768
+ // admitted sub/misc entries whose backfilled body LOOKED main-agent; the
769
+ // isMain ring drops those — intentional, more-correct behavior (pinned in
770
+ // test/v2-window-two-pass.test.js). Teammate-session main entries are
771
+ // excluded by both (they carry the teammate tag / isMain=false).
772
+ if (d.isMain) {
773
+ ring.push(d);
774
+ if (ring.length > MAINAGENT_RING_DEPTH) ring.shift();
775
+ }
776
+ if (++n % 50 === 0) await new Promise((resolve) => setImmediate(resolve));
777
+ }
778
+ return { recs: [...dedup.values()], ring };
779
+ }
780
+
781
+ /**
782
+ * Window selection over a (possibly shared) descriptor scan — cheap array ops,
783
+ * run per caller. Filter order matches the historical single pass: dedup →
784
+ * `before` → tail-`limit` → `since` (emission filter, applied to membership so
785
+ * excluded entries are never materialized). The baseline candidate is decided
786
+ * on the post-limit window start; `since` may then drop it — exactly like the
787
+ * old emission-time since filter did.
788
+ */
789
+ function selectV2Window(scan, opts = {}) {
790
+ const limit = opts.limit || 0;
791
+ const before = opts.before || null;
792
+ const since = opts.since || null;
793
+ let recs = scan.recs;
794
+ if (before) recs = recs.filter((r) => r.ts && r.ts < before);
795
+ const totalCount = recs.length;
796
+ let hasMore = false;
797
+ if (limit > 0 && recs.length > limit) {
798
+ hasMore = true;
799
+ recs = recs.slice(recs.length - limit);
800
+ }
801
+ const oldestTimestamp = recs.length > 0 ? recs[0].ts : '';
802
+ // Baseline: if the first main-conversation record in the window is a delta,
803
+ // Pass B promotes it to a synthesized checkpoint over its replayed state.
804
+ const firstMainIdx = recs.findIndex((r) => r.isMain);
805
+ let baselineKey = null;
806
+ if (firstMainIdx >= 0 && recs[firstMainIdx].isMainDelta) {
807
+ baselineKey = itemKey(recs[firstMainIdx].sessionId, recs[firstMainIdx].seq);
808
+ }
809
+ if (since) recs = recs.filter((r) => !(r.ts && r.ts < since));
810
+ if (baselineKey && !recs.some((r) => itemKey(r.sessionId, r.seq) === baselineKey)) {
811
+ baselineKey = null; // baseline fell below `since` — incremental clients rely on their cached state
812
+ }
813
+ return { members: recs, totalCount, hasMore, oldestTimestamp, baselineKey, ring: scan.ring };
814
+ }
815
+
816
+ /** Level-1 shared Pass A + per-caller selection. `cached` gates the TTL
817
+ * micro-cache read (F5b: /events live-attach must never consume it). */
818
+ export async function computeV2Window(sessionDir, opts = {}) {
819
+ const scan = await _scanFlight.run(`scan:${sessionDir}`, () => scanV2Descriptors(sessionDir), { cached: !!opts.cached });
820
+ return selectV2Window(scan, opts);
821
+ }
822
+
823
+ /**
824
+ * S10a Pass B: re-synthesize the session, materializing ONLY window members
825
+ * (plus the mainAgent ring). Out-of-window requests advance conversation state
826
+ * without building entries/loading blobs/stringifying — peak memory is
827
+ * O(window raws) + one live conversation state + window-member blobs.
828
+ *
829
+ * `onEntry` (streaming mode) receives members in synthesis (seq) order — when
830
+ * duplicate timestamp|url keys exist this can differ from the historical
831
+ * dedup-map order, but clients re-dedup on the same key so the final state is
832
+ * equivalent (algorithm review F6). `collect` mode fills a slot array in
833
+ * Pass A output order instead: byte-identical to the historical single pass.
834
+ */
835
+ async function materializeV2Window(sessionDir, win, { collect = false, onEntry = null, promoteKeys = null } = {}) {
836
+ const memberIdx = new Map();
837
+ win.members.forEach((d, i) => memberIdx.set(itemKey(d.sessionId, d.seq), i));
838
+ const ringKeys = new Set(win.ring.map((d) => itemKey(d.sessionId, d.seq)));
839
+ const slots = collect ? new Array(win.members.length) : null;
840
+ const ringRaws = new Map();
841
+ const materialize = (sessionId, seq) => {
842
+ const k = itemKey(sessionId, seq);
843
+ return memberIdx.has(k) || ringKeys.has(k);
844
+ };
845
+ let sentCount = 0;
846
+ let n = 0;
847
+ for (const item of iterateV2Items(sessionDir, { materialize })) {
848
+ const k = itemKey(item.sessionId, item.seq);
849
+ let raw = JSON.stringify(item.entry);
850
+ // The ring keeps the UN-promoted raw — parity with the old onScan hook,
851
+ // which saw every entry before the baseline rebuild.
852
+ if (ringKeys.has(k)) ringRaws.set(k, raw);
853
+ const idx = memberIdx.get(k);
854
+ if (idx !== undefined) {
855
+ // Baseline: promote the window-start main delta to a checkpoint over its
856
+ // replayed state (item.stateRef aliases the live conversation state at
857
+ // this seq — used transiently, never retained). Same construction as the
858
+ // historical end-of-window rebuild, byte-for-byte.
859
+ // `promoteKeys` (V3.S1): per-member force-checkpoint for single-entry
860
+ // reads — a mid-session main delta carries only its appended slice, but
861
+ // the detail view needs the full replayed state for BOTH the target and
862
+ // its prevMain (Body Diff / cacheLoss compare whole messages arrays).
863
+ // The window baseline promotes only win.baselineKey (the window start).
864
+ if (k === win.baselineKey || (promoteKeys && promoteKeys.has(k))) {
865
+ const state = item.stateRef || [];
866
+ const rebuilt = { ...item.entry, body: { ...item.entry.body, messages: state } };
867
+ rebuilt._isCheckpoint = true;
868
+ rebuilt._totalMessageCount = state.length;
869
+ raw = JSON.stringify(rebuilt);
870
+ }
871
+ if (collect) {
872
+ slots[idx] = raw;
873
+ } else if (onEntry) {
874
+ await onEntry(raw);
875
+ }
876
+ sentCount++;
877
+ }
878
+ if (++n % 20 === 0) await new Promise((resolve) => setImmediate(resolve));
879
+ }
880
+ const mainAgentRing = win.ring
881
+ .map((d) => ringRaws.get(itemKey(d.sessionId, d.seq)))
882
+ .filter(Boolean);
883
+ return { slots, sentCount, mainAgentRing };
884
+ }
885
+
886
+ /**
887
+ * Windowed read of a v2 session: dedup (timestamp|url, last wins), optional
888
+ * `before` filter, tail-`limit` slice — and a BASELINE GUARANTEE the byte-tail
889
+ * heuristics of v1 files cannot give: when the window starts on a main-
890
+ * conversation delta, that entry is synthesized as a checkpoint carrying the
891
+ * full replayed state at its seq, so a window is always reconstructable
892
+ * regardless of how sparse the session's organic snapshots are.
893
+ *
894
+ * S10a: two-pass. The historical single pass stringified and RETAINED every
895
+ * entry (~10x on-disk expansion resident per load — the 2026-07-15 OOM);
896
+ * now Pass A selects the window over light descriptors and Pass B
897
+ * materializes only its members. Output is byte-identical to the single pass.
898
+ * The onScan option is retired on this path — consumers use `mainAgentRing`
899
+ * from the result instead (P0-2).
900
+ *
901
+ * S10b: level-2 coalescing — a whole window result keyed (dir,limit,before) is
902
+ * shared across concurrent callers and TTL-cached for the read-only historical
903
+ * surfaces. `since` is NOT in the key (it filters emission over the same
904
+ * window), so this array path does not take `since`; the /events incremental
905
+ * path uses streamV2WindowedEntries with its own per-caller Pass B.
906
+ *
907
+ * IMMUTABILITY: the returned object (incl. `entries` and `mainAgentRing`) may
908
+ * be shared by reference across coalesced + TTL-cached callers — treat it as
909
+ * READ-ONLY. An in-place mutation (sort/reverse/push) would corrupt the shared
910
+ * cache for concurrent readers; copy first if you must mutate.
911
+ *
912
+ * @param {string} sessionDir
913
+ * @param {{limit?: number, before?: string|null, cached?: boolean}} [opts]
914
+ * @returns {Promise<{entries: string[], hasMore: boolean, oldestTimestamp: string, totalCount: number, mainAgentRing: string[]}>}
915
+ */
916
+ /**
917
+ * V3.S1: on-demand single-entry read — the detail view's data source once the
918
+ * request list is metadata rows. Locates the target (sessionId, seq) in the
919
+ * shared descriptor scan, pairs it with its preceding isMain descriptor
920
+ * (Body Diff / Context tab need the previous mainAgent, raw-seq adjacency),
921
+ * and materializes exactly those members with per-member checkpoint promotion
922
+ * (main deltas come back with the full replayed state, matching what the
923
+ * client holds after reconstruction on the legacy channel).
924
+ *
925
+ * @param {string} sessionDir - leader session dir (teammate rows resolve
926
+ * through the leader's k-way fold so tags/order match the list)
927
+ * @param {{seq: number, sessionId?: string|null, cached?: boolean}} opts -
928
+ * sessionId (UUID) disambiguates teammate rows; null = any session's seq
929
+ * @returns {Promise<{entry: string, prevMain: string|null}|null>} raw JSON
930
+ * strings (never re-parsed here), or null when the seq is unknown / gated
931
+ * out by synthesis (crash-orphan) — callers answer 404.
932
+ */
933
+ export async function readV2SingleEntry(sessionDir, opts = {}) {
934
+ const seq = opts.seq;
935
+ const sessionId = opts.sessionId || null;
936
+ const key = `entry:${sessionDir}|${sessionId || ''}|${seq}`;
937
+ return _windowFlight.run(key, async () => {
938
+ const scan = await _scanFlight.run(`scan:${sessionDir}`, () => scanV2Descriptors(sessionDir), { cached: true });
939
+ const recs = scan.recs;
940
+ const idx = recs.findIndex((d) => d.seq === seq && (!sessionId || d.sessionId === sessionId));
941
+ if (idx === -1) return null;
942
+ const target = recs[idx];
943
+ let prevMain = null;
944
+ for (let i = idx - 1; i >= 0; i--) {
945
+ if (recs[i].isMain) { prevMain = recs[i]; break; }
946
+ }
947
+ const members = prevMain ? [prevMain, target] : [target];
948
+ const win = { members, ring: [], baselineKey: null };
949
+ const promoteKeys = new Set(members.filter((d) => d.isMainDelta).map((d) => itemKey(d.sessionId, d.seq)));
950
+ const { slots } = await materializeV2Window(sessionDir, win, { collect: true, promoteKeys });
951
+ const entry = slots[members.length - 1];
952
+ if (entry === undefined) return null; // synthesis gate dropped it between passes
953
+ return { entry, prevMain: prevMain ? (slots[0] ?? null) : null };
954
+ }, { cached: opts.cached !== false });
955
+ }
956
+
957
+ export async function readV2WindowedEntries(sessionDir, opts = {}) {
958
+ const cached = !!opts.cached;
959
+ const key = `win:${sessionDir}|${opts.limit || 0}|${opts.before || ''}`;
960
+ return _windowFlight.run(key, async () => {
961
+ _windowStats.materializeRuns++;
962
+ const win = await computeV2Window(sessionDir, opts);
963
+ const { slots, mainAgentRing } = await materializeV2Window(sessionDir, win, { collect: true });
964
+ const entries = [];
965
+ for (let i = 0; i < slots.length; i++) {
966
+ if (slots[i] !== undefined) {
967
+ entries.push(slots[i]);
968
+ } else {
969
+ // Pass B synthesized a different member set than Pass A — both passes
970
+ // run the same gates, so a hole means the session changed between
971
+ // passes (live append) or a synthesis bug. Surface it; entry dropped.
972
+ reportSwallowed('v2-read.window-pass-divergence', new Error(`${basename(sessionDir)}: window slot ${i} not materialized`));
973
+ }
974
+ }
975
+ return {
976
+ entries,
977
+ hasMore: win.hasMore,
978
+ oldestTimestamp: win.oldestTimestamp,
979
+ totalCount: win.totalCount,
980
+ mainAgentRing,
981
+ };
982
+ }, { cached });
983
+ }
984
+
985
+ /**
986
+ * Streaming window read (S10a): same selection semantics as
987
+ * readV2WindowedEntries but entries are delivered one at a time via `onEntry`
988
+ * and never accumulated — the limit=0 full-session and `/events` incremental
989
+ * (`since`) paths stay O(one entry) resident instead of materializing the
990
+ * whole expanded session (the pre-S10a reconnect-storm OOM path).
991
+ *
992
+ * @param {string} sessionDir
993
+ * @param {{limit?: number, before?: string|null, since?: string|null,
994
+ * onReady?: (info:{totalCount:number, hasMore:boolean, oldestTs:string|null})=>void}} opts
995
+ * @param {(raw: string) => void|Promise<void>} onEntry
996
+ * @returns {Promise<{sentCount: number, totalCount: number, mainAgentRing: string[]}>}
997
+ */
998
+ export async function streamV2WindowedEntries(sessionDir, opts, onEntry) {
999
+ const win = await computeV2Window(sessionDir, opts);
1000
+ if (opts.onReady) opts.onReady({ totalCount: win.totalCount, hasMore: win.hasMore, oldestTs: win.oldestTimestamp || null });
1001
+ // Bounded window (limit>0): collect into slots and emit in Pass A output
1002
+ // order — byte-order parity with the historical single pass. Unbounded paths
1003
+ // (limit=0: /api/requests, workspaces reload, explicit ?limit=0) stream in
1004
+ // synthesis (seq) order without retention. ACCEPTED ORDER CHANGE: when two
1005
+ // entries share timestamp|url (e.g. same-ms countTokens bursts), the dedup
1006
+ // survivor emits at its later seq position instead of the first-occurrence
1007
+ // slot. Safe because the client re-dedups by the same key and main deltas
1008
+ // reconstruct via _seq/_seqEpoch (not array index) — final state is
1009
+ // order-independent; the default /events cold-load is bounded and unaffected.
1010
+ if (opts.limit > 0) {
1011
+ const { slots, mainAgentRing } = await materializeV2Window(sessionDir, win, { collect: true });
1012
+ let sentCount = 0;
1013
+ for (let i = 0; i < slots.length; i++) {
1014
+ if (slots[i] === undefined) {
1015
+ // Pass A selected a member Pass B didn't materialize (a live append
1016
+ // between passes / a synthesis bug). Same invariant break the array
1017
+ // reader reports — surface it here too rather than dropping silently
1018
+ // (this streaming path is the /events live source, where it's most
1019
+ // likely). The entry is omitted; the client's next `since` read heals.
1020
+ reportSwallowed('v2-read.window-pass-divergence', new Error(`${basename(sessionDir)}: window slot ${i} not materialized (stream)`));
1021
+ continue;
1022
+ }
1023
+ await onEntry(slots[i]);
1024
+ if (++sentCount % 20 === 0) await new Promise((resolve) => setImmediate(resolve));
1025
+ }
1026
+ return { sentCount, totalCount: win.totalCount, mainAgentRing };
1027
+ }
1028
+ const { sentCount, mainAgentRing } = await materializeV2Window(sessionDir, win, { onEntry });
1029
+ return { sentCount, totalCount: win.totalCount, mainAgentRing };
1030
+ }
1031
+
1032
+ // ─── session listing (spec §12, list entry pulled forward from S6a) ─────────
1033
+
1034
+ /** Bounded head read: parse the FIRST JSONL line of a file without loading the
1035
+ * whole thing (a main conversation's opening snapshot can be multi-MB; the
1036
+ * list only wants a preview). Returns null on any shortfall. */
1037
+ function readFirstJsonLine(path, budget = 256 * 1024) {
1038
+ let fd;
1039
+ try {
1040
+ fd = openSync(path, 'r');
1041
+ const buf = Buffer.alloc(budget);
1042
+ const n = readSync(fd, buf, 0, budget, 0);
1043
+ const head = buf.toString('utf-8', 0, n);
1044
+ const nl = head.indexOf('\n');
1045
+ if (nl <= 0) return null; // no complete first line inside the budget
1046
+ return JSON.parse(head.slice(0, nl));
1047
+ } catch {
1048
+ return null;
1049
+ } finally {
1050
+ if (fd !== undefined) { try { closeSync(fd); } catch { /* already closed */ } }
1051
+ }
1052
+ }
1053
+
1054
+ /**
1055
+ * Summarize every session under LOG_DIR/<project>/ for the log list (spec §12).
1056
+ * Deliberately cheap: journal lines only (small) + a bounded head read of the
1057
+ * main conversation's first epoch for the preview — conversation bodies are
1058
+ * never loaded. Teammate linkage is surfaced via `leader` so the caller can
1059
+ * fold those sessions into their leader's view instead of double-listing.
1060
+ * @returns {Array<{sid, dir, startTs, leader, turns, size, preview}>}
1061
+ */
1062
+ export function listV2Sessions(projectDir) {
1063
+ const out = [];
1064
+ for (const sid of listSessionIds(projectDir)) {
1065
+ try {
1066
+ const dir = join(projectDir, 'sessions', sid);
1067
+ if (!existsSync(join(dir, 'journal.jsonl'))) continue;
1068
+ let meta = null;
1069
+ try { meta = JSON.parse(readFileSync(join(dir, 'meta.json'), 'utf-8')); } catch { /* tolerated — journal is self-describing */ }
1070
+ if (meta && meta.wireFormat != null && !isSupportedWireFormat(meta.wireFormat)) {
1071
+ // Reader version gate (spec §14): don't list a session this build
1072
+ // can't read — a garbage preview/turn-count is worse than absence.
1073
+ reportSwallowed('v2-read.unsupported-wire-format', new Error(`${sid}: wireFormat=${meta.wireFormat}`));
1074
+ continue;
1075
+ }
1076
+
1077
+ // turns = main requests that completed (journal two-phase fold). The
1078
+ // journal sentinel is checked in the same pass: per §14 the per-file
1079
+ // sentinel WINS over meta.json, and readSession/adapter refuse such a
1080
+ // session — listing it would show a phantom row that opens empty.
1081
+ const reqKind = new Map();
1082
+ let turns = 0;
1083
+ let sentinelVersion = null;
1084
+ for (const line of readJsonlTolerant(join(dir, 'journal.jsonl'))) {
1085
+ if (line.ph === 'req') reqKind.set(line.seq, line.kind);
1086
+ else if (line.ph === 'done' && reqKind.get(line.seq) === 'main') {
1087
+ turns++;
1088
+ reqKind.delete(line.seq); // fold duplicate done lines (§14)
1089
+ } else if (line.ph === 'meta' && typeof line.wireFormat === 'number' && !isSupportedWireFormat(line.wireFormat)) {
1090
+ sentinelVersion = line.wireFormat;
1091
+ break;
1092
+ }
1093
+ }
1094
+ if (sentinelVersion != null) {
1095
+ reportSwallowed('v2-read.unsupported-wire-format', new Error(`${sid}: wireFormat=${sentinelVersion} (journal sentinel)`));
1096
+ continue;
1097
+ }
1098
+
1099
+ // preview = ALL user prompts of the session, from the prompts.jsonl
1100
+ // display cache (written by V2Writer / the converter; bounded head read
1101
+ // so the list stays O(budget) per session). Sessions predating the
1102
+ // cache fall back to the first epoch's first line — routed through the
1103
+ // shared extractor so command/caveat chrome never leaks into the row.
1104
+ let preview = readPromptsHead(join(dir, 'prompts.jsonl'));
1105
+ if (preview.length === 0) {
1106
+ const first = readFirstJsonLine(join(dir, 'conversations', 'main', 'e0.jsonl'));
1107
+ if (first && Array.isArray(first.msgs)) {
1108
+ preview = collectPromptsFromEvents([first]);
1109
+ }
1110
+ }
1111
+
1112
+ out.push({
1113
+ sid,
1114
+ dir,
1115
+ startTs: (meta && meta.startTs) || '',
1116
+ leader: (meta && meta.leader) || null,
1117
+ turns,
1118
+ size: dirSizeSync(dir),
1119
+ preview,
1120
+ });
1121
+ } catch { /* one unreadable session must not break the list */ }
1122
+ }
1123
+ return out;
1124
+ }