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,558 @@
1
+ // Wire Format v2 — live feed (S6b): streams v2 session-dir appends to the SSE
2
+ // clients, replacing the v1 log-file tail as the live channel.
3
+ //
4
+ // Architecture (plan 1.2):
5
+ // - One incremental cursor per live session dir, built on SessionSynthesizer —
6
+ // the SAME synthesis engine cold reads use, so live emission is
7
+ // byte-identical to a later cold read by construction.
8
+ // - Two-level watcher topology: a watcher on `sessions/` sees new session dirs
9
+ // appear (cross-process producers: teammates, IM workers); a per-session-dir
10
+ // watcher sees journal.jsonl / responses.jsonl appends (direct children).
11
+ // Conversation event files live two levels deeper where non-recursive
12
+ // fs.watch cannot see them — but the writer appends the conv line BEFORE the
13
+ // journal line, so the journal append is the tick; the synthesizer's
14
+ // defer/retry covers the rare flush inversion, and a safety poll covers
15
+ // missed events entirely (fs.watch is lossy under load).
16
+ // - The leader process's own writer nudges the feed in-process (`tick()`)
17
+ // for zero-latency delivery — the nudge only schedules a cursor read; the
18
+ // DATA always comes from the files, keeping one synthesis path.
19
+ //
20
+ // Emission: every synthesized item is round-tripped through JSON (protecting
21
+ // the synthesizer's internal objects from downstream mutation) and pushed
22
+ // through log-watcher's processWatchedEntry pipeline — the same enrichment,
23
+ // reconstruction and side-events the v1 tail produced.
24
+
25
+ import { watch, existsSync, readdirSync, statSync, openSync, readSync, closeSync } from 'node:fs';
26
+ import { join } from 'node:path';
27
+ import { reportSwallowed } from '../error-report.js';
28
+ import { createIncrementalReconstructor } from '../delta-reconstructor.js';
29
+ import { processWatchedEntry, sendEventToClients, sendEventRawToClients } from '../log-watcher.js';
30
+ import { SessionSynthesizer } from './adapter.js';
31
+ import { computeCacheLoss } from './meta-rows.js';
32
+ import { classifyRequest } from '../../../src/utils/requestType.js';
33
+
34
+ const FSWATCH_DEBOUNCE_MS = 80;
35
+ const SAFETY_POLL_MS = 5000;
36
+ const DEFER_MS = 3000; // synthesizer park deadline (lagging lines)
37
+ const ATTACH_RECENT_MS = 5 * 60 * 1000; // pre-existing dirs considered "live"
38
+ const TICK_RETRY_MS = 250; // in-process tick raced the first queue drain
39
+ const TICK_RETRY_MAX = 8;
40
+
41
+ /** Read newly appended complete lines from a file cursor {path, offset,
42
+ * pending}. Returns an array of raw line strings (possibly empty). A file
43
+ * that shrank (should never happen — v2 files are append-only) returns null
44
+ * so the caller can rebuild the cursor. */
45
+ function readNewLines(cursor) {
46
+ let size;
47
+ try {
48
+ size = statSync(cursor.path).size;
49
+ } catch {
50
+ return []; // not created yet
51
+ }
52
+ if (size < cursor.offset) return null;
53
+ if (size === cursor.offset) return [];
54
+ const toRead = size - cursor.offset;
55
+ const buf = Buffer.alloc(toRead);
56
+ let fd;
57
+ try {
58
+ fd = openSync(cursor.path, 'r');
59
+ readSync(fd, buf, 0, toRead, cursor.offset);
60
+ } catch (err) {
61
+ reportSwallowed('v2-live.read', err);
62
+ return [];
63
+ } finally {
64
+ if (fd !== undefined) { try { closeSync(fd); } catch { /* already closed */ } }
65
+ }
66
+ cursor.offset = size;
67
+ const chunk = cursor.pending + buf.toString('utf-8');
68
+ const parts = chunk.split('\n');
69
+ cursor.pending = parts.pop() || '';
70
+ return parts.map((l) => l.trim()).filter(Boolean);
71
+ }
72
+
73
+ export class V2LiveFeed {
74
+ /**
75
+ * @param {object} opts
76
+ * @param {Array} opts.clients - shared SSE client array (server.js)
77
+ * @param {Function} opts.getClaudePid
78
+ * @param {Function} opts.runParallelHook
79
+ * @param {Function} [opts.notifyStatsWorker] - called with the session dir
80
+ * after a batch of entries was emitted from it
81
+ * @param {Function} [opts.watchImpl] - fs.watch injection (tests)
82
+ * @param {number} [opts.safetyPollMs] - 0 disables the timer (tests drive
83
+ * _safetyTick manually)
84
+ * @param {number} [opts.deferMs]
85
+ * @param {Function} [opts.now]
86
+ */
87
+ constructor(opts = {}) {
88
+ this._clients = opts.clients || [];
89
+ this._getClaudePid = opts.getClaudePid || (() => null);
90
+ this._runParallelHook = opts.runParallelHook || (() => Promise.resolve());
91
+ this._notifyStatsWorker = opts.notifyStatsWorker || null;
92
+ this._watchImpl = opts.watchImpl || watch;
93
+ this._safetyPollMs = typeof opts.safetyPollMs === 'number' ? opts.safetyPollMs : SAFETY_POLL_MS;
94
+ this._deferMs = typeof opts.deferMs === 'number' ? opts.deferMs : DEFER_MS;
95
+ this._now = opts.now || Date.now;
96
+ // Wire v3 (V3.S2): when on, every emitted item ALSO broadcasts a metadata
97
+ // row (v2_requests_delta). Explicit ctor param — this module has no access
98
+ // to the server deps object.
99
+ this._wireV3 = !!opts.wireV3;
100
+ this._sessions = new Map(); // dir → cursor bundle
101
+ this._seenDirs = new Map(); // dir → last observed journal mtimeMs (attached or not)
102
+ this._sessionsRoot = null;
103
+ this._rootWatcher = null;
104
+ this._safetyTimer = null;
105
+ this._startedAt = 0;
106
+ this._active = false;
107
+ }
108
+
109
+ /** Begin (or switch to) live-following a project's sessions/ root. */
110
+ start(projectDir) {
111
+ this.stop();
112
+ this._active = true;
113
+ this._startedAt = this._now();
114
+ this._sessionsRoot = join(projectDir, 'sessions');
115
+ this._armRootWatcher();
116
+ this._initialScan();
117
+ if (this._safetyPollMs > 0) {
118
+ this._safetyTimer = setInterval(() => this._safetyTick(), this._safetyPollMs);
119
+ }
120
+ }
121
+
122
+ stop() {
123
+ this._active = false;
124
+ if (this._safetyTimer) { clearInterval(this._safetyTimer); this._safetyTimer = null; }
125
+ if (this._rootWatcher) { try { this._rootWatcher.close(); } catch { /* already closed */ } this._rootWatcher = null; }
126
+ for (const cur of this._sessions.values()) this._closeCursor(cur);
127
+ this._sessions.clear();
128
+ this._seenDirs.clear();
129
+ this._sessionsRoot = null;
130
+ }
131
+
132
+ /** In-process nudge from the local V2Writer after an ingest: attach the
133
+ * session on first sight and schedule a cursor read. Zero-latency path for
134
+ * the leader's own traffic. The write queue drains asynchronously, so the
135
+ * very first tick can race the journal's creation — retry briefly (the
136
+ * per-dir watcher and safety poll are the durable fallbacks). */
137
+ tick(sessionDir, _retries = 0) {
138
+ if (!this._active || !sessionDir) return;
139
+ let cur = this._sessions.get(sessionDir);
140
+ if (!cur) {
141
+ cur = this._attach(sessionDir, { suppressExisting: false });
142
+ if (!cur) {
143
+ if (_retries < TICK_RETRY_MAX) {
144
+ setTimeout(() => this.tick(sessionDir, _retries + 1), TICK_RETRY_MS);
145
+ }
146
+ return;
147
+ }
148
+ }
149
+ this._scheduleRead(cur);
150
+ }
151
+
152
+ /** True when a session dir is being followed (tests / diagnostics). */
153
+ isFollowing(sessionDir) {
154
+ return this._sessions.has(sessionDir);
155
+ }
156
+
157
+ // ── watcher topology ───────────────────────────────────────────────────────
158
+
159
+ _armRootWatcher() {
160
+ if (this._rootWatcher || !this._sessionsRoot) return;
161
+ if (!existsSync(this._sessionsRoot)) return; // re-tried by the safety tick
162
+ try {
163
+ const watcher = this._watchImpl(this._sessionsRoot, (eventType, filename) => {
164
+ // A new (or renamed) direct child is potentially a fresh session dir.
165
+ if (!this._active) return;
166
+ if (filename) {
167
+ const dir = join(this._sessionsRoot, filename);
168
+ this._maybeAttachNew(dir);
169
+ } else {
170
+ this._scanRoot();
171
+ }
172
+ });
173
+ watcher.on('error', (err) => {
174
+ reportSwallowed('v2-live.root-watch', err);
175
+ try { watcher.close(); } catch { /* already closed */ }
176
+ if (this._rootWatcher === watcher) this._rootWatcher = null; // safety poll re-arms
177
+ });
178
+ this._rootWatcher = watcher;
179
+ } catch (err) {
180
+ reportSwallowed('v2-live.root-watch', err);
181
+ }
182
+ }
183
+
184
+ _initialScan() {
185
+ if (!existsSync(this._sessionsRoot)) return;
186
+ let names = [];
187
+ try {
188
+ names = readdirSync(this._sessionsRoot, { withFileTypes: true }).filter((e) => e.isDirectory()).map((e) => e.name);
189
+ } catch (err) {
190
+ reportSwallowed('v2-live.scan', err);
191
+ return;
192
+ }
193
+ for (const name of names) {
194
+ const dir = join(this._sessionsRoot, name);
195
+ const mtime = this._journalMtime(dir);
196
+ this._seenDirs.set(dir, mtime);
197
+ // Pre-existing dirs with recent activity are live producers that predate
198
+ // this feed (mid-run server start / workspace switch): follow them but
199
+ // suppress their history — the cold reload channel covers it.
200
+ if (mtime > 0 && this._now() - mtime <= ATTACH_RECENT_MS) {
201
+ this._attach(dir, { suppressExisting: true });
202
+ }
203
+ }
204
+ }
205
+
206
+ _scanRoot() {
207
+ if (!existsSync(this._sessionsRoot)) return;
208
+ let names = [];
209
+ try {
210
+ names = readdirSync(this._sessionsRoot, { withFileTypes: true }).filter((e) => e.isDirectory()).map((e) => e.name);
211
+ } catch {
212
+ return;
213
+ }
214
+ for (const name of names) this._maybeAttachNew(join(this._sessionsRoot, name));
215
+ }
216
+
217
+ /** Attach a dir first seen after feed start (root watcher / safety scan). */
218
+ _maybeAttachNew(dir) {
219
+ if (this._sessions.has(dir)) return;
220
+ const mtime = this._journalMtime(dir);
221
+ if (mtime === 0) return; // no journal yet — the next event/tick retries
222
+ const seenBefore = this._seenDirs.has(dir);
223
+ this._seenDirs.set(dir, mtime);
224
+ if (!seenBefore) {
225
+ // Genuinely new session dir: everything in it is new — emit from byte 0
226
+ // when it was born after the feed started, otherwise treat as history.
227
+ // birthtime ≤ 0 (Linux filesystems without btime) must count as new:
228
+ // suppressing would swallow a cross-process session's first entries
229
+ // (review P2).
230
+ const bornAfterStart = (() => {
231
+ try {
232
+ const bt = statSync(dir).birthtimeMs;
233
+ return !(bt > 0) || bt >= this._startedAt - 1000;
234
+ } catch { return true; }
235
+ })();
236
+ this._attach(dir, { suppressExisting: !bornAfterStart });
237
+ }
238
+ }
239
+
240
+ _journalMtime(dir) {
241
+ try { return statSync(join(dir, 'journal.jsonl')).mtimeMs; } catch { return 0; }
242
+ }
243
+
244
+ // ── per-session cursors ────────────────────────────────────────────────────
245
+
246
+ _attach(dir, { suppressExisting }) {
247
+ if (this._sessions.has(dir)) return this._sessions.get(dir);
248
+ if (!existsSync(join(dir, 'journal.jsonl'))) return null;
249
+ const cur = {
250
+ dir,
251
+ synth: new SessionSynthesizer(dir, { deferMs: this._deferMs, now: this._now }),
252
+ // Per-session reconstructor (review P2): two concurrently-followed main
253
+ // producers in one project (a second ccv process, IM worker) must not
254
+ // rebase each other's accumulated baseline — v1's tail followed exactly
255
+ // one file, so a single shared reconstructor was a new interleave surface.
256
+ reconstructor: createIncrementalReconstructor(),
257
+ journal: { path: join(dir, 'journal.jsonl'), offset: 0, pending: '' },
258
+ responses: { path: join(dir, 'responses.jsonl'), offset: 0, pending: '' },
259
+ convFiles: new Map(), // path → {key, offset, pending}
260
+ watcher: null,
261
+ debounce: null,
262
+ reading: false,
263
+ dirty: false,
264
+ suppress: !!suppressExisting,
265
+ };
266
+ this._sessions.set(dir, cur);
267
+ this._seenDirs.set(dir, this._journalMtime(dir));
268
+ try {
269
+ cur.watcher = this._watchImpl(dir, () => this._scheduleRead(cur));
270
+ cur.watcher.on('error', (err) => {
271
+ reportSwallowed('v2-live.session-watch', err);
272
+ try { cur.watcher.close(); } catch { /* already closed */ }
273
+ cur.watcher = null; // safety poll keeps the cursor alive
274
+ });
275
+ } catch (err) {
276
+ reportSwallowed('v2-live.session-watch', err);
277
+ }
278
+ this._readCursor(cur); // seed (suppressed history or brand-new content)
279
+ cur.suppress = false;
280
+ return cur;
281
+ }
282
+
283
+ _closeCursor(cur) {
284
+ if (cur.debounce) { clearTimeout(cur.debounce); cur.debounce = null; }
285
+ if (cur.watcher) { try { cur.watcher.close(); } catch { /* already closed */ } cur.watcher = null; }
286
+ }
287
+
288
+ _scheduleRead(cur) {
289
+ if (!this._active) return;
290
+ if (cur.debounce) return;
291
+ cur.debounce = setTimeout(() => {
292
+ cur.debounce = null;
293
+ this._readCursor(cur);
294
+ }, FSWATCH_DEBOUNCE_MS);
295
+ }
296
+
297
+ /** Read all new bytes of one session (conv events → responses → journal —
298
+ * the order that maximizes gate success), feed the synthesizer, emit. */
299
+ _readCursor(cur) {
300
+ if (cur.reading) { cur.dirty = true; return; }
301
+ cur.reading = true;
302
+ try {
303
+ do {
304
+ cur.dirty = false;
305
+ this._feedConvFiles(cur);
306
+ const respLines = readNewLines(cur.responses);
307
+ if (respLines === null) { this._rebuildCursor(cur); return; }
308
+ for (const raw of respLines) {
309
+ cur.synth.ingestResponseLine(raw);
310
+ // Wire v3 (V3.S4): forward the raw stored line — the flagged client
311
+ // assembles v1-shape entries from native lines locally. Suppressed
312
+ // during seed (history is covered by the cold-load channel).
313
+ if (this._wireV3 && !cur.suppress) this._forwardNative(cur, 'resp', null, raw);
314
+ }
315
+ const journalLines = readNewLines(cur.journal);
316
+ if (journalLines === null) { this._rebuildCursor(cur); return; }
317
+ for (const raw of journalLines) {
318
+ let line = null;
319
+ try { line = JSON.parse(raw); } catch (err) {
320
+ reportSwallowed('v2-live.journal-parse', new Error(`${cur.dir}: ${err.message}`));
321
+ }
322
+ if (line) cur.synth.ingestJournalLine(line);
323
+ }
324
+ this._emitDrained(cur);
325
+ } while (cur.dirty);
326
+ } finally {
327
+ cur.reading = false;
328
+ }
329
+ }
330
+
331
+ _feedConvFiles(cur) {
332
+ const convRoot = join(cur.dir, 'conversations');
333
+ if (!existsSync(convRoot)) return;
334
+ let keys = [];
335
+ try {
336
+ keys = readdirSync(convRoot, { withFileTypes: true }).filter((e) => e.isDirectory()).map((e) => e.name);
337
+ } catch {
338
+ return;
339
+ }
340
+ for (const key of keys) {
341
+ let files = [];
342
+ try {
343
+ files = readdirSync(join(convRoot, key)).filter((f) => /^e\d+\.jsonl$/.test(f));
344
+ } catch {
345
+ continue;
346
+ }
347
+ // Numeric epoch order. NB: file order is NOT guaranteed to be globally
348
+ // seq-ordered (a pre-fix writer restart appended newer seqs into an
349
+ // older epoch file) — the synthesizer's ingestConvLine keeps its event
350
+ // window seq-sorted on insert, so feed order only needs to be stable.
351
+ files.sort((a, b) => Number(a.match(/\d+/)[0]) - Number(b.match(/\d+/)[0]));
352
+ for (const f of files) {
353
+ const p = join(convRoot, key, f);
354
+ let fc = cur.convFiles.get(p);
355
+ if (!fc) {
356
+ fc = { key, path: p, offset: 0, pending: '' };
357
+ cur.convFiles.set(p, fc);
358
+ }
359
+ const lines = readNewLines(fc);
360
+ if (lines === null) { this._rebuildCursor(cur); return; }
361
+ for (const raw of lines) {
362
+ let ev = null;
363
+ try { ev = JSON.parse(raw); } catch (err) {
364
+ reportSwallowed('v2-live.conv-parse', new Error(`${cur.dir}/${key}: ${err.message}`));
365
+ }
366
+ if (ev) {
367
+ cur.synth.ingestConvLine(key, ev);
368
+ if (this._wireV3 && !cur.suppress) this._forwardNative(cur, 'conv', key, raw);
369
+ }
370
+ }
371
+ }
372
+ }
373
+ }
374
+
375
+ /** Append-only invariant broke (shrunk file): rebuild the whole cursor with
376
+ * a fresh synthesizer, replaying silently to the current state. */
377
+ _rebuildCursor(cur) {
378
+ reportSwallowed('v2-live.cursor-rebuild', new Error(`${cur.dir}: file shrank — rebuilding cursor`));
379
+ this._closeCursor(cur);
380
+ this._sessions.delete(cur.dir);
381
+ const fresh = this._attach(cur.dir, { suppressExisting: true });
382
+ if (fresh) this._scheduleRead(fresh);
383
+ }
384
+
385
+ _emitDrained(cur) {
386
+ cur.synth.sweepDeadlines();
387
+ const items = cur.synth.drain();
388
+ if (items.length === 0) return;
389
+ if (cur.suppress) {
390
+ // Seed phase: history is already covered by the cold-load channel, so
391
+ // nothing is broadcast — but the shared reconstructor still consumes it,
392
+ // giving later deltas a baseline (v1's tail restarted with no baseline
393
+ // and passed bare slices through; the cold reload had to cover them).
394
+ for (const item of items) {
395
+ try {
396
+ cur.reconstructor.reconstruct(JSON.parse(JSON.stringify(item.entry)));
397
+ } catch (err) {
398
+ reportSwallowed('v2-live.seed', err);
399
+ }
400
+ }
401
+ return;
402
+ }
403
+ let emitted = 0;
404
+ for (const item of items) {
405
+ let parsed = null;
406
+ try {
407
+ // JSON round-trip: downstream (reconstructor/enrichers) mutate entries
408
+ // in place — the synthesizer's own objects must stay pristine so the
409
+ // placeholder→completed mutation stays byte-true.
410
+ parsed = JSON.parse(JSON.stringify(item.entry));
411
+ } catch (err) {
412
+ reportSwallowed('v2-live.emit', err);
413
+ continue;
414
+ }
415
+ try {
416
+ processWatchedEntry(parsed, {
417
+ reconstructor: cur.reconstructor,
418
+ clients: this._clients,
419
+ getClaudePid: this._getClaudePid,
420
+ runParallelHook: this._runParallelHook,
421
+ suppressEntryBroadcast: this._wireV3,
422
+ });
423
+ emitted++;
424
+ } catch (err) {
425
+ reportSwallowed('v2-live.emit', err);
426
+ }
427
+ // Wire v3: derive the metadata row from the post-reconstruction entry
428
+ // (main deltas carry full messages here — classification/cacheLoss get
429
+ // the same inputs the legacy list sees). Row-level errors must not
430
+ // disturb the legacy broadcast above.
431
+ if (this._wireV3) {
432
+ try {
433
+ this._emitRow(cur, item, parsed);
434
+ } catch (err) {
435
+ reportSwallowed('v2-live.row', err);
436
+ }
437
+ }
438
+ }
439
+ if (emitted > 0 && this._notifyStatsWorker) {
440
+ try { this._notifyStatsWorker(cur.dir); } catch (err) { reportSwallowed('v2-live.stats-notify', err); }
441
+ }
442
+ }
443
+
444
+ /** Wire v3 (V3.S4): broadcast one raw native line (conv event or responses
445
+ * line) — the flagged client's assembler replays these into v1-shape
446
+ * entries. `raw` is forwarded verbatim inside a JSON envelope carrying the
447
+ * session identity + channel. sessionId comes from the synthesizer (the
448
+ * UUID, matching the rows' sessionId and the entries' _seqEpoch). */
449
+ _forwardNative(cur, kind, channel, raw) {
450
+ try {
451
+ const payload = kind === 'conv'
452
+ ? `{"sessionId":${JSON.stringify(cur.synth.sessionId)},"channel":${JSON.stringify(channel)},"line":${raw}}`
453
+ : `{"sessionId":${JSON.stringify(cur.synth.sessionId)},"line":${raw}}`;
454
+ const eventName = kind === 'conv' ? 'v3_conv' : 'v3_resp';
455
+ sendEventRawToClients(this._clients, eventName, payload);
456
+ } catch (err) {
457
+ reportSwallowed('v2-live.native-forward', err);
458
+ }
459
+ }
460
+
461
+ /** Wire v3 (V3.S2): one metadata row per emitted item, derived from the
462
+ * post-reconstruction entry. Rows upsert client-side by (sessionId, seq):
463
+ * a placeholder row precedes its completed row, and a correction re-send
464
+ * follows when the NEXT request's arrival changes the previous row's
465
+ * classification (classifyRequest's Preflight/Plan cases read nextReq). */
466
+ _rowFrom(item, parsed) {
467
+ const usage = parsed.response?.body?.usage || null;
468
+ return {
469
+ seq: item.seq,
470
+ sessionId: item.sessionId,
471
+ timestamp: parsed.timestamp || '',
472
+ url: parsed.url || '',
473
+ method: parsed.method || 'POST',
474
+ kind: item.isMain ? 'main' : (parsed.teammate ? 'teammate' : 'sub'),
475
+ mainAgent: parsed.mainAgent === true,
476
+ teammate: parsed.teammate || undefined,
477
+ model: parsed.body?.model,
478
+ proxyUrl: parsed.proxyUrl || undefined,
479
+ status: parsed.response?.status,
480
+ duration: parsed.duration,
481
+ usage: usage ? {
482
+ input_tokens: usage.input_tokens || 0,
483
+ output_tokens: usage.output_tokens || 0,
484
+ cache_read_input_tokens: usage.cache_read_input_tokens || 0,
485
+ cache_creation_input_tokens: usage.cache_creation_input_tokens || 0,
486
+ } : null,
487
+ inProgress: !!parsed.inProgress,
488
+ typeTag: null,
489
+ cacheLoss: null,
490
+ };
491
+ }
492
+
493
+ _emitRow(cur, item, parsed) {
494
+ const row = this._rowFrom(item, parsed);
495
+ try {
496
+ const tag = classifyRequest(parsed, null);
497
+ row.typeTag = tag ? { type: tag.type, subType: tag.subType ?? null } : null;
498
+ } catch (err) { reportSwallowed('v2-live.row-classify', err); }
499
+ if (row.mainAgent && !row.inProgress) {
500
+ const u = parsed.response?.body?.usage;
501
+ const cw = (u && u.cache_creation_input_tokens) || 0;
502
+ const cr = (u && u.cache_read_input_tokens) || 0;
503
+ if (cur._v3PrevMain && cw > 0 && cw > cr) {
504
+ try { row.cacheLoss = computeCacheLoss(cur._v3PrevMain, parsed); } catch (err) { reportSwallowed('v2-live.row-cacheloss', err); }
505
+ }
506
+ cur._v3PrevMain = parsed; // one full entry retained per cursor (bounded)
507
+ }
508
+ // Lookahead correction: this entry is the previous row's nextReq.
509
+ const prev = cur._v3Last;
510
+ if (prev && (prev.row.sessionId !== row.sessionId || prev.row.seq !== row.seq)) {
511
+ try {
512
+ const tag = classifyRequest(prev.entry, parsed);
513
+ const corrected = tag ? { type: tag.type, subType: tag.subType ?? null } : null;
514
+ if (JSON.stringify(corrected) !== JSON.stringify(prev.row.typeTag)) {
515
+ prev.row.typeTag = corrected;
516
+ sendEventToClients(this._clients, 'v2_requests_delta', prev.row);
517
+ }
518
+ } catch (err) { reportSwallowed('v2-live.row-classify', err); }
519
+ }
520
+ cur._v3Last = { row, entry: parsed };
521
+ sendEventToClients(this._clients, 'v2_requests_delta', row);
522
+ }
523
+
524
+ // ── safety poll ────────────────────────────────────────────────────────────
525
+
526
+ _safetyTick() {
527
+ if (!this._active) return;
528
+ this._armRootWatcher(); // re-arm after errors / late directory creation
529
+ // Attached sessions: unconditional slow re-read (fs.watch is lossy).
530
+ for (const cur of [...this._sessions.values()]) {
531
+ this._readCursor(cur);
532
+ }
533
+ // Unattached dirs: attach brand-new ones (missed root events) and
534
+ // re-attach previously idle ones whose journal moved again.
535
+ if (!existsSync(this._sessionsRoot)) return;
536
+ let names = [];
537
+ try {
538
+ names = readdirSync(this._sessionsRoot, { withFileTypes: true }).filter((e) => e.isDirectory()).map((e) => e.name);
539
+ } catch {
540
+ return;
541
+ }
542
+ for (const name of names) {
543
+ const dir = join(this._sessionsRoot, name);
544
+ if (this._sessions.has(dir)) continue;
545
+ const mtime = this._journalMtime(dir);
546
+ if (mtime === 0) continue;
547
+ const seen = this._seenDirs.get(dir);
548
+ if (seen === undefined) {
549
+ this._maybeAttachNew(dir);
550
+ } else if (mtime > seen) {
551
+ // A dir skipped at the initial scan (stale then) has resumed activity:
552
+ // follow it now, history suppressed (it predates this feed).
553
+ this._seenDirs.set(dir, mtime);
554
+ this._attach(dir, { suppressExisting: true });
555
+ }
556
+ }
557
+ }
558
+ }