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,222 @@
1
+ // Cold-load fallback session selection (task B, 2026-07-15).
2
+ //
3
+ // On `ccv`/`ccv -c` startup the current session's dir does not exist until its
4
+ // first API request resolves a sid, so `getLiveLogSource()` would return '' and
5
+ // the [对话] panel loads empty. This picker supplies the session to cold-load:
6
+ // the most-recent MAIN (non-teammate) session that actually has a main turn,
7
+ // so the panel shows the last real conversation.
8
+ //
9
+ // CONTENT REQUIREMENT (2026-07-15 refresh-bug fix): a session can be the
10
+ // writer's "current" session (its sid resolved) while having NO main turn yet —
11
+ // e.g. `-c` startup, or a background sub / count_tokens request sets _currentSid
12
+ // before the user sends anything. Cold-loading such an empty session renders a
13
+ // blank panel (the symptom: fresh load shows data, a refresh once the empty
14
+ // current session exists shows nothing, and it only fills after the user sends
15
+ // a request). So selection requires at least one `kind:'main'` request — the
16
+ // "activated" session — and both getLiveLogSource and the picker apply it.
17
+ //
18
+ // Deliberately lightweight — meta.json + a bounded journal HEAD read only. It
19
+ // does NOT use listV2Sessions (dirSizeSync recursion + prompts head-reads +
20
+ // full turn counting): this runs on every cold `/events` connection.
21
+
22
+ import { existsSync, readFileSync, openSync, readSync, closeSync } from 'node:fs';
23
+ import { join } from 'node:path';
24
+ import { StringDecoder } from 'node:string_decoder';
25
+ import { listSessionIds } from './replay.js';
26
+ import { isSupportedWireFormat } from './layout.js';
27
+
28
+ /**
29
+ * Find the on-disk dir NAME of an existing session for `sessionId` (a UUID), or
30
+ * null. Task C: dir names are `<ts>_<uuid>` (new) or `<uuid>` (legacy/staging),
31
+ * so the identity UUID is recovered by suffix. Used by the writer (restart /
32
+ * `-c` re-attach: must reuse the existing dir, not mint a second one) and by
33
+ * convert's dual-write skip. Match precedence: exact `=== sessionId` (legacy),
34
+ * then `endsWith('_'+sessionId)`; on >1 suffix match prefer meta.sessionId===id,
35
+ * else the newest meta.startTs (partial-migration / live-skip can leave both).
36
+ * @returns {string|null} the dir basename, or null if none exists
37
+ */
38
+ export function resolveSessionDirName(projectDir, sessionId, sessionsDirName = 'sessions') {
39
+ if (!projectDir || !sessionId) return null;
40
+ const suffix = '_' + sessionId;
41
+ let bestSuffix = null; // { name, startTs, idMatch }
42
+ for (const name of listSessionIds(projectDir, sessionsDirName)) {
43
+ if (name === sessionId) return name; // exact legacy dir — unambiguous
44
+ if (!name.endsWith(suffix)) continue;
45
+ let meta = null;
46
+ try { meta = JSON.parse(readFileSync(join(projectDir, sessionsDirName, name, 'meta.json'), 'utf-8')); } catch { /* tolerate */ }
47
+ const idMatch = !!(meta && meta.sessionId === sessionId);
48
+ const startTs = (meta && meta.startTs) || '';
49
+ if (bestSuffix === null
50
+ || (idMatch && !bestSuffix.idMatch)
51
+ || (idMatch === bestSuffix.idMatch && startTs > bestSuffix.startTs)) {
52
+ bestSuffix = { name, startTs, idMatch };
53
+ }
54
+ }
55
+ return bestSuffix ? bestSuffix.name : null;
56
+ }
57
+
58
+ const MAIN_REQ_SCAN_BUDGET = 256 * 1024; // a session's first main req sits at the journal head
59
+ // The first main turn's `done` can land far past the head: journal lines are
60
+ // metadata-only (~0.5–2 KB each; bodies live in conversation/blob stores), but a
61
+ // heavy multi-agent first turn interleaves many sub/heartbeat req+done lines
62
+ // before the main done. 8 MB ≈ thousands of journal lines — far beyond any
63
+ // realistic first turn — and the streaming scanner early-exits on the first hit,
64
+ // so a session with a completed turn stops within its first few hundred lines.
65
+ const COMPLETED_TURN_SCAN_BUDGET = 8 * 1024 * 1024;
66
+ const SCAN_CHUNK = 64 * 1024;
67
+
68
+ /**
69
+ * Stream journal.jsonl through `onLine(line)` (trimmed, non-empty lines only),
70
+ * reading fixed-size chunks so memory stays bounded regardless of file size.
71
+ * Stops as soon as `onLine` returns truthy (→ true) or `budget` bytes have been
72
+ * read (→ false). The final unterminated line (possibly torn mid-write) is
73
+ * delivered too — predicates JSON.parse and ignore what doesn't parse. Missing
74
+ * file / any read error → false (callers treat that as "not activated").
75
+ * @param {string} dir - absolute session dir
76
+ * @param {number} budget - max bytes to read before giving up
77
+ * @param {(line:string) => boolean} onLine
78
+ * @returns {boolean}
79
+ */
80
+ function scanJournal(dir, budget, onLine) {
81
+ const journal = join(dir, 'journal.jsonl');
82
+ if (!existsSync(journal)) return false;
83
+ let fd;
84
+ try {
85
+ fd = openSync(journal, 'r');
86
+ const chunk = Buffer.alloc(Math.min(SCAN_CHUNK, budget));
87
+ const decoder = new StringDecoder('utf-8'); // multi-byte chars may straddle chunks
88
+ let read = 0;
89
+ let carry = ''; // unterminated tail of the previous chunk
90
+ while (read < budget) {
91
+ const n = readSync(fd, chunk, 0, Math.min(chunk.length, budget - read), read);
92
+ if (n <= 0) break;
93
+ read += n;
94
+ carry += decoder.write(n === chunk.length ? chunk : chunk.subarray(0, n));
95
+ let start = 0;
96
+ for (;;) {
97
+ const nl = carry.indexOf('\n', start);
98
+ if (nl === -1) break;
99
+ const line = carry.slice(start, nl).trim();
100
+ start = nl + 1;
101
+ if (line && onLine(line)) return true;
102
+ }
103
+ if (start > 0) carry = carry.slice(start);
104
+ }
105
+ const last = (carry + decoder.end()).trim();
106
+ return !!(last && onLine(last));
107
+ } catch {
108
+ return false;
109
+ } finally {
110
+ if (fd !== undefined) { try { closeSync(fd); } catch {} }
111
+ }
112
+ }
113
+
114
+ /**
115
+ * Does this session dir have at least one `kind:'main'` request on disk? A
116
+ * bounded head scan of journal.jsonl — the opening main request is at the top,
117
+ * so a session that has ANY main turn is detected within the budget. An empty /
118
+ * sub-only / not-yet-activated session returns false.
119
+ * @param {string} dir - absolute session dir
120
+ * @returns {boolean}
121
+ */
122
+ export function sessionHasMainTurn(dir) {
123
+ return scanJournal(dir, MAIN_REQ_SCAN_BUDGET, (line) => {
124
+ // Cheap prefilter before JSON.parse: a main req line carries both.
125
+ if (!line.includes('"ph":"req"') || !line.includes('"kind":"main"')) return false;
126
+ try {
127
+ const o = JSON.parse(line);
128
+ return !!(o && o.ph === 'req' && o.kind === 'main');
129
+ } catch { return false; } // torn line — keep scanning
130
+ });
131
+ }
132
+
133
+ /**
134
+ * Does this session dir have at least one COMPLETED main turn — a `kind:'main'`
135
+ * request whose `done` line is also present? A streaming scan correlating
136
+ * main-req seqs with done seqs (either arrival order), bounded by
137
+ * COMPLETED_TURN_SCAN_BUDGET — wide enough that a heavy multi-agent first turn
138
+ * whose done lands megabytes past the head is still found. Stronger than
139
+ * `sessionHasMainTurn`: a session with only an in-flight first main request (req
140
+ * written, response still streaming) returns false, so cold-load keeps showing
141
+ * the previous conversation until the current one has renderable content.
142
+ * @param {string} dir - absolute session dir
143
+ * @returns {boolean}
144
+ */
145
+ export function sessionHasCompletedMainTurn(dir) {
146
+ const mainSeqs = new Set(); // seqs of kind:'main' req lines seen so far
147
+ const doneSeqs = new Set(); // seqs of done lines seen so far
148
+ return scanJournal(dir, COMPLETED_TURN_SCAN_BUDGET, (line) => {
149
+ if (line.includes('"ph":"req"') && line.includes('"kind":"main"')) {
150
+ try {
151
+ const o = JSON.parse(line);
152
+ if (o && o.ph === 'req' && o.kind === 'main' && o.seq != null) {
153
+ if (doneSeqs.has(o.seq)) return true;
154
+ mainSeqs.add(o.seq);
155
+ }
156
+ } catch { /* torn line — keep scanning */ }
157
+ } else if (line.includes('"ph":"done"')) {
158
+ try {
159
+ const o = JSON.parse(line);
160
+ if (o && o.ph === 'done' && o.seq != null) {
161
+ if (mainSeqs.has(o.seq)) return true;
162
+ doneSeqs.add(o.seq);
163
+ }
164
+ } catch { /* torn line — keep scanning */ }
165
+ }
166
+ return false;
167
+ });
168
+ }
169
+
170
+ /**
171
+ * The newest readable, non-teammate session that HAS a main turn, as
172
+ * `{ dir, sessionId }`, or null. "Readable" mirrors listV2Sessions' gates (spec
173
+ * §14): journal.jsonl exists and the wireFormat is supported. Candidates are
174
+ * ordered by meta.startTs (desc) and the first with a main turn wins. Returns
175
+ * the identity UUID (meta.sessionId) alongside the dir so a `-c` continuation
176
+ * can adopt this session's folder while preserving its identity.
177
+ *
178
+ * `excludeDir` skips one absolute session dir entirely: getLiveLogSource passes
179
+ * its current in-flight session here, because this picker's weaker
180
+ * has-a-main-req gate would otherwise re-select exactly the dir the caller's
181
+ * completed-turn gate just rejected (it IS the newest once its first main req
182
+ * is written), nullifying the fallback.
183
+ * @param {string} projectDir - absolute LOG_DIR/<project>
184
+ * @param {{excludeDir?: string}} [opts]
185
+ * @returns {{dir:string, sessionId:string}|null}
186
+ */
187
+ export function latestMainSession(projectDir, { excludeDir = '' } = {}) {
188
+ if (!projectDir) return null;
189
+ const candidates = []; // { dir, sessionId, startTs }
190
+ for (const name of listSessionIds(projectDir)) {
191
+ const dir = join(projectDir, 'sessions', name);
192
+ if (excludeDir && dir === excludeDir) continue;
193
+ if (!existsSync(join(dir, 'journal.jsonl'))) continue;
194
+ let meta = null;
195
+ try { meta = JSON.parse(readFileSync(join(dir, 'meta.json'), 'utf-8')); } catch { continue; }
196
+ if (!meta) continue;
197
+ if (meta.wireFormat != null && !isSupportedWireFormat(meta.wireFormat)) continue;
198
+ // Teammate/sub sessions carry a `leader` pointer; only a leader session
199
+ // folds in its siblings when rendered, so a teammate dir loaded directly
200
+ // renders wrong. Keep only main (leader-less) sessions.
201
+ if (meta.leader) continue;
202
+ candidates.push({ dir, sessionId: meta.sessionId || '', startTs: meta.startTs || '' });
203
+ }
204
+ // Newest first, then return the first that actually has a main turn — skipping
205
+ // a not-yet-activated newest session (the refresh-bug fix).
206
+ candidates.sort((a, b) => (a.startTs < b.startTs ? 1 : a.startTs > b.startTs ? -1 : 0));
207
+ for (const c of candidates) {
208
+ if (sessionHasMainTurn(c.dir)) return { dir: c.dir, sessionId: c.sessionId };
209
+ }
210
+ return null;
211
+ }
212
+
213
+ /**
214
+ * Absolute dir of the newest readable, non-teammate session that HAS a main
215
+ * turn, or '' if there is none. Thin wrapper over {@link latestMainSession}.
216
+ * @param {string} projectDir - absolute LOG_DIR/<project>
217
+ * @param {{excludeDir?: string}} [opts] - see {@link latestMainSession}
218
+ * @returns {string} absolute session dir, or ''
219
+ */
220
+ export function latestMainSessionDir(projectDir, opts) {
221
+ return latestMainSession(projectDir, opts)?.dir || '';
222
+ }
@@ -0,0 +1,104 @@
1
+ // wire-v2 S10b — in-flight coalescing + bounded TTL micro-cache for the
2
+ // windowed v2 reads (docs/refactor/WIRE_FORMAT_V2.md §14.1).
3
+ //
4
+ // Concurrent windowed loads of the SAME session (multi-tab /events cold loads,
5
+ // EventSource auto-reconnect storms, /api/local-log + IM popup on the live
6
+ // session) used to run one full synthesis EACH — 2-3 concurrent 1.5GB
7
+ // syntheses was the 2026-07-15 OOM trigger. Coalescing shares one run.
8
+ //
9
+ // Cache semantics (algorithm review F5b): callers pass `cached` per run.
10
+ // cached=true — read-only historical surfaces (/api/local-log tail, IM
11
+ // popup): may consume a ≤ttl-stale result. IM freshness is
12
+ // backfilled by the im_log_update SSE stream.
13
+ // cached=false — the /events live-attach path: NEVER reads the ≤ttl cache,
14
+ // and still populates the cache for later historical readers.
15
+ // NOTE it does NOT guarantee a maximally-fresh window: it
16
+ // still JOINS a currently-executing run for the same key, so
17
+ // its result reflects the moment that run STARTED (staleness ≤
18
+ // one in-flight run's duration). For /events this is bounded
19
+ // because the live-attach path only uses the LEVEL-1 scan
20
+ // flight (a light descriptor pass), not the level-2 window
21
+ // flight; the client's next `since` reconnect heals any gap.
22
+ // Refusing to join would reopen the reconnect-storm OOM.
23
+ // Failed runs are never cached; the in-flight slot is always released.
24
+ //
25
+ // Bounded cache: a whole window object (entries: string[]) can be hundreds of
26
+ // MB, so beyond the TTL sweep the cache is capped at MAX_CACHE_ENTRIES
27
+ // (oldest-inserted evicted) — a burst of distinct keys (rapid `before`
28
+ // pagination, many sessions) within one TTL must not re-accumulate the very
29
+ // memory pressure S10 removes.
30
+ const MAX_CACHE_ENTRIES = 8;
31
+
32
+ export class SingleFlight {
33
+ /**
34
+ * @param {{ttlMs?: number, now?: () => number}} [opts] - ttlMs bounds the
35
+ * micro-cache; now is clock injection for tests.
36
+ */
37
+ constructor(opts = {}) {
38
+ this._ttlMs = typeof opts.ttlMs === 'number' ? opts.ttlMs : 500;
39
+ this._now = opts.now || Date.now;
40
+ this._inflight = new Map(); // key → Promise
41
+ this._cache = new Map(); // key → {value, expiresAt}
42
+ }
43
+
44
+ /**
45
+ * Run `fn` under `key`, coalescing with any in-flight run of the same key.
46
+ * @param {string} key
47
+ * @param {() => Promise<any>|any} fn
48
+ * @param {{cached?: boolean}} [opts] - cached=false bypasses the cache READ
49
+ * (live-attach safety) while still joining/populating.
50
+ * @returns {Promise<any>}
51
+ */
52
+ run(key, fn, opts = {}) {
53
+ const cached = opts.cached !== false;
54
+ this._sweep();
55
+ if (cached) {
56
+ const c = this._cache.get(key);
57
+ if (c && this._now() < c.expiresAt) return Promise.resolve(c.value);
58
+ }
59
+ const existing = this._inflight.get(key);
60
+ if (existing) return existing;
61
+ // Cache write + in-flight cleanup settle in the SAME continuation as the
62
+ // run, BEFORE the returned promise resolves — so a later caller never joins
63
+ // an already-settled run (which would defeat cached=false freshness) and
64
+ // never sees a populated cache without the slot cleared.
65
+ const p = (async () => {
66
+ // Defer fn to a microtask so this._inflight.set(key, p) below runs FIRST
67
+ // — otherwise a synchronous throw in fn would run the finally (delete)
68
+ // before the slot is even registered, stranding it.
69
+ try {
70
+ const value = await Promise.resolve().then(fn);
71
+ this._cache.set(key, { value, expiresAt: this._now() + this._ttlMs });
72
+ this._evict();
73
+ return value;
74
+ } finally {
75
+ this._inflight.delete(key); // errors are never cached; slot always released
76
+ }
77
+ })();
78
+ this._inflight.set(key, p);
79
+ return p;
80
+ }
81
+
82
+ /** Drop expired cache entries — values can be large (window objects), so
83
+ * they must not outlive their TTL just because no one asked again. */
84
+ _sweep() {
85
+ const t = this._now();
86
+ for (const [key, e] of this._cache) {
87
+ if (t >= e.expiresAt) this._cache.delete(key);
88
+ }
89
+ }
90
+
91
+ /** Enforce the size cap: Map preserves insertion order, so the first keys are
92
+ * the oldest — evict them until at/under MAX_CACHE_ENTRIES. */
93
+ _evict() {
94
+ while (this._cache.size > MAX_CACHE_ENTRIES) {
95
+ const oldest = this._cache.keys().next().value;
96
+ this._cache.delete(oldest);
97
+ }
98
+ }
99
+
100
+ /** Test hook: current cache/in-flight sizes. */
101
+ _statsForTest() {
102
+ return { cacheSize: this._cache.size, inflightSize: this._inflight.size };
103
+ }
104
+ }