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
@@ -1,18 +1,47 @@
1
1
  // Local log management routes (moved verbatim from server.js handleRequest).
2
- import { existsSync, realpathSync, statSync, createReadStream, openSync, readSync, closeSync } from 'node:fs';
2
+ import { existsSync, realpathSync, statSync, createReadStream, mkdtempSync, rmSync } from 'node:fs';
3
3
  import { join, basename } from 'node:path';
4
+ import { tmpdir } from 'node:os';
5
+ import AdmZip from 'adm-zip';
4
6
  import { LOG_DIR } from '../../findcc.js';
5
- import { _projectName, LOG_FILE, getAgentSpawnRegistrySnapshot } from '../interceptor.js';
6
- import { listLocalLogs, deleteLogFiles, mergeLogFiles, archiveLogFiles, validateLogPath, findPreviousSegment } from '../lib/log-management.js';
7
- import { countLogEntries, streamRawEntriesAsync, readTailEntries, collectFilteredRawEntriesAsync } from '../lib/log-stream.js';
8
- import { isTeammateLikeEntry } from '../lib/teammate-detect.js';
7
+ import { _projectName, _v2Writer } from '../interceptor.js';
8
+ import { listV2Logs, listLocalLogs, countListedV1Files, deleteLogFiles, validateLogPath } from '../lib/log-management.js';
9
+ import { countLogEntries, streamRawEntriesAsync, readTailEntries } from '../lib/log-stream.js';
10
+ import { sseHead, sseWrite, wireEnd } from '../lib/wire-compress.js';
11
+ import { dirSizeSync } from '../lib/v2/layout.js';
12
+ import { extractV2Zip } from '../lib/log-zip.js';
13
+ import { startConvert, stopConvert, convertStatus } from '../lib/v2/convert-manager.js';
14
+ import { migrationStatus } from '../lib/v2/migrate-prompt.js';
15
+
16
+ // Cap for the in-memory zip build (adm-zip toBuffer holds the whole archive in
17
+ // heap) and the upload accumulation — the repo has an OOM history, so both the
18
+ // zipped source folder and the uploaded archive are bounded before buffering.
19
+ const MAX_UPLOAD = 300 * 1024 * 1024;
20
+ // Download-zip source cap; read per-request so CCV_MAX_ZIP_SOURCE is an ops
21
+ // tunable (and a test seam) without a restart.
22
+ const maxZipSource = () => Number(process.env.CCV_MAX_ZIP_SOURCE) || 300 * 1024 * 1024;
9
23
 
10
24
  async function localLogs(req, res, parsedUrl, isLocal, deps) {
11
25
  try {
12
- // 按当前实例 pid 硬隔离日志列表;`?all=1` 越过过滤看全部实例(顶部「显示全部」开关)。
13
- const instanceId = (deps && deps.instanceId) || null;
14
- const showAll = parsedUrl?.searchParams?.get('all') === '1';
15
- const result = await listLocalLogs(LOG_DIR, _projectName, { instanceId, showAll });
26
+ // 1.7.0: the DEFAULT list is v2 sessions, unconditionally. `?view=v1`
27
+ // serves the legacy-file list for the modal's v1 view (list + migrate +
28
+ // open + delete for leftovers the converter never removes).
29
+ if (parsedUrl?.searchParams?.get('view') === 'v1') {
30
+ const v1 = await listLocalLogs(LOG_DIR, _projectName);
31
+ res.writeHead(200, { 'Content-Type': 'application/json' });
32
+ res.end(JSON.stringify(v1));
33
+ return;
34
+ }
35
+ const result = listV2Logs(LOG_DIR, _projectName);
36
+ // Legacy v1 files are not in this list — surface two distinct signals:
37
+ // - _v1FileCount: v1 files ON DISK (gates the v1-view entry link; the
38
+ // converter never deletes sources, so this outlives a finished migration)
39
+ // - _unmigratedV1Count/Bytes: files still AWAITING migration (gates the
40
+ // migrate button + hint inside the v1 view and the startup prompt)
41
+ const mig = migrationStatus(LOG_DIR, _projectName || '');
42
+ result._unmigratedV1Count = mig.files;
43
+ result._unmigratedV1Bytes = mig.totalBytes;
44
+ result._v1FileCount = _projectName ? countListedV1Files(join(LOG_DIR, _projectName)) : 0;
16
45
  res.writeHead(200, { 'Content-Type': 'application/json' });
17
46
  res.end(JSON.stringify(result));
18
47
  } catch (err) {
@@ -28,7 +57,69 @@ async function downloadLog(req, res, parsedUrl) {
28
57
  res.end(JSON.stringify({ error: 'Invalid file name' }));
29
58
  return;
30
59
  }
31
- if (!file.endsWith('.jsonl') && !file.endsWith('.jsonl.zip')) {
60
+ // wire-v2 S5: rebuilt download for v2 sessions — the adapter stream as a v1
61
+ // `.jsonl`. `format=raw` (session-dir zip) is the S6a half of the S0 contract:
62
+ // a lossless zip of the whole session folder, the transport for round-tripping
63
+ // a v2 session (folder) back through the upload-and-parse flow.
64
+ if (file.startsWith('v2:')) {
65
+ if (parsedUrl.searchParams.get('format') === 'raw') {
66
+ try {
67
+ const sessionDir = validateLogPath(LOG_DIR, file);
68
+ if (dirSizeSync(sessionDir) > maxZipSource()) {
69
+ res.writeHead(413, { 'Content-Type': 'application/json' });
70
+ res.end(JSON.stringify({ error: 'Session too large to zip', code: 'TOO_LARGE' }));
71
+ return;
72
+ }
73
+ const dirName = basename(sessionDir);
74
+ const zip = new AdmZip();
75
+ // Wrap the folder under `<dirName>/` and skip dotfiles (.DS_Store etc.).
76
+ // adm-zip passes the zip-internal path (incl. the `<dirName>/` root) to
77
+ // the filter, so match on any path segment starting with a dot.
78
+ zip.addLocalFolder(sessionDir, dirName, (n) => !/(^|\/)\./.test(n.replace(/\\/g, '/')));
79
+ const buf = zip.toBuffer();
80
+ res.writeHead(200, {
81
+ 'Content-Type': 'application/zip',
82
+ 'Content-Disposition': `attachment; filename="${encodeURIComponent(`${dirName}.zip`)}"`,
83
+ 'Content-Length': buf.length,
84
+ });
85
+ res.end(buf);
86
+ } catch (err) {
87
+ if (!res.headersSent) {
88
+ const status = err.code === 'NOT_FOUND' ? 404 : err.code === 'ACCESS_DENIED' ? 403 : 500;
89
+ // Don't leak internal messages (may contain absolute paths) on 500 —
90
+ // genericize like the upload handler does.
91
+ res.writeHead(status, { 'Content-Type': 'application/json' });
92
+ res.end(JSON.stringify({ error: status >= 500 ? 'server_error' : err.message }));
93
+ } else {
94
+ res.end();
95
+ }
96
+ }
97
+ return;
98
+ }
99
+ try {
100
+ const sessionDir = validateLogPath(LOG_DIR, file);
101
+ res.writeHead(200, {
102
+ 'Content-Type': 'application/octet-stream',
103
+ 'Content-Disposition': `attachment; filename="${encodeURIComponent(`${basename(sessionDir)}.v2-rebuilt.jsonl`)}"`,
104
+ 'Transfer-Encoding': 'chunked',
105
+ });
106
+ await streamRawEntriesAsync(sessionDir, (raw) => {
107
+ res.write(raw);
108
+ res.write('\n---\n');
109
+ });
110
+ res.end();
111
+ } catch (err) {
112
+ if (!res.headersSent) {
113
+ const status = err.code === 'NOT_FOUND' ? 404 : err.code === 'ACCESS_DENIED' ? 403 : 500;
114
+ res.writeHead(status, { 'Content-Type': 'application/json' });
115
+ res.end(JSON.stringify({ error: err.message }));
116
+ } else {
117
+ res.end();
118
+ }
119
+ }
120
+ return;
121
+ }
122
+ if (!file.endsWith('.jsonl')) {
32
123
  res.writeHead(400, { 'Content-Type': 'application/json' });
33
124
  res.end(JSON.stringify({ error: 'Invalid file type' }));
34
125
  return;
@@ -48,10 +139,7 @@ async function downloadLog(req, res, parsedUrl) {
48
139
  return;
49
140
  }
50
141
  const format = parsedUrl.searchParams.get('format');
51
- // 默认下载重建后的 .jsonl;raw 下载原始字节(.zip 或 .jsonl 视实际而定)
52
- const fileName = (format !== 'raw' && file.endsWith('.jsonl.zip'))
53
- ? basename(file).slice(0, -4)
54
- : basename(file);
142
+ const fileName = basename(file);
55
143
  // Delta storage: format=raw 下载原始文件;默认下载重建后的全量格式
56
144
  if (format === 'raw') {
57
145
  const stat = statSync(realPath);
@@ -89,20 +177,24 @@ async function localLog(req, res, parsedUrl) {
89
177
  return;
90
178
  }
91
179
 
92
- // 验证文件类型:允许 .jsonl .jsonl.zip
93
- if (!file.endsWith('.jsonl') && !file.endsWith('.jsonl.zip')) {
180
+ // 验证文件类型:v2 寻址(spec §12);直连 .jsonl 保留为未迁移日志的逃生舱
181
+ // (代码与转换器共享,UI 无入口——1.7.0 决策)。
182
+ const isV2Ref = file.startsWith('v2:');
183
+ if (!isV2Ref && !file.endsWith('.jsonl')) {
94
184
  res.writeHead(400, { 'Content-Type': 'application/json' });
95
- res.end(JSON.stringify({ error: 'Invalid file type. Only .jsonl(.zip) files are allowed.' }));
185
+ res.end(JSON.stringify({ error: 'Invalid file type. Only .jsonl files are allowed.' }));
96
186
  return;
97
187
  }
98
188
 
99
189
  try {
100
190
  // 独立 SSE 流:直接向请求方返回 event-stream,不走 /events 广播
101
- validateLogPath(LOG_DIR, file);
102
- const filePath = join(LOG_DIR, file);
191
+ // v2 寻址时 validateLogPath 返回 session 目录绝对路径,log-stream 据此分派适配器
192
+ const filePath = validateLogPath(LOG_DIR, file);
103
193
  const limitVal = Math.min(parseInt(parsedUrl.searchParams.get('limit'), 10) || 0, 500);
104
194
 
105
- res.writeHead(200, {
195
+ // Negotiated Content-Encoding (br|identity) — every subsequent byte MUST
196
+ // go through sseWrite/wireEnd (see server/lib/wire-compress.js).
197
+ sseHead(req, res, 200, {
106
198
  'Content-Type': 'text/event-stream',
107
199
  'Cache-Control': 'no-cache',
108
200
  'Connection': 'keep-alive',
@@ -111,27 +203,27 @@ async function localLog(req, res, parsedUrl) {
111
203
  if (limitVal > 0) {
112
204
  // 尾部加载模式:跳过 countLogEntries,只读文件末尾
113
205
  const { entries, hasMore, oldestTimestamp, estimatedTotal } = await readTailEntries(filePath, { limit: limitVal });
114
- res.write(`event: load_start\ndata: ${JSON.stringify({ total: estimatedTotal, incremental: false, hasMore, oldestTs: oldestTimestamp })}\n\n`);
206
+ sseWrite(res, `event: load_start\ndata: ${JSON.stringify({ total: estimatedTotal, incremental: false, hasMore, oldestTs: oldestTimestamp })}\n\n`);
115
207
  for (const raw of entries) {
116
- res.write('event: load_chunk\ndata: [');
117
- res.write(raw.includes('\n') ? raw.replace(/\n/g, '') : raw);
118
- res.write(']\n\n');
208
+ sseWrite(res, 'event: load_chunk\ndata: [');
209
+ sseWrite(res, raw.includes('\n') ? raw.replace(/\n/g, '') : raw);
210
+ sseWrite(res, ']\n\n');
119
211
  }
120
212
  } else {
121
213
  // 全量加载模式(向后兼容)
122
214
  const total = await countLogEntries(filePath);
123
- res.write(`event: load_start\ndata: ${JSON.stringify({ total, incremental: false })}\n\n`);
215
+ sseWrite(res, `event: load_start\ndata: ${JSON.stringify({ total, incremental: false })}\n\n`);
124
216
  await streamRawEntriesAsync(filePath, (raw) => {
125
- res.write('event: load_chunk\ndata: [');
126
- res.write(raw.includes('\n') ? raw.replace(/\n/g, '') : raw);
127
- res.write(']\n\n');
217
+ sseWrite(res, 'event: load_chunk\ndata: [');
218
+ sseWrite(res, raw.includes('\n') ? raw.replace(/\n/g, '') : raw);
219
+ sseWrite(res, ']\n\n');
128
220
  });
129
221
  }
130
- res.write(`event: load_end\ndata: {}\n\n`);
131
- res.end();
222
+ sseWrite(res, `event: load_end\ndata: {}\n\n`);
223
+ wireEnd(res);
132
224
  } catch (err) {
133
225
  // 如果 headers 未发送,返回 JSON 错误;否则关闭连接
134
- // 落 stderr 让用户在 ccv 终端能看到 .jsonl.zip 解压失败等具体原因(SSE onerror 在客户端
226
+ // 落 stderr 让用户在 ccv 终端能看到具体原因(SSE onerror 在客户端
135
227
  // 不携带错误明细,只能从服务端日志反查)
136
228
  console.error('[local-log]', file, err && err.stack || err);
137
229
  if (!res.headersSent) {
@@ -139,7 +231,7 @@ async function localLog(req, res, parsedUrl) {
139
231
  res.writeHead(status, { 'Content-Type': 'application/json' });
140
232
  res.end(JSON.stringify({ error: err.message }));
141
233
  } else {
142
- res.end();
234
+ wireEnd(res); // flush the encoder trailer if the stream was compressed
143
235
  }
144
236
  }
145
237
  }
@@ -155,7 +247,7 @@ function deleteLogs(req, res, parsedUrl, isLocal, deps) {
155
247
  res.end(JSON.stringify({ error: 'No files specified' }));
156
248
  return;
157
249
  }
158
- const results = deleteLogFiles(LOG_DIR, files);
250
+ const results = deleteLogFiles(LOG_DIR, files, { liveSessionDir: _v2Writer.currentSessionDir() });
159
251
  res.writeHead(200, { 'Content-Type': 'application/json' });
160
252
  res.end(JSON.stringify({ results }));
161
253
  } catch {
@@ -165,162 +257,115 @@ function deleteLogs(req, res, parsedUrl, isLocal, deps) {
165
257
  });
166
258
  }
167
259
 
168
- function mergeLogs(req, res, parsedUrl, isLocal, deps) {
169
- let body = '';
170
- req.on('data', chunk => { body += chunk; if (body.length > deps.MAX_POST_BODY) req.destroy(); });
171
- req.on('end', async () => {
172
- try {
173
- const { files } = JSON.parse(body);
174
- const merged = await mergeLogFiles(LOG_DIR, files);
175
- res.writeHead(200, { 'Content-Type': 'application/json' });
176
- res.end(JSON.stringify({ ok: true, merged }));
177
- } catch (err) {
178
- const status = err.code === 'NOT_FOUND' ? 404 : err.code === 'INVALID_INPUT' ? 400 : 500;
179
- res.writeHead(status, { 'Content-Type': 'application/json' });
180
- res.end(JSON.stringify({ error: err.message }));
181
- }
182
- });
260
+ // wire-v2 S8: one-click v1→v2 migration of the CURRENT project, run by the
261
+ // resident convert-manager worker. GET = status snapshot for the modal's
262
+ // polling; POST {action:'start'|'stop'} controls the task. Local/LAN exposure
263
+ // via the existing auth middleware (same posture as delete-logs).
264
+ function getWireV2Convert(req, res) {
265
+ res.writeHead(200, { 'Content-Type': 'application/json' });
266
+ res.end(JSON.stringify(convertStatus(LOG_DIR, _projectName || '')));
183
267
  }
184
268
 
185
- function archiveLogs(req, res, parsedUrl, isLocal, deps) {
269
+ function postWireV2Convert(req, res, parsedUrl, isLocal, deps) {
186
270
  let body = '';
187
271
  req.on('data', chunk => { body += chunk; if (body.length > deps.MAX_POST_BODY) req.destroy(); });
188
272
  req.on('end', () => {
189
273
  try {
190
- const { files } = JSON.parse(body);
191
- if (!Array.isArray(files) || files.length === 0) {
274
+ const { action } = JSON.parse(body || '{}');
275
+ if (action !== 'start' && action !== 'stop') {
192
276
  res.writeHead(400, { 'Content-Type': 'application/json' });
193
- res.end(JSON.stringify({ error: 'No files specified' }));
277
+ res.end(JSON.stringify({ error: 'action must be "start" or "stop"' }));
194
278
  return;
195
279
  }
196
- if (files.length > 50) {
280
+ if (action === 'start' && !_projectName) {
197
281
  res.writeHead(400, { 'Content-Type': 'application/json' });
198
- res.end(JSON.stringify({ error: 'Too many files (max 50 per request)' }));
282
+ res.end(JSON.stringify({ error: 'no active project' }));
199
283
  return;
200
284
  }
201
- const result = archiveLogFiles(LOG_DIR, files);
202
- res.writeHead(200, { 'Content-Type': 'application/json' });
203
- res.end(JSON.stringify(result));
204
- } catch {
205
- res.writeHead(400, { 'Content-Type': 'application/json' });
206
- res.end(JSON.stringify({ error: 'Invalid JSON' }));
285
+ const result = action === 'start' ? startConvert(LOG_DIR, _projectName) : stopConvert();
286
+ res.writeHead(result.ok ? 200 : 409, { 'Content-Type': 'application/json' });
287
+ res.end(JSON.stringify(result.ok ? { ok: true } : { error: result.error }));
288
+ } catch (err) {
289
+ const status = err instanceof SyntaxError ? 400 : 500;
290
+ res.writeHead(status, { 'Content-Type': 'application/json' });
291
+ res.end(JSON.stringify({ error: err?.message || 'internal error' }));
207
292
  }
208
293
  });
209
294
  }
210
295
 
211
- // Bounded head read: returns the parsed first frame of a live log file when it
212
- // is a rotation-context sentinel, else null. Never scans past the first frame.
213
- function readHeadRotationContext(file) {
214
- try {
215
- if (!file || !existsSync(file)) return null;
216
- const fd = openSync(file, 'r');
217
- let head;
218
- try {
219
- const buf = Buffer.alloc(64 * 1024);
220
- const n = readSync(fd, buf, 0, buf.length, 0);
221
- head = buf.toString('utf-8', 0, n);
222
- } finally {
223
- closeSync(fd);
224
- }
225
- const frameEnd = head.indexOf('\n---\n');
226
- if (frameEnd <= 0) return null;
227
- const entry = JSON.parse(head.slice(0, frameEnd));
228
- return entry && entry.ccvRotationContext ? entry : null;
229
- } catch {
230
- return null;
231
- }
232
- }
233
-
234
- // Cheap substring gate evaluated before JSON.parse: every teammate shape
235
- // carries at least one of these markers in its raw JSON (external tag,
236
- // proxy-mode system marker, native SDK prompt). Giant MainAgent checkpoint
237
- // frames are skipped without parsing.
238
- function teammateRawPrefilter(raw) {
239
- return raw.includes('"teammate"')
240
- || raw.includes('agent in a team')
241
- || raw.includes('Agent Teammate Communication')
242
- || raw.includes('You are a Claude agent');
243
- }
244
-
245
- // Single-flight + short-TTL cache for the previous-segment scan. A real
246
- // predecessor is ~300MB and the parse pass transiently costs hundreds of MB of
247
- // RSS; every non-incremental cold load triggers the route, so concurrent
248
- // viewers / rapid refreshes must share one scan. Keyed by path+size+mtime —
249
- // the old segment can still GROW (external teammates keep appending to it),
250
- // which changes the key and forces a rescan.
251
- const PREV_SCAN_TTL_MS = 60 * 1000;
252
- let _prevScanCache = null; // { key, promise, expiresAt }
253
-
254
- function collectPrevSegmentCached(prev, predicate, opts) {
255
- let st = null;
256
- try { st = statSync(prev); } catch { }
257
- const key = `${prev}|${st ? st.size : 0}|${st ? st.mtimeMs : 0}`;
258
- const now = Date.now();
259
- if (_prevScanCache && _prevScanCache.key === key && _prevScanCache.expiresAt > now) {
260
- return _prevScanCache.promise;
296
+ // wire-v2 S6a: upload a v2-session zip (produced by `?format=raw` download),
297
+ // extract it to a temp dir, synthesize + stream it back as v1-shaped `\n---\n`
298
+ // raw entries for ephemeral viewing, then delete the temp dir. Nothing is
299
+ // written into LOG_DIR — view-only, parity with the legacy client-side `.jsonl`
300
+ // upload. Raw `application/zip` body (no multipart); uses its own MAX_UPLOAD
301
+ // cap, NOT deps.MAX_POST_BODY (which is ~10MB and would reject any real zip).
302
+ function uploadLogZip(req, res) {
303
+ const contentLength = parseInt(req.headers['content-length'] || '0', 10);
304
+ if (contentLength > MAX_UPLOAD) {
305
+ res.writeHead(413, { 'Content-Type': 'application/json' });
306
+ res.end(JSON.stringify({ error: 'File too large', code: 'TOO_LARGE' }));
307
+ return;
261
308
  }
262
- const promise = collectFilteredRawEntriesAsync(prev, predicate, opts);
263
- _prevScanCache = { key, promise, expiresAt: now + PREV_SCAN_TTL_MS };
264
- promise.catch(() => {
265
- if (_prevScanCache && _prevScanCache.promise === promise) _prevScanCache = null;
266
- });
267
- return promise;
268
- }
269
-
270
- /**
271
- * Post-rotation teammate backfill. Resolves the previous segment of the
272
- * server's OWN live LOG_FILE (no client-supplied filenames — no traversal
273
- * surface) and streams NDJSON:
274
- * line 1: { rotationContext, teammateNames } (context; teammateNames merges
275
- * the head sentinel's carry-forward with the in-process spawn
276
- * registry — the sentinel may sit outside the client's load window)
277
- * lines: one backfill entry per line (teammate-like, renderable only)
278
- * last: { done: true, truncated, prevSegment }
279
- */
280
- async function prevSegmentTeammates(req, res, parsedUrl, isLocal, deps) {
281
- res.writeHead(200, { 'Content-Type': 'application/x-ndjson' });
282
- try {
283
- const current = LOG_FILE;
284
- const sentinel = readHeadRotationContext(current);
285
- const names = new Map(sentinel?.teammateNames?.filter((p) => Array.isArray(p) && p[0]) || []);
286
- for (const [prefix, name] of getAgentSpawnRegistrySnapshot()) names.set(prefix, name);
287
- res.write(JSON.stringify({
288
- rotationContext: sentinel ? { from: sentinel.from || null } : null,
289
- teammateNames: [...names.entries()],
290
- }) + '\n');
291
- // No live file (workspace pre-select) or resume placeholder → context only.
292
- if (!current || current.endsWith('_temp.jsonl')) {
293
- res.end(JSON.stringify({ done: true, truncated: false, prevSegment: null }) + '\n');
309
+ let chunks = [];
310
+ let totalSize = 0;
311
+ let aborted = false;
312
+ // A large remote upload can reset mid-flight; without an 'error' listener the
313
+ // socket error would crash the process (unhandled 'error' event).
314
+ req.on('error', () => { aborted = true; chunks = []; });
315
+ req.on('data', (chunk) => {
316
+ if (aborted) return;
317
+ totalSize += chunk.length;
318
+ if (totalSize > MAX_UPLOAD) {
319
+ aborted = true;
320
+ res.writeHead(413, { 'Content-Type': 'application/json' });
321
+ res.end(JSON.stringify({ error: 'File too large', code: 'TOO_LARGE' }));
322
+ req.destroy();
294
323
  return;
295
324
  }
296
- const instanceId = (deps && deps.instanceId) || null;
297
- const prev = findPreviousSegment(current, _projectName, instanceId);
298
- if (!prev) {
299
- res.end(JSON.stringify({ done: true, truncated: false, prevSegment: null }) + '\n');
300
- return;
325
+ chunks.push(chunk);
326
+ });
327
+ req.on('end', async () => {
328
+ if (aborted) return;
329
+ let tmpDir = null;
330
+ try {
331
+ tmpDir = mkdtempSync(join(tmpdir(), 'ccv-upload-'));
332
+ const buf = Buffer.concat(chunks);
333
+ chunks = []; // free the per-chunk copies; adm-zip holds `buf` from here
334
+ const sessionDir = extractV2Zip(buf, tmpDir);
335
+ res.writeHead(200, {
336
+ 'Content-Type': 'application/octet-stream',
337
+ 'Transfer-Encoding': 'chunked',
338
+ });
339
+ await streamRawEntriesAsync(sessionDir, (raw) => {
340
+ res.write(raw);
341
+ res.write('\n---\n');
342
+ });
343
+ res.end();
344
+ } catch (err) {
345
+ const status = err?.status || 500;
346
+ if (status >= 500) console.error('[api/upload-log-zip]', err);
347
+ if (!res.headersSent) {
348
+ const safeError = status >= 500 ? 'server_error' : (err?.message || 'error');
349
+ res.writeHead(status, { 'Content-Type': 'application/json' });
350
+ res.end(JSON.stringify({ error: safeError, code: err?.code || 'unknown' }));
351
+ } else {
352
+ res.end();
353
+ }
354
+ } finally {
355
+ // Always reclaim the temp dir. The adapter has finished all reads by the
356
+ // time the await settles (cold v2 reads are bounded), so this is race-free
357
+ // even when the client disconnected mid-stream.
358
+ if (tmpDir) { try { rmSync(tmpDir, { recursive: true, force: true }); } catch {} }
301
359
  }
302
- const { entries, truncated } = await collectPrevSegmentCached(prev, (entry) => (
303
- !entry.inProgress && !entry.isHeartbeat && !entry.isCountTokens
304
- && !entry.ccvRotationContext
305
- && !(entry._deltaFormat && entry.mainAgent && !entry.teammate)
306
- && !!entry.timestamp
307
- && Array.isArray(entry.response?.body?.content) && entry.response.body.content.length > 0
308
- && isTeammateLikeEntry(entry)
309
- ), { rawPrefilter: teammateRawPrefilter });
310
- for (const entry of entries) res.write(JSON.stringify(entry) + '\n');
311
- res.end(JSON.stringify({ done: true, truncated, prevSegment: basename(prev) }) + '\n');
312
- } catch (err) {
313
- // Headers already sent — terminate the NDJSON stream with an error line.
314
- try { res.end(JSON.stringify({ done: true, error: err?.message || 'internal error' }) + '\n'); } catch { }
315
- }
360
+ });
316
361
  }
317
362
 
318
363
  export const logsRoutes = [
319
364
  { method: 'GET', match: 'exact', path: '/api/local-logs', handler: localLogs },
365
+ { method: 'GET', match: 'exact', path: '/api/wire-v2-convert', handler: getWireV2Convert },
366
+ { method: 'POST', match: 'exact', path: '/api/wire-v2-convert', handler: postWireV2Convert },
320
367
  { method: 'GET', match: 'exact', path: '/api/download-log', handler: downloadLog },
321
368
  { method: 'GET', match: 'exact', path: '/api/local-log', handler: localLog },
322
369
  { method: 'POST', match: 'exact', path: '/api/delete-logs', handler: deleteLogs },
323
- { method: 'POST', match: 'exact', path: '/api/merge-logs', handler: mergeLogs },
324
- { method: 'POST', match: 'exact', path: '/api/archive-logs', handler: archiveLogs },
325
- { method: 'GET', match: 'exact', path: '/api/prev-segment-teammates', handler: prevSegmentTeammates },
370
+ { method: 'POST', match: 'exact', path: '/api/upload-log-zip', handler: uploadLogZip },
326
371
  ];
@@ -58,7 +58,6 @@ function preferencesGet(req, res, parsedUrl, isLocal, deps) {
58
58
  prefs.logDir = LOG_DIR; // 始终返回当前运行时的日志目录
59
59
  // 日志设置出厂默认"继承":键缺失(从未设置过)才注入;显式关闭持久化的是 null(键存在),不覆盖。
60
60
  // 虚拟默认 —— 仅注入回包不落盘(GET 不写文件),直接读 preferences.json 的代码看不到该默认。
61
- if (!('resumeAutoChoice' in prefs)) prefs.resumeAutoChoice = 'continue';
62
61
  // home-friendly 展示形态:设了 CLAUDE_CONFIG_DIR 的用户看到真实路径,默认用户看到 "~/.claude"
63
62
  // join() 而非字符串拼接,避免 Windows 分隔符不匹配导致比较失败
64
63
  const _cDir = getClaudeConfigDir();
@@ -168,8 +167,6 @@ function preferencesPost(req, res, parsedUrl, isLocal, deps) {
168
167
  delete prefs.prefsByProject; // fork blob 绝不回显(与 GET 一致)
169
168
  stripImConfigs(prefs);
170
169
  prefs.logDir = LOG_DIR;
171
- // 与 GET 一致:回显里补齐 resumeAutoChoice 虚拟默认(已在上方落盘,文件不含该默认值)
172
- if (!('resumeAutoChoice' in prefs)) prefs.resumeAutoChoice = 'continue';
173
170
  res.writeHead(200, { 'Content-Type': 'application/json' });
174
171
  res.end(JSON.stringify(prefs));
175
172
  } catch (err) {
@@ -22,10 +22,9 @@ export function getInstallMethod({
22
22
  return 'npm';
23
23
  }
24
24
 
25
- function projectName(req, res, parsedUrl, isLocal, deps) {
25
+ function projectName(req, res) {
26
26
  res.writeHead(200, { 'Content-Type': 'application/json' });
27
- // instanceId (`--pid`) lets the client scope its session-pin requests + show 项目(id) in the title.
28
- res.end(JSON.stringify({ projectName: _projectName || '', instanceId: (deps && deps.instanceId) || null }));
27
+ res.end(JSON.stringify({ projectName: _projectName || '' }));
29
28
  }
30
29
 
31
30
  function projectDir(req, res) {
@@ -1,7 +1,7 @@
1
1
  // Server-side "current session" pin — replaces the old per-browser localStorage pin so a
2
- // project's "仅展示当前会话" pointer is shared across every device hitting THIS ccv process,
3
- // and (with `--pid`) isolated between processes. View state only → no loopback gating.
4
- // - GET /api/session-pin → { pinnedSessionId, instanceId }
2
+ // project's current-session pointer is shared across every device hitting THIS ccv process.
3
+ // View state only → no loopback gating.
4
+ // - GET /api/session-pin → { pinnedSessionId }
5
5
  // - POST /api/session-pin {pinnedSessionId: string|null} → persist + SSE-broadcast
6
6
  // `_logDir` is the live per-project dir ('' = no active project → GET null / POST no-op).
7
7
  import { _logDir } from '../interceptor.js';
@@ -13,9 +13,8 @@ function sendJson(res, code, obj) {
13
13
  res.end(JSON.stringify(obj));
14
14
  }
15
15
 
16
- function getHandler(req, res, parsedUrl, isLocal, deps) {
17
- const instanceId = deps.instanceId || null;
18
- sendJson(res, 200, { pinnedSessionId: readPin(_logDir, instanceId), instanceId });
16
+ function getHandler(req, res) {
17
+ sendJson(res, 200, { pinnedSessionId: readPin(_logDir) });
19
18
  }
20
19
 
21
20
  function postHandler(req, res, parsedUrl, isLocal, deps) {
@@ -27,8 +26,8 @@ function postHandler(req, res, parsedUrl, isLocal, deps) {
27
26
  catch { return sendJson(res, 400, { error: 'Invalid JSON' }); }
28
27
  const raw = incoming.pinnedSessionId;
29
28
  const pinnedSessionId = (typeof raw === 'string' && raw) ? raw : null;
30
- writePin(_logDir, deps.instanceId || null, pinnedSessionId);
31
- // Broadcast to THIS process's SSE clients so every device on this instance stays in sync.
29
+ writePin(_logDir, pinnedSessionId);
30
+ // Broadcast to THIS process's SSE clients so every device stays in sync.
32
31
  sendEventToClients(deps.clients, 'session_pin', { pinnedSessionId });
33
32
  sendJson(res, 200, { ok: true, pinnedSessionId });
34
33
  });
@@ -0,0 +1,60 @@
1
+ // Wire v3 routes (V3.S1+): on-demand single-entry detail for the metadata-row
2
+ // request list. Registered unconditionally — harmless while CCV_WIRE_V3 is off
3
+ // (nothing calls it), so the route itself needs no flag gate.
4
+ import { join } from 'node:path';
5
+ import { LOG_DIR } from '../../findcc.js';
6
+ import { validateLogPath } from '../lib/log-management.js';
7
+ import { resolveSessionDirName } from '../lib/v2/session-select.js';
8
+ import { readV2SingleEntry } from '../lib/v2/adapter.js';
9
+ import { sseHead, sseWrite, wireEnd } from '../lib/wire-compress.js';
10
+
11
+ /**
12
+ * GET /api/v2-entry?file=v2:<project>/<dirToken>&seq=N[&sid=<uuid>]
13
+ * dirToken is normally the session dir basename (`<ts>_<uuid>`); a bare UUID
14
+ * (the client only holds `_seqEpoch="v2:<uuid>"`) is resolved to the basename
15
+ * BEFORE validateLogPath — validation expects an existing directory path.
16
+ * `sid` disambiguates teammate rows: the fold runs over the leader dir, the
17
+ * target is keyed (sid, seq); omitted = match seq in any folded session.
18
+ */
19
+ async function v2Entry(req, res, parsedUrl) {
20
+ const file = parsedUrl.searchParams.get('file') || '';
21
+ const seq = parseInt(parsedUrl.searchParams.get('seq'), 10);
22
+ const sid = parsedUrl.searchParams.get('sid') || null;
23
+ const m = /^v2:([^/]+)\/([^/]+)$/.exec(file);
24
+ if (!m || !Number.isInteger(seq) || seq < 0) {
25
+ res.writeHead(400, { 'Content-Type': 'application/json' });
26
+ res.end(JSON.stringify({ error: 'expected file=v2:<project>/<session>&seq=<n>' }));
27
+ return;
28
+ }
29
+ try {
30
+ // UUID → dir-basename resolution first (rename/-c adoption safe), then the
31
+ // same path validation every other v2 ref goes through.
32
+ const [, project, dirToken] = m;
33
+ const resolved = resolveSessionDirName(join(LOG_DIR, project), dirToken) || dirToken;
34
+ const sessionDir = validateLogPath(LOG_DIR, `v2:${project}/${resolved}`);
35
+ const result = await readV2SingleEntry(sessionDir, { seq, sessionId: sid });
36
+ if (!result) {
37
+ res.writeHead(404, { 'Content-Type': 'application/json' });
38
+ res.end(JSON.stringify({ error: 'entry not found' }));
39
+ return;
40
+ }
41
+ // Whole-stream JSON (like /api/requests): compress without per-event flush.
42
+ sseHead(req, res, 200, { 'Content-Type': 'application/json' }, { flush: false });
43
+ // slots are raw JSON strings — compose without re-parse/re-stringify.
44
+ sseWrite(res, `{"entry":${result.entry},"prevMain":${result.prevMain ?? 'null'}}`);
45
+ wireEnd(res);
46
+ } catch (err) {
47
+ console.error('[v2-entry]', file, err && err.stack || err);
48
+ if (!res.headersSent) {
49
+ const status = err.code === 'NOT_FOUND' ? 404 : err.code === 'ACCESS_DENIED' ? 403 : 500;
50
+ res.writeHead(status, { 'Content-Type': 'application/json' });
51
+ res.end(JSON.stringify({ error: err.message }));
52
+ } else {
53
+ wireEnd(res);
54
+ }
55
+ }
56
+ }
57
+
58
+ export const v2Routes = [
59
+ { method: 'GET', match: 'exact', path: '/api/v2-entry', handler: v2Entry },
60
+ ];