cc-viewer 1.6.348 → 1.7.0

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 (126) hide show
  1. package/README.md +13 -1
  2. package/cli.js +116 -37
  3. package/dist/assets/App-DTkacfRW.js +2 -0
  4. package/dist/assets/{MdxEditorPanel-ualoRFOL.js → MdxEditorPanel-C5Q6uUyH.js} +1 -1
  5. package/dist/assets/Mobile-MoBp9c8k.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-Ac8vnV3v.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-Cd_TAXTb.js +2 -0
  57. package/dist/assets/{seqResourceLoaders-B5Zy_CC_.css → seqResourceLoaders-CxuD1CYl.css} +1 -1
  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 +2 -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 +34 -272
  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 +1059 -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 +451 -0
  99. package/server/lib/v2/migrate-prompt.js +54 -0
  100. package/server/lib/v2/replay.js +191 -0
  101. package/server/lib/v2/session-select.js +222 -0
  102. package/server/lib/v2/singleflight.js +104 -0
  103. package/server/lib/v2/v2-writer.js +513 -0
  104. package/server/lib/v2/verify.js +233 -0
  105. package/server/routes/events.js +30 -130
  106. package/server/routes/files-fs.js +4 -2
  107. package/server/routes/im.js +11 -2
  108. package/server/routes/logs.js +194 -152
  109. package/server/routes/preferences.js +0 -3
  110. package/server/routes/project-meta.js +2 -3
  111. package/server/routes/session-pin.js +7 -8
  112. package/server/routes/workspaces.js +44 -9
  113. package/server/server.js +47 -65
  114. package/server/workspace-registry.js +24 -4
  115. package/dist/assets/App-VTo4NVdo.js +0 -2
  116. package/dist/assets/Mobile-CcmYvMm_.js +0 -1
  117. package/dist/assets/classDiagram-6PBFFD2Q-BXnyUgsj.js +0 -1
  118. package/dist/assets/classDiagram-v2-HSJHXN6E-BXnyUgsj.js +0 -1
  119. package/dist/assets/clone-md-Mc1-T.js +0 -1
  120. package/dist/assets/index-DCo650PG.js +0 -2
  121. package/dist/assets/mermaid.core-C2Parj1q.js +0 -7
  122. package/dist/assets/seqResourceLoaders-BVozq68i.js +0 -2
  123. package/server/lib/instance-registry.js +0 -57
  124. package/server/lib/jsonl-archive.js +0 -180
  125. package/server/lib/teammate-detect.js +0 -32
  126. package/server/lib/zip-safety.js +0 -68
@@ -1,18 +1,46 @@
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 { dirSizeSync } from '../lib/v2/layout.js';
11
+ import { extractV2Zip } from '../lib/log-zip.js';
12
+ import { startConvert, stopConvert, convertStatus } from '../lib/v2/convert-manager.js';
13
+ import { migrationStatus } from '../lib/v2/migrate-prompt.js';
14
+
15
+ // Cap for the in-memory zip build (adm-zip toBuffer holds the whole archive in
16
+ // heap) and the upload accumulation — the repo has an OOM history, so both the
17
+ // zipped source folder and the uploaded archive are bounded before buffering.
18
+ const MAX_UPLOAD = 300 * 1024 * 1024;
19
+ // Download-zip source cap; read per-request so CCV_MAX_ZIP_SOURCE is an ops
20
+ // tunable (and a test seam) without a restart.
21
+ const maxZipSource = () => Number(process.env.CCV_MAX_ZIP_SOURCE) || 300 * 1024 * 1024;
9
22
 
10
23
  async function localLogs(req, res, parsedUrl, isLocal, deps) {
11
24
  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 });
25
+ // 1.7.0: the DEFAULT list is v2 sessions, unconditionally. `?view=v1`
26
+ // serves the legacy-file list for the modal's v1 view (list + migrate +
27
+ // open + delete for leftovers the converter never removes).
28
+ if (parsedUrl?.searchParams?.get('view') === 'v1') {
29
+ const v1 = await listLocalLogs(LOG_DIR, _projectName);
30
+ res.writeHead(200, { 'Content-Type': 'application/json' });
31
+ res.end(JSON.stringify(v1));
32
+ return;
33
+ }
34
+ const result = listV2Logs(LOG_DIR, _projectName);
35
+ // Legacy v1 files are not in this list — surface two distinct signals:
36
+ // - _v1FileCount: v1 files ON DISK (gates the v1-view entry link; the
37
+ // converter never deletes sources, so this outlives a finished migration)
38
+ // - _unmigratedV1Count/Bytes: files still AWAITING migration (gates the
39
+ // migrate button + hint inside the v1 view and the startup prompt)
40
+ const mig = migrationStatus(LOG_DIR, _projectName || '');
41
+ result._unmigratedV1Count = mig.files;
42
+ result._unmigratedV1Bytes = mig.totalBytes;
43
+ result._v1FileCount = _projectName ? countListedV1Files(join(LOG_DIR, _projectName)) : 0;
16
44
  res.writeHead(200, { 'Content-Type': 'application/json' });
17
45
  res.end(JSON.stringify(result));
18
46
  } catch (err) {
@@ -28,7 +56,69 @@ async function downloadLog(req, res, parsedUrl) {
28
56
  res.end(JSON.stringify({ error: 'Invalid file name' }));
29
57
  return;
30
58
  }
31
- if (!file.endsWith('.jsonl') && !file.endsWith('.jsonl.zip')) {
59
+ // wire-v2 S5: rebuilt download for v2 sessions — the adapter stream as a v1
60
+ // `.jsonl`. `format=raw` (session-dir zip) is the S6a half of the S0 contract:
61
+ // a lossless zip of the whole session folder, the transport for round-tripping
62
+ // a v2 session (folder) back through the upload-and-parse flow.
63
+ if (file.startsWith('v2:')) {
64
+ if (parsedUrl.searchParams.get('format') === 'raw') {
65
+ try {
66
+ const sessionDir = validateLogPath(LOG_DIR, file);
67
+ if (dirSizeSync(sessionDir) > maxZipSource()) {
68
+ res.writeHead(413, { 'Content-Type': 'application/json' });
69
+ res.end(JSON.stringify({ error: 'Session too large to zip', code: 'TOO_LARGE' }));
70
+ return;
71
+ }
72
+ const dirName = basename(sessionDir);
73
+ const zip = new AdmZip();
74
+ // Wrap the folder under `<dirName>/` and skip dotfiles (.DS_Store etc.).
75
+ // adm-zip passes the zip-internal path (incl. the `<dirName>/` root) to
76
+ // the filter, so match on any path segment starting with a dot.
77
+ zip.addLocalFolder(sessionDir, dirName, (n) => !/(^|\/)\./.test(n.replace(/\\/g, '/')));
78
+ const buf = zip.toBuffer();
79
+ res.writeHead(200, {
80
+ 'Content-Type': 'application/zip',
81
+ 'Content-Disposition': `attachment; filename="${encodeURIComponent(`${dirName}.zip`)}"`,
82
+ 'Content-Length': buf.length,
83
+ });
84
+ res.end(buf);
85
+ } catch (err) {
86
+ if (!res.headersSent) {
87
+ const status = err.code === 'NOT_FOUND' ? 404 : err.code === 'ACCESS_DENIED' ? 403 : 500;
88
+ // Don't leak internal messages (may contain absolute paths) on 500 —
89
+ // genericize like the upload handler does.
90
+ res.writeHead(status, { 'Content-Type': 'application/json' });
91
+ res.end(JSON.stringify({ error: status >= 500 ? 'server_error' : err.message }));
92
+ } else {
93
+ res.end();
94
+ }
95
+ }
96
+ return;
97
+ }
98
+ try {
99
+ const sessionDir = validateLogPath(LOG_DIR, file);
100
+ res.writeHead(200, {
101
+ 'Content-Type': 'application/octet-stream',
102
+ 'Content-Disposition': `attachment; filename="${encodeURIComponent(`${basename(sessionDir)}.v2-rebuilt.jsonl`)}"`,
103
+ 'Transfer-Encoding': 'chunked',
104
+ });
105
+ await streamRawEntriesAsync(sessionDir, (raw) => {
106
+ res.write(raw);
107
+ res.write('\n---\n');
108
+ });
109
+ res.end();
110
+ } catch (err) {
111
+ if (!res.headersSent) {
112
+ const status = err.code === 'NOT_FOUND' ? 404 : err.code === 'ACCESS_DENIED' ? 403 : 500;
113
+ res.writeHead(status, { 'Content-Type': 'application/json' });
114
+ res.end(JSON.stringify({ error: err.message }));
115
+ } else {
116
+ res.end();
117
+ }
118
+ }
119
+ return;
120
+ }
121
+ if (!file.endsWith('.jsonl')) {
32
122
  res.writeHead(400, { 'Content-Type': 'application/json' });
33
123
  res.end(JSON.stringify({ error: 'Invalid file type' }));
34
124
  return;
@@ -48,10 +138,7 @@ async function downloadLog(req, res, parsedUrl) {
48
138
  return;
49
139
  }
50
140
  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);
141
+ const fileName = basename(file);
55
142
  // Delta storage: format=raw 下载原始文件;默认下载重建后的全量格式
56
143
  if (format === 'raw') {
57
144
  const stat = statSync(realPath);
@@ -89,17 +176,19 @@ async function localLog(req, res, parsedUrl) {
89
176
  return;
90
177
  }
91
178
 
92
- // 验证文件类型:允许 .jsonl .jsonl.zip
93
- if (!file.endsWith('.jsonl') && !file.endsWith('.jsonl.zip')) {
179
+ // 验证文件类型:v2 寻址(spec §12);直连 .jsonl 保留为未迁移日志的逃生舱
180
+ // (代码与转换器共享,UI 无入口——1.7.0 决策)。
181
+ const isV2Ref = file.startsWith('v2:');
182
+ if (!isV2Ref && !file.endsWith('.jsonl')) {
94
183
  res.writeHead(400, { 'Content-Type': 'application/json' });
95
- res.end(JSON.stringify({ error: 'Invalid file type. Only .jsonl(.zip) files are allowed.' }));
184
+ res.end(JSON.stringify({ error: 'Invalid file type. Only .jsonl files are allowed.' }));
96
185
  return;
97
186
  }
98
187
 
99
188
  try {
100
189
  // 独立 SSE 流:直接向请求方返回 event-stream,不走 /events 广播
101
- validateLogPath(LOG_DIR, file);
102
- const filePath = join(LOG_DIR, file);
190
+ // v2 寻址时 validateLogPath 返回 session 目录绝对路径,log-stream 据此分派适配器
191
+ const filePath = validateLogPath(LOG_DIR, file);
103
192
  const limitVal = Math.min(parseInt(parsedUrl.searchParams.get('limit'), 10) || 0, 500);
104
193
 
105
194
  res.writeHead(200, {
@@ -131,7 +220,7 @@ async function localLog(req, res, parsedUrl) {
131
220
  res.end();
132
221
  } catch (err) {
133
222
  // 如果 headers 未发送,返回 JSON 错误;否则关闭连接
134
- // 落 stderr 让用户在 ccv 终端能看到 .jsonl.zip 解压失败等具体原因(SSE onerror 在客户端
223
+ // 落 stderr 让用户在 ccv 终端能看到具体原因(SSE onerror 在客户端
135
224
  // 不携带错误明细,只能从服务端日志反查)
136
225
  console.error('[local-log]', file, err && err.stack || err);
137
226
  if (!res.headersSent) {
@@ -155,7 +244,7 @@ function deleteLogs(req, res, parsedUrl, isLocal, deps) {
155
244
  res.end(JSON.stringify({ error: 'No files specified' }));
156
245
  return;
157
246
  }
158
- const results = deleteLogFiles(LOG_DIR, files);
247
+ const results = deleteLogFiles(LOG_DIR, files, { liveSessionDir: _v2Writer.currentSessionDir() });
159
248
  res.writeHead(200, { 'Content-Type': 'application/json' });
160
249
  res.end(JSON.stringify({ results }));
161
250
  } catch {
@@ -165,162 +254,115 @@ function deleteLogs(req, res, parsedUrl, isLocal, deps) {
165
254
  });
166
255
  }
167
256
 
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
- });
257
+ // wire-v2 S8: one-click v1→v2 migration of the CURRENT project, run by the
258
+ // resident convert-manager worker. GET = status snapshot for the modal's
259
+ // polling; POST {action:'start'|'stop'} controls the task. Local/LAN exposure
260
+ // via the existing auth middleware (same posture as delete-logs).
261
+ function getWireV2Convert(req, res) {
262
+ res.writeHead(200, { 'Content-Type': 'application/json' });
263
+ res.end(JSON.stringify(convertStatus(LOG_DIR, _projectName || '')));
183
264
  }
184
265
 
185
- function archiveLogs(req, res, parsedUrl, isLocal, deps) {
266
+ function postWireV2Convert(req, res, parsedUrl, isLocal, deps) {
186
267
  let body = '';
187
268
  req.on('data', chunk => { body += chunk; if (body.length > deps.MAX_POST_BODY) req.destroy(); });
188
269
  req.on('end', () => {
189
270
  try {
190
- const { files } = JSON.parse(body);
191
- if (!Array.isArray(files) || files.length === 0) {
271
+ const { action } = JSON.parse(body || '{}');
272
+ if (action !== 'start' && action !== 'stop') {
192
273
  res.writeHead(400, { 'Content-Type': 'application/json' });
193
- res.end(JSON.stringify({ error: 'No files specified' }));
274
+ res.end(JSON.stringify({ error: 'action must be "start" or "stop"' }));
194
275
  return;
195
276
  }
196
- if (files.length > 50) {
277
+ if (action === 'start' && !_projectName) {
197
278
  res.writeHead(400, { 'Content-Type': 'application/json' });
198
- res.end(JSON.stringify({ error: 'Too many files (max 50 per request)' }));
279
+ res.end(JSON.stringify({ error: 'no active project' }));
199
280
  return;
200
281
  }
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' }));
282
+ const result = action === 'start' ? startConvert(LOG_DIR, _projectName) : stopConvert();
283
+ res.writeHead(result.ok ? 200 : 409, { 'Content-Type': 'application/json' });
284
+ res.end(JSON.stringify(result.ok ? { ok: true } : { error: result.error }));
285
+ } catch (err) {
286
+ const status = err instanceof SyntaxError ? 400 : 500;
287
+ res.writeHead(status, { 'Content-Type': 'application/json' });
288
+ res.end(JSON.stringify({ error: err?.message || 'internal error' }));
207
289
  }
208
290
  });
209
291
  }
210
292
 
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;
293
+ // wire-v2 S6a: upload a v2-session zip (produced by `?format=raw` download),
294
+ // extract it to a temp dir, synthesize + stream it back as v1-shaped `\n---\n`
295
+ // raw entries for ephemeral viewing, then delete the temp dir. Nothing is
296
+ // written into LOG_DIR — view-only, parity with the legacy client-side `.jsonl`
297
+ // upload. Raw `application/zip` body (no multipart); uses its own MAX_UPLOAD
298
+ // cap, NOT deps.MAX_POST_BODY (which is ~10MB and would reject any real zip).
299
+ function uploadLogZip(req, res) {
300
+ const contentLength = parseInt(req.headers['content-length'] || '0', 10);
301
+ if (contentLength > MAX_UPLOAD) {
302
+ res.writeHead(413, { 'Content-Type': 'application/json' });
303
+ res.end(JSON.stringify({ error: 'File too large', code: 'TOO_LARGE' }));
304
+ return;
261
305
  }
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');
306
+ let chunks = [];
307
+ let totalSize = 0;
308
+ let aborted = false;
309
+ // A large remote upload can reset mid-flight; without an 'error' listener the
310
+ // socket error would crash the process (unhandled 'error' event).
311
+ req.on('error', () => { aborted = true; chunks = []; });
312
+ req.on('data', (chunk) => {
313
+ if (aborted) return;
314
+ totalSize += chunk.length;
315
+ if (totalSize > MAX_UPLOAD) {
316
+ aborted = true;
317
+ res.writeHead(413, { 'Content-Type': 'application/json' });
318
+ res.end(JSON.stringify({ error: 'File too large', code: 'TOO_LARGE' }));
319
+ req.destroy();
294
320
  return;
295
321
  }
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;
322
+ chunks.push(chunk);
323
+ });
324
+ req.on('end', async () => {
325
+ if (aborted) return;
326
+ let tmpDir = null;
327
+ try {
328
+ tmpDir = mkdtempSync(join(tmpdir(), 'ccv-upload-'));
329
+ const buf = Buffer.concat(chunks);
330
+ chunks = []; // free the per-chunk copies; adm-zip holds `buf` from here
331
+ const sessionDir = extractV2Zip(buf, tmpDir);
332
+ res.writeHead(200, {
333
+ 'Content-Type': 'application/octet-stream',
334
+ 'Transfer-Encoding': 'chunked',
335
+ });
336
+ await streamRawEntriesAsync(sessionDir, (raw) => {
337
+ res.write(raw);
338
+ res.write('\n---\n');
339
+ });
340
+ res.end();
341
+ } catch (err) {
342
+ const status = err?.status || 500;
343
+ if (status >= 500) console.error('[api/upload-log-zip]', err);
344
+ if (!res.headersSent) {
345
+ const safeError = status >= 500 ? 'server_error' : (err?.message || 'error');
346
+ res.writeHead(status, { 'Content-Type': 'application/json' });
347
+ res.end(JSON.stringify({ error: safeError, code: err?.code || 'unknown' }));
348
+ } else {
349
+ res.end();
350
+ }
351
+ } finally {
352
+ // Always reclaim the temp dir. The adapter has finished all reads by the
353
+ // time the await settles (cold v2 reads are bounded), so this is race-free
354
+ // even when the client disconnected mid-stream.
355
+ if (tmpDir) { try { rmSync(tmpDir, { recursive: true, force: true }); } catch {} }
301
356
  }
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
- }
357
+ });
316
358
  }
317
359
 
318
360
  export const logsRoutes = [
319
361
  { method: 'GET', match: 'exact', path: '/api/local-logs', handler: localLogs },
362
+ { method: 'GET', match: 'exact', path: '/api/wire-v2-convert', handler: getWireV2Convert },
363
+ { method: 'POST', match: 'exact', path: '/api/wire-v2-convert', handler: postWireV2Convert },
320
364
  { method: 'GET', match: 'exact', path: '/api/download-log', handler: downloadLog },
321
365
  { method: 'GET', match: 'exact', path: '/api/local-log', handler: localLog },
322
366
  { 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 },
367
+ { method: 'POST', match: 'exact', path: '/api/upload-log-zip', handler: uploadLogZip },
326
368
  ];
@@ -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
  });
@@ -1,8 +1,19 @@
1
1
  // Workspace routes (moved verbatim from server.js handleRequest).
2
2
  import { existsSync, statSync } from 'node:fs';
3
3
  import { basename } from 'node:path';
4
- import { LOG_FILE, initForWorkspace, resetWorkspace } from '../interceptor.js';
5
- import { watchLogFile, unwatchAll } from '../lib/log-watcher.js';
4
+ import { initForWorkspace, resetWorkspace, getLiveLogSource, markContinuedLaunch, markForkSession, markResumeSession, isContinuedLaunch } from '../interceptor.js';
5
+ import { LOG_DIR } from '../../findcc.js';
6
+ import { migrationStatus } from '../lib/v2/migrate-prompt.js';
7
+ import { reportSwallowed } from '../lib/error-report.js';
8
+
9
+ // P2: claude continuation flags — the workspace launcher injects `-c` itself
10
+ // (WorkspaceList's logCount heuristic), so argv scanning in cli.js never sees
11
+ // it; this is detection channel ② of interceptor.isContinuedLaunch().
12
+ const CONTINUE_FLAGS = new Set(['-c', '--continue', '-r', '--resume']);
13
+ // Explicit resume flags — continued for the migrate prompt, but `-c` folder
14
+ // adoption must NOT fire (it targets the latest main session, not the user's
15
+ // chosen one). Mirrors cli.js's CCV_CLAUDE_RESUME channel.
16
+ const RESUME_FLAGS = new Set(['-r', '--resume']);
6
17
  import { unwatchAllWorkflows } from '../lib/workflow-watcher.js';
7
18
  import { readClaudeProjectModel } from '../lib/context-watcher.js';
8
19
  import { countLogEntries, streamRawEntriesAsync } from '../lib/log-stream.js';
@@ -49,18 +60,28 @@ function workspacesLaunch(req, res, parsedUrl, isLocal, deps) {
49
60
  const result = initForWorkspace(wsPath);
50
61
  process.env.CCV_PROJECT_DIR = wsPath;
51
62
 
52
- // 启动日志监听
53
- watchLogFile(deps.logWatcherOpts(LOG_FILE));
63
+ // 启动日志监听(S6b: v2 live feed when the v2 writer is active)
64
+ deps.startLogWatch();
54
65
 
55
66
  // 启动 stats worker(如果尚未启动)
56
67
  if (!deps.statsWorker) deps.startStatsWorker();
57
68
  deps.startStreamingStatusTimer();
58
69
 
70
+ // P2 检测通道②:workspace 启动器注入的 -c/--continue/-r/--resume(无论
71
+ // 是否走 PTY 分支都要打标——迁移提示的 continued 语义只取决于参数本身)。
72
+ const mergedArgs = [...deps.workspaceClaudeArgs, ...(Array.isArray(launchExtraArgs) ? launchExtraArgs : [])];
73
+ if (mergedArgs.some((a) => CONTINUE_FLAGS.has(a))) markContinuedLaunch();
74
+ // `--fork-session`: a continuation that mints a NEW session id on purpose —
75
+ // `-c` folder adoption must NOT fire for it.
76
+ if (mergedArgs.includes('--fork-session')) markForkSession();
77
+ // `-r`/`--resume`: user-chosen target session — adoption must not redirect
78
+ // it to the latest main session.
79
+ if (mergedArgs.some((a) => RESUME_FLAGS.has(a))) markResumeSession();
80
+
59
81
  // 启动 PTY
60
82
  const proxyPort = process.env.CCV_PROXY_PORT;
61
83
  if (proxyPort) {
62
84
  const { spawnClaude } = await import('../pty-manager.js');
63
- const mergedArgs = [...deps.workspaceClaudeArgs, ...(Array.isArray(launchExtraArgs) ? launchExtraArgs : [])];
64
85
  await spawnClaude(parseInt(proxyPort), wsPath, mergedArgs, deps.workspaceClaudePath, deps.workspaceIsNpmVersion, deps.actualPort, deps.protocol, deps.INTERNAL_TOKEN);
65
86
  }
66
87
 
@@ -75,11 +96,15 @@ function workspacesLaunch(req, res, parsedUrl, isLocal, deps) {
75
96
  });
76
97
 
77
98
  // 流式分段广播以刷新会话区域,避免全量加载 OOM
78
- const wsReloadTotal = await countLogEntries(LOG_FILE);
99
+ // S6b: the live source is the v2 session dir when the v2 writer is
100
+ // active (a fresh workspace has no session yet → empty stream, the live
101
+ // feed picks up from the first request).
102
+ const wsReloadSource = getLiveLogSource();
103
+ const wsReloadTotal = await countLogEntries(wsReloadSource);
79
104
  deps.clients.forEach(client => {
80
105
  try { client.write(`event: load_start\ndata: ${JSON.stringify({ total: wsReloadTotal, incremental: false })}\n\n`); } catch {}
81
106
  });
82
- await streamRawEntriesAsync(LOG_FILE, (raw) => {
107
+ await streamRawEntriesAsync(wsReloadSource, (raw) => {
83
108
  deps.clients.forEach(client => {
84
109
  try { client.write('event: load_chunk\ndata: ['); client.write(raw.replace(/\n/g, '')); client.write(']\n\n'); } catch {}
85
110
  });
@@ -88,6 +113,16 @@ function workspacesLaunch(req, res, parsedUrl, isLocal, deps) {
88
113
  try { client.write(`event: load_end\ndata: {}\n\n`); } catch {}
89
114
  });
90
115
 
116
+ // 1.7.0 迁移引导(P2):切进的项目仍有未转换 v1 日志 → 对存量连接广播
117
+ // migrate_prompt(新连接由 /events 的连接帧覆盖)。
118
+ try {
119
+ const mig = migrationStatus(LOG_DIR, result.projectName || '');
120
+ if (mig.pending) {
121
+ const frame = `event: migrate_prompt\ndata: ${JSON.stringify({ ...mig, continued: isContinuedLaunch() })}\n\n`;
122
+ deps.clients.forEach(client => { try { client.write(frame); } catch {} });
123
+ }
124
+ } catch (e) { reportSwallowed('sse.migrate_prompt', e); }
125
+
91
126
  res.writeHead(200, { 'Content-Type': 'application/json' });
92
127
  res.end(JSON.stringify({ ok: true, projectName: result.projectName }));
93
128
  } catch (err) {
@@ -139,8 +174,8 @@ function workspacesStop(req, res, parsedUrl, isLocal, deps) {
139
174
  ]).then(() => {
140
175
  // 接续原有清理流程
141
176
 
142
- // 停止日志监听
143
- unwatchAll();
177
+ // 停止日志监听(v1 tail + v2 live feed)
178
+ deps.stopLogWatch();
144
179
  unwatchAllWorkflows();
145
180
 
146
181
  // 重置 interceptor 状态