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
@@ -7,16 +7,18 @@ const _ccvSkipArgs = ['--version', '-v', '--v', '--help', '-h', 'doctor', 'insta
7
7
  const _ccvSkip = _ccvSkipArgs.includes(process.argv[2]);
8
8
 
9
9
  import './lib/proxy-env.js';
10
- import { appendFileSync, mkdirSync, readFileSync, writeFileSync, statSync, unlinkSync, existsSync, watchFile, openSync, readSync, closeSync } from 'node:fs';
11
- import { AsyncWriteQueue } from './lib/async-write-queue.js';
12
- import { renameSyncWithRetry } from './lib/file-api.js';
10
+ import { mkdirSync, readFileSync, writeFileSync, existsSync, watchFile } from 'node:fs';
13
11
  import http from 'node:http';
14
12
  import https from 'node:https';
15
13
  import { homedir } from 'node:os';
16
14
  import { fileURLToPath, pathToFileURL } from 'node:url';
17
15
  import { dirname, join, basename } from 'node:path';
18
16
  import { LOG_DIR } from '../findcc.js';
19
- import { assembleStreamMessage, createStreamAssembler, cleanupTempFiles, findRecentLog, claimUntaggedLog, logFilePrefix, isAnthropicApiPath, isMainAgentRequest, rotateLogFile, fingerprintMsg, replaceTopLevelModel, injectOutputConfigEffort, resolveProfileModel, extractAgentSpawnPairs, parseRotationContextHead } from './lib/interceptor-core.js';
17
+ import { assembleStreamMessage, createStreamAssembler, isAnthropicApiPath, isMainAgentRequest, replaceTopLevelModel, injectOutputConfigEffort, resolveProfileModel, extractAgentSpawnPairs } from './lib/interceptor-core.js';
18
+ import { V2Writer } from './lib/v2/v2-writer.js';
19
+ import { reportSwallowed } from './lib/error-report.js';
20
+ import { latestMainSessionDir, sessionHasCompletedMainTurn } from './lib/v2/session-select.js';
21
+ import { sanitizePathComponent } from './lib/v2/layout.js';
20
22
 
21
23
 
22
24
 
@@ -200,126 +202,27 @@ function _replaceProxyAuthHeaders(headers, apiKey) {
200
202
 
201
203
  export { _activeProfile, _defaultConfig, _loadProxyProfile, PROFILE_PATH, setActiveProfileForWorkspace, getActiveProfileId };
202
204
 
203
- // 生成新的日志文件路径
204
- function generateNewLogFilePath() {
205
- const now = new Date();
206
- const ts = now.getFullYear().toString()
207
- + String(now.getMonth() + 1).padStart(2, '0')
208
- + String(now.getDate()).padStart(2, '0')
209
- + '_'
210
- + String(now.getHours()).padStart(2, '0')
211
- + String(now.getMinutes()).padStart(2, '0')
212
- + String(now.getSeconds()).padStart(2, '0');
205
+ // 1.7.0: the v1 single-file write path is retired — logs live in per-session
206
+ // v2 dirs owned by V2Writer. Only the project binding (name + dir) remains
207
+ // here; there is no log FILE to create, resume, rotate or delta-encode.
208
+ function resolveProjectBinding() {
213
209
  let cwd;
214
210
  try { cwd = process.cwd(); } catch { cwd = homedir(); }
215
211
  const projectName = basename(cwd).replace(/[^a-zA-Z0-9_\-\.]/g, '_');
216
212
  const dir = join(LOG_DIR, projectName);
217
213
  try { mkdirSync(dir, { recursive: true }); } catch { }
218
- // `--pid`(CCV_INSTANCE_ID) 实例:文件名前缀 `<pid>__`,让每个实例只读/续自己的日志血脉(多进程隔离)。
219
- // 内部读 env(与上面内部读 cwd 同理)→ 轮转/workspace 等所有 caller 自动带前缀,无需逐处传参。
220
- // 前缀走单一来源 logFilePrefix(与 matcher 共用,防漂移)。
221
- const instanceId = process.env.CCV_INSTANCE_ID || '';
222
- return { filePath: join(dir, `${logFilePrefix(projectName, instanceId)}${ts}.jsonl`), dir, projectName };
214
+ return { dir, projectName };
223
215
  }
224
216
 
225
- // Resume 状态(供 server.js 使用)
226
- let _resumeState = null;
227
- let _resolveChoice = null;
228
- const _choicePromise = new Promise(resolve => { _resolveChoice = resolve; });
229
-
230
- function resolveResumeChoice(choice) {
231
- if (!_resumeState) return;
232
- const { recentFile, tempFile } = _resumeState;
233
- try {
234
- if (choice === 'continue') {
235
- // 将临时文件内容追加到旧日志
236
- if (existsSync(tempFile)) {
237
- const tempContent = readFileSync(tempFile, 'utf-8');
238
- if (tempContent.trim()) {
239
- appendFileSync(recentFile, tempContent);
240
- }
241
- unlinkSync(tempFile);
242
- }
243
- LOG_FILE = recentFile;
244
- seedSpawnRegistryFromLogHead(LOG_FILE);
245
- } else {
246
- // new: 将临时文件 rename 为正式新日志文件名(空文件直接删除)
247
- const newPath = tempFile.replace('_temp.jsonl', '.jsonl');
248
- if (existsSync(tempFile)) {
249
- const sz = statSync(tempFile).size;
250
- if (sz > 0) {
251
- renameSyncWithRetry(tempFile, newPath);
252
- } else {
253
- try { unlinkSync(tempFile); } catch { }
254
- }
255
- }
256
- LOG_FILE = newPath;
257
- }
258
- } catch (err) {
259
- console.error('[CC Viewer] resolveResumeChoice error:', err);
260
- }
261
- const result = { logFile: LOG_FILE };
262
- _resumeState = null;
263
- _resolveChoice(result);
264
- return result;
265
- }
266
-
267
- // Delta storage: 增量存储开关和状态(默认开启,设置 CCV_DISABLE_DELTA=1 关闭)
268
- // 注意:delta 计算原本依赖 mainAgent 请求串行假设。实证发现 teammate 终止 / 多 SSE 通道
269
- // 注入等情况会让两条 mainAgent 请求 30ms 内连续到达(前一条流式响应未完成时后一条已发起),
270
- // 导致仅在 completed 时更新 _lastMessagesCount/_lastTailFp 出现状态滞后 → Plan C 漏检。
271
- const _deltaStorageEnabled = process.env.CCV_DISABLE_DELTA !== '1';
272
- // In-place last-msg replace 检测开关(默认开启,设置 CCV_DISABLE_TAIL_FP_CHECKPOINT=1 关闭)。
273
- // 关闭后回退到旧行为(仅按长度算 delta,遇到末位原地替换会丢失"末位换内容"信息)。
274
- const _tailFpCheckEnabled = process.env.CCV_DISABLE_TAIL_FP_CHECKPOINT !== '1';
275
- // 这两个变量代表"截至本次请求开始前的最新已知态"。请求开始处理时即同步更新(eager),
276
- // 不再等到 _commitDeltaState(completed 时执行)。Plan C 检测使用进入函数前的快照。
277
- // 异常分支(请求失败 / 服务端不发送)不会回滚——下一个成功请求覆盖即可,状态不会永久错位。
278
- // 命名说明:变量名保留 `_last` 前缀(历史命名),但语义已由"上次 commit 后"变为"上次见到的最新态"。
279
- // 三路并发场景下,连续 3 个请求若 length 非单调(如 257→259→258),_lastMessagesCount 会跟随
280
- // 最新一次 startRequest,可能让早到的更大值被覆盖;这种情况 Plan C 走 length 不等支路最终
281
- // 命中 needsCheckpoint 写完整快照,client 拿到正确数据,不破坏正确性。
282
- let _lastMessagesCount = 0; // 截至最近一次 startRequest 的完整 messages 数量(eager-updated)
283
- let _lastTailFp = ''; // 截至最近一次 startRequest 的末位 message 指纹(eager-updated)
284
- let _mainAgentDeltaCount = 0; // mainAgent 请求计数器(用于触发定期 checkpoint)
285
- const CHECKPOINT_INTERVAL = 10; // 每 N 条 mainAgent 请求写一个 checkpoint
286
-
287
- // 完成序倒置守卫(KEEP IN SYNC: server/lib/delta-reconstructor.js + docs/WIRE_FORMAT.md §3.7):
288
- // entry 形态在请求发起时冻结,但 completed entry 按响应完成顺序落盘(AsyncWriteQueue FIFO)。
289
- // burst 下慢请求的条目会落在快请求之后,文件序 ≠ 请求序,重建器按文件序拼接会翻倍。
290
- // `_seq` 记录请求发起序(语义序),`_seqEpoch` 标识写进程(重启 / 多进程混写时 seq 不可比,
291
- // 重建器据 epoch 切换基线而不是误判乱序)。teammate 子进程不参与(其条目不进 mainAgent 重建)。
292
- let _seqCounter = 0;
293
- // 时间戳 + 6 位随机尾:随机尾用于区分同毫秒启动的第二写进程(IM worker 场景)
294
- const _seqEpoch = `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
295
-
296
- /**
297
- * Delta storage: completed 写入成功后更新状态。
298
- *
299
- * 幂等守卫(`originalLength > _lastMessagesCount`):eager-update 已在请求开始时把
300
- * _lastMessagesCount/_lastTailFp 推到本次值;本函数对同 originalLength 的 commit no-op,
301
- * 且必须严格大于才更新——防止两条 mainAgent 请求乱序完成时,先到的较短 commit 把
302
- * 已被 eager 推高的状态倒推回去(A 流式数秒、B 短先 commit、A 后 commit 时 _lastMessagesCount
303
- * 与 _lastTailFp 都会被 A.length/fp 覆盖,下条 C 拿陈旧 prev → Plan C 漏/误检 → doubled-history
304
- * 残余)。等长情况下也不动 fp:等长 in-place replace 的 _lastTailFp 已被 eager 推到 latest 值,
305
- * commit 倒推会让下条请求的 Plan C 误判 in-place。
306
- *
307
- * 作用域纠正(不是真的兜底):本函数与 eager 块共享 "可写入" 前置条件——caller 传
308
- * `_deltaOriginalMessagesLength`,该值只有在 `_deltaStorageEnabled && mainAgent &&
309
- * Array.isArray(messages) && messages.length>0` 时才非 0;其它分支传 0 进来这里就
310
- * short-circuit。即 `_deltaStorageEnabled=false / messages 非数组` 等 eager 跳过的分支
311
- * 本函数同样跳过,不能视作异常路径的兜底。保留本函数是为了首次启动 / 定期 checkpoint
312
- * 后的快路径回写(与 eager 等价但解耦 commit 时序),让未来 eager 块若重构调用顺序时
313
- * commit 仍能把状态推上去。请求失败 / 服务端不发送时永远不调本函数;状态由 eager 残留,
314
- * 下个成功请求覆盖即可,不会永久错位。
315
- */
316
- function _commitDeltaState(originalLength, originalTailFp) {
317
- if (_deltaStorageEnabled && originalLength > 0 && originalLength > _lastMessagesCount) {
318
- _lastMessagesCount = originalLength;
319
- if (typeof originalTailFp === 'string') {
320
- _lastTailFp = originalTailFp;
321
- }
322
- }
217
+ // The single completion-write helper shared by all five completion/error
218
+ // paths (streaming happy/assemble-fail, stream-setup catch, non-streaming
219
+ // happy, read-error). V2Writer catches + reportSwallowed internally; the outer
220
+ // catch is belt-and-braces so a broken guard can never crash the fetch hook —
221
+ // but a throw that ESCAPED the writer's own guards is a dropped log entry,
222
+ // which must never be silent (CLAUDE.md swallowed-catch rule).
223
+ function _writeCompletedEntry(requestEntry, v2Handle) {
224
+ try { _v2Writer.ingestCompletion(v2Handle, requestEntry); }
225
+ catch (err) { reportSwallowed('interceptor.ingest-completion', err); }
323
226
  }
324
227
 
325
228
  // Teammate 子进程检测:--parent-session-id(旧模式)或 --agent-name(原生 team 模式)
@@ -327,160 +230,204 @@ const _isTeammate = process.argv.includes('--parent-session-id') || process.argv
327
230
  // 提取 teammate 元数据(--agent-name worker-1 --team-name fix-ts-errors)
328
231
  let _teammateName = null;
329
232
  let _teamName = null;
233
+ let _parentSessionId = null;
330
234
  {
331
235
  const args = process.argv;
332
236
  const nameIdx = args.indexOf('--agent-name');
333
237
  if (nameIdx !== -1 && nameIdx + 1 < args.length) _teammateName = args[nameIdx + 1];
334
238
  const teamIdx = args.indexOf('--team-name');
335
239
  if (teamIdx !== -1 && teamIdx + 1 < args.length) _teamName = args[teamIdx + 1];
240
+ const parentIdx = args.indexOf('--parent-session-id');
241
+ if (parentIdx !== -1 && parentIdx + 1 < args.length) _parentSessionId = args[parentIdx + 1];
336
242
  }
337
243
 
338
- // `--pid` 实例 id(cli.js 在 import server 前已设 env,时序安全)。null = 默认模式(不分实例)。
339
- // 只用于日志文件名的分实例隔离(findRecentLog/cleanupTempFiles/claim);不影响 claude 自己的 -c。
340
- const INSTANCE_ID = process.env.CCV_INSTANCE_ID || null;
341
-
342
- // 初始化日志文件路径(异步,支持用户交互)
343
- // 工作区模式下延迟到选择工作区后再初始化
344
- let _newLogFile, _logDir, _projectName;
244
+ // 项目绑定初始化。工作区模式下延迟到选择工作区后再初始化。
245
+ let _logDir, _projectName;
345
246
  if (process.env.CCV_WORKSPACE_MODE === '1') {
346
- _newLogFile = '';
347
247
  _logDir = '';
348
248
  _projectName = '';
349
- } else if (_isTeammate) {
350
- // Teammate 子进程:只需 projectName 和 logDir 来查找 leader 日志,不生成新文件路径
351
- let cwd;
352
- try { cwd = process.cwd(); } catch { cwd = homedir(); }
353
- _projectName = basename(cwd).replace(/[^a-zA-Z0-9_\-\.]/g, '_');
354
- _logDir = join(LOG_DIR, _projectName);
355
- // teammate 子进程继承同一 env(含 CCV_INSTANCE_ID)→ 命中 leader 的 pid 日志;无 env 时为 null = 现状。
356
- const _leaderLog = findRecentLog(_logDir, _projectName, INSTANCE_ID);
357
- _newLogFile = _leaderLog || ''; // 没有 leader 日志时不写入
358
249
  } else {
359
- ({ filePath: _newLogFile, dir: _logDir, projectName: _projectName } = generateNewLogFilePath());
360
- // 启动时清理残留临时文件(按本实例收窄,避免多进程下 rename 掉别的实例正在写的 _temp)
361
- cleanupTempFiles(_logDir, _projectName, INSTANCE_ID);
250
+ ({ dir: _logDir, projectName: _projectName } = resolveProjectBinding());
251
+ }
252
+ // Deprecated (1.7.0): the v1 log file no longer exists. The always-empty
253
+ // export keeps legacy read fallbacks ("no file → empty stream") type-stable
254
+ // until the last consumers are deleted with the continuity machinery (P3).
255
+ const LOG_FILE = '';
256
+
257
+ // The v2 writer (docs/refactor/WIRE_FORMAT_V2.md §13) — the ONLY log store
258
+ // since 1.7.0. Every method is internally caught + reportSwallowed; project
259
+ // resolves through a getter because workspace mode rebinds _projectName at
260
+ // runtime. The disk-space guard inside is the single remaining write inhibitor.
261
+ const _v2Writer = new V2Writer({
262
+ logDir: LOG_DIR,
263
+ project: () => _projectName,
264
+ ...(_isTeammate && {
265
+ leader: {
266
+ ...(_teammateName && { agentName: _teammateName }),
267
+ ...(_teamName && { teamName: _teamName }),
268
+ ...(_parentSessionId && { parentSessionId: _parentSessionId }),
269
+ },
270
+ }),
271
+ });
272
+ export { _v2Writer };
273
+
274
+ // S6b: the live read source — the current v2 session dir once the writer has
275
+ // resolved a session; '' before the first request (readers treat a missing
276
+ // path as an empty stream, same as v1's not-yet-created file did).
277
+ //
278
+ // Task B (2026-07-15): the [对话] panel must not cold-load empty. Two cases
279
+ // produce an empty current session: (a) before the first request, currentSid is
280
+ // null; (b) a session's sid resolved but it has NO main turn yet (`-c` startup
281
+ // or a background sub/count_tokens request set _currentSid before the user sent
282
+ // anything) — cold-loading THAT renders blank (the refresh bug: fresh load
283
+ // shows data, a refresh once the empty current session exists shows nothing,
284
+ // filling only after the user sends a request). So we serve the current session
285
+ // ONLY when it has a main turn ("activated"); otherwise we fall back to the
286
+ // newest MAIN session that does. Selection is idempotent across refreshes.
287
+ //
288
+ // The live feed still follows the ACTUAL written dir (it never reads this
289
+ // function), so once the current session gets its first main turn, its entries
290
+ // form a newer session the frontend auto-switches to. Selection rides the same
291
+ // bounded cold-load window (DEFAULT_EVENTS_LIMIT) as any session — never
292
+ // limit=0 — so it stays memory-safe (S10). All three cold-load consumers
293
+ // (/events, /api/requests, workspace reload) route through here.
294
+ export function getLiveLogSource() {
295
+ const dir = _v2Writer.currentSessionDir();
296
+ // "Activated" requires a COMPLETED main turn, not merely a written main
297
+ // request line: a session with only an in-flight first request has nothing a
298
+ // cold load can render yet, so keep falling back to the previous conversation
299
+ // (which CAN render) until the current one has a done. Removes the blank flash
300
+ // between "first `-c`/fresh main request written" and "its response emitted".
301
+ if (dir && sessionHasCompletedMainTurn(dir)) return dir; // activated current session
302
+ if (!_projectName) return ''; // no project bound yet (mirrors v2-writer's guard)
303
+ try {
304
+ // excludeDir: the current session just failed the completed-turn gate, but
305
+ // the picker's weaker has-a-main-req gate would re-select it (it is the
306
+ // newest dir once its first main req is written) — handing back exactly the
307
+ // blank in-flight session the strict gate rejected. Excluding it makes the
308
+ // fallback actually land on the previous, renderable conversation.
309
+ const fallback = latestMainSessionDir(join(LOG_DIR, sanitizePathComponent(_projectName)), { excludeDir: dir });
310
+ // A current-but-empty session with nothing else on disk: '' (still empty,
311
+ // but the live feed fills it in place — no worse than before the fix).
312
+ return fallback || dir || '';
313
+ } catch (err) {
314
+ reportSwallowed('cold-load.fallback-select', err);
315
+ return dir || '';
316
+ }
362
317
  }
363
- let LOG_FILE = _newLogFile;
364
318
 
365
- // 异步写入队列 替代 appendFileSync,避免阻塞事件循环(Windows NTFS 尤为严重)
366
- const _writeQueue = new AsyncWriteQueue(() => LOG_FILE);
319
+ // P2 (-c migration guidance): three detection channels, any one marks this
320
+ // launch as "continuing an older conversation" — the migrate prompt then
321
+ // re-prompts even a dismissed user, because the conversation's first half
322
+ // lives in un-migrated v1 logs and only migration makes it viewable.
323
+ // ① cli.js scans claude argv and sets CCV_CLAUDE_CONTINUE before importing
324
+ // the server (its own -c pass-through);
325
+ // ② the workspace launcher injects -c server-side (WorkspaceList heuristic)
326
+ // and calls markContinuedLaunch();
327
+ // ③ wire-level fallback in V2Writer: a brand-new session whose FIRST main
328
+ // snapshot already contains assistant turns can only be a continuation.
329
+ let _continuedLaunchMarked = false;
330
+ let _forkLaunchMarked = false;
331
+ export function markContinuedLaunch() { _continuedLaunchMarked = true; _syncContinuationMode(); }
332
+ export function isContinuedLaunch() {
333
+ return process.env.CCV_CLAUDE_CONTINUE === '1'
334
+ || _continuedLaunchMarked
335
+ || _v2Writer.sawContinuedSession();
336
+ }
337
+ // `--fork-session` (resume/continue but mint a NEW session id) — the user
338
+ // explicitly wants a fresh session, so `-c` folder adoption must NOT fire.
339
+ // Detected two ways, mirroring the continuation channels: ① cli.js sets
340
+ // CCV_CLAUDE_FORK_SESSION from the claude argv; ② the workspace launcher calls
341
+ // markForkSession().
342
+ export function markForkSession() { _forkLaunchMarked = true; _syncContinuationMode(); }
343
+ export function isForkSession() {
344
+ return process.env.CCV_CLAUDE_FORK_SESSION === '1' || _forkLaunchMarked;
345
+ }
346
+ // Explicit `-r`/`--resume` — the user targets a session of THEIR choosing (an
347
+ // id or the interactive picker), while adoption always targets the LATEST main
348
+ // session; if Claude mints a fresh wire sid for the resume, adoption would
349
+ // misroute the resumed conversation into whatever session happens to be newest.
350
+ // So an explicit resume keeps the pre-adoption behavior (its own folder);
351
+ // adoption serves `-c`/`--continue` only. Same two channels as fork: ① cli.js
352
+ // sets CCV_CLAUDE_RESUME from the claude argv; ② the workspace launcher calls
353
+ // markResumeSession(). The launch still counts as "continued" for the migrate
354
+ // prompt (isContinuedLaunch is unchanged).
355
+ let _resumeLaunchMarked = false;
356
+ export function markResumeSession() { _resumeLaunchMarked = true; _syncContinuationMode(); }
357
+ export function isResumeSession() {
358
+ return process.env.CCV_CLAUDE_RESUME === '1' || _resumeLaunchMarked;
359
+ }
360
+ // Push the launch's continuation/fork/resume intent into the writer so `-c`
361
+ // folder adoption can decide before the first request. Uses ONLY the
362
+ // pre-request continuation signals (env / workspace marker) — never the
363
+ // wire-level sawContinuedSession, which is known too late to avoid minting the
364
+ // folder.
365
+ function _syncContinuationMode() {
366
+ _v2Writer.setContinuationMode({
367
+ continued: process.env.CCV_CLAUDE_CONTINUE === '1' || _continuedLaunchMarked,
368
+ fork: isForkSession(),
369
+ resume: isResumeSession(),
370
+ });
371
+ }
372
+ _syncContinuationMode(); // seed from the CLI env at module load (`ccv -c`)
367
373
 
368
374
  // 现在 _projectName/_logDir 已初始化,可以安全加载 proxy profile(含 workspace override)
369
375
  // 并挂载 watchFile 同步列表变化。
370
376
  _loadProxyProfile();
371
377
  try { watchFile(PROFILE_PATH, { interval: 1500 }, _loadProxyProfile); } catch { }
372
378
 
373
- const _initPromise = (async () => {
374
- if (!_logDir || !_projectName) return; // 工作区模式下跳过
375
- if (_isTeammate) return; // Teammate 已在上方同步初始化,跳过 async resume 流程
376
- try {
377
- let recentLog = findRecentLog(_logDir, _projectName, INSTANCE_ID);
378
- // 首启接管(仅 --pid 实例):本 pid 还没有自己的日志时,可接管项目里最近的【无标签】日志
379
- // (原子 rename 成 `<pid>__…`),从此并入该 pid 血脉。claim 必须在 build _resumeState 之前,
380
- // 这样 _resumeState.recentFile 指向 rename 后的新路径(否则会指向已被移走的旧名)。
381
- if (!recentLog && INSTANCE_ID) {
382
- const claimed = claimUntaggedLog(_logDir, _projectName, INSTANCE_ID);
383
- if (claimed) recentLog = claimed;
384
- }
385
- if (recentLog) {
386
- // IM worker:无人值守、无 UI 可应答 resume 交互。直接 continue 最近会话日志(保留记忆、
387
- // 让记录弹窗读到同一份持续增长的文件),不进入 resume 交互状态(否则会一直写 *_temp.jsonl,
388
- // 仅在干净退出时才 rename,SIGKILL 下丢失)。
389
- if (process.env.CCV_IM_PLATFORM) {
390
- LOG_FILE = recentLog;
391
- seedSpawnRegistryFromLogHead(LOG_FILE);
392
- return;
393
- }
394
- // Leader / 普通进程:走 resume 交互流程
395
- const tempFile = _newLogFile.replace('.jsonl', '_temp.jsonl');
396
- LOG_FILE = tempFile;
397
- _resumeState = {
398
- recentFile: recentLog,
399
- recentFileName: basename(recentLog),
400
- tempFile,
401
- };
402
- }
403
- } catch { }
404
- })();
379
+ // Kept as an awaited boot barrier for callers; nothing asynchronous remains
380
+ // since the v1 resume flow retired (v2 sessions key off wire session_ids).
381
+ const _initPromise = Promise.resolve();
405
382
 
406
- export { LOG_FILE, _initPromise, _resumeState, _choicePromise, resolveResumeChoice, _projectName, _logDir };
383
+ export { LOG_FILE, _initPromise, _projectName, _logDir };
407
384
 
408
- // 工作区模式:动态初始化指定路径的日志文件
409
- // 如果有 1 小时内的最近日志,自动复用(与单目录模式行为一致)
410
- export function initForWorkspace(projectPath, { forceNew = false } = {}) {
385
+ // 工作区模式:绑定指定路径的项目(v2 会话目录由 V2Writer 按需创建)。
386
+ // forceNew 仅存于签名兼容(Electron multi-tab 传入):v2 下每个 claude 进程
387
+ // 天然是新 session,无旧文件可复用。
388
+ export function initForWorkspace(projectPath, { forceNew = false } = {}) { // eslint-disable-line no-unused-vars
411
389
  const projectName = basename(projectPath).replace(/[^a-zA-Z0-9_\-\.]/g, '_');
412
390
  const dir = join(LOG_DIR, projectName);
413
391
  try { mkdirSync(dir, { recursive: true }); } catch {}
414
392
 
415
- cleanupTempFiles(dir, projectName, INSTANCE_ID);
416
-
417
- // 检查是否有最近的日志文件可以复用(始终复用最新日志)
418
- // forceNew: Electron multi-tab 模式下强制创建新文件,避免与已有 ccv 实例共享日志
419
- let recentLog = !forceNew && findRecentLog(dir, projectName, INSTANCE_ID);
420
- // 首启接管(与单项目模式 _initPromise 一致):本 pid 在该 workspace 还没有自己的日志时,
421
- // 接管最近的无标签日志,避免 workspace 模式下 --pid 首启不接管的不一致。forceNew 时显式不接管。
422
- if (!recentLog && INSTANCE_ID && !forceNew) {
423
- const claimed = claimUntaggedLog(dir, projectName, INSTANCE_ID);
424
- if (claimed) recentLog = claimed;
425
- }
426
- if (recentLog) {
427
- _projectName = projectName;
428
- _logDir = dir;
429
- LOG_FILE = recentLog;
430
- // Same lifecycle as the single-project boot paths: start this workspace's
431
- // registry fresh, then re-seed from the resumed segment's head sentinel so
432
- // the next rotation's carry-forward stays complete after a restart/switch.
433
- _agentSpawnRegistry.clear();
434
- seedSpawnRegistryFromLogHead(LOG_FILE);
435
- // workspace 切换后,重读该 workspace 的 active-profile.json(可能和上一个 workspace 不同)
436
- _loadProxyProfile();
437
- return { filePath: recentLog, dir, projectName, resumed: true };
438
- }
439
-
440
- // 没有最近日志,创建新文件
441
- const now = new Date();
442
- const ts = now.getFullYear().toString()
443
- + String(now.getMonth() + 1).padStart(2, '0')
444
- + String(now.getDate()).padStart(2, '0')
445
- + '_'
446
- + String(now.getHours()).padStart(2, '0')
447
- + String(now.getMinutes()).padStart(2, '0')
448
- + String(now.getSeconds()).padStart(2, '0');
449
-
450
- // 与 generateNewLogFilePath 同款命名(共用 logFilePrefix):`--pid` 实例带 `<pid>__` 前缀。
451
- const filePath = join(dir, `${logFilePrefix(projectName, INSTANCE_ID)}${ts}.jsonl`);
452
-
453
393
  _projectName = projectName;
454
394
  _logDir = dir;
455
- LOG_FILE = filePath;
456
- // Fresh file for a fresh workspace context — no carried names apply.
395
+ // Fresh workspace context — no carried names apply; future requests must
396
+ // create session dirs under the NEW project.
457
397
  _agentSpawnRegistry.clear();
458
- _loadProxyProfile(); // 同上
459
-
460
- return { filePath, dir, projectName, resumed: false };
398
+ _v2Writer.resetSessions(); // also clears the per-process `-c` adoption latch
399
+ // A `--fork-session` / `-r` launch marks fork/resume intent; neither must
400
+ // leak into a LATER workspace's `-c` (in a long-lived server that would
401
+ // permanently suppress adoption). Clear both marks per launch and re-sync —
402
+ // the workspace launcher re-marks right after if THIS launch is itself a
403
+ // fork/resume. (`_continuedLaunchMarked` stays sticky on purpose: the migrate
404
+ // prompt relies on it, and a stale continued flag is harmless — adoption also
405
+ // requires the wire to actually replay assistant history.)
406
+ _forkLaunchMarked = false;
407
+ _resumeLaunchMarked = false;
408
+ _syncContinuationMode();
409
+ _loadProxyProfile(); // 重读该 workspace 的 active-profile.json
410
+
411
+ return { filePath: '', dir, projectName, resumed: false };
461
412
  }
462
413
 
463
414
  // 工作区模式:重置日志状态(返回工作区列表时调用)
464
415
  export function resetWorkspace() {
465
416
  _projectName = '';
466
417
  _logDir = '';
467
- LOG_FILE = '';
468
418
  // Workspace context gone: drop carried teammate names — the registry is
469
419
  // module-global, and without this a rotation in the NEXT workspace would
470
420
  // write a sentinel carrying THIS workspace's names (unbounded growth +
471
421
  // cross-workspace leakage into the route's teammateNames snapshot).
472
422
  _agentSpawnRegistry.clear();
423
+ _v2Writer.resetSessions(); // wire-v2: empty project → ingest no-ops until re-init
473
424
  _loadProxyProfile(); // workspace 上下文消失,回落到 profile.json.active
474
425
  }
475
426
 
476
- // Windows NTFS + Defender 下大文件 I/O 代价远高于 Mac/Linux,降低分割阈值减轻压力
477
- const MAX_LOG_SIZE = (process.platform === 'win32' ? 150 : 300) * 1024 * 1024;
478
-
479
427
  // Agent-spawn registry: prompt-prefix(60) → teammate name, accumulated from
480
- // mainAgent responses as they are written. Carried into the next segment via
481
- // the rotation-context sentinel so post-split viewers can still resolve
482
- // teammate names whose spawn turn lives in the previous file. The pure
483
- // extraction/parsing lives in interceptor-core.js (unit-tested there).
428
+ // mainAgent responses. v2 identity keying (ConvResolver) is the surviving
429
+ // consumer; the v1 rotation-sentinel carry retired with rotation itself. The
430
+ // pure extraction/parsing lives in interceptor-core.js (unit-tested there).
484
431
  const _agentSpawnRegistry = new Map();
485
432
 
486
433
  function collectAgentSpawns(entry) {
@@ -492,66 +439,6 @@ function collectAgentSpawns(entry) {
492
439
  } catch { }
493
440
  }
494
441
 
495
- // Boot re-seed: when resuming an existing log whose FIRST frame is a
496
- // rotation-context sentinel, load its carried names so a restarted leader
497
- // still writes a complete sentinel at the next rotation. Bounded read of the
498
- // file head only — never a whole-segment scan (segments reach 300MB).
499
- function seedSpawnRegistryFromLogHead(file) {
500
- try {
501
- if (!file || !existsSync(file)) return;
502
- const fd = openSync(file, 'r');
503
- let head;
504
- try {
505
- const buf = Buffer.alloc(64 * 1024);
506
- const n = readSync(fd, buf, 0, buf.length, 0);
507
- head = buf.toString('utf-8', 0, n);
508
- } finally {
509
- closeSync(fd);
510
- }
511
- const entry = parseRotationContextHead(head);
512
- if (!entry || !Array.isArray(entry.teammateNames)) return;
513
- for (const pair of entry.teammateNames) {
514
- if (Array.isArray(pair) && pair[0] && pair[1]) _agentSpawnRegistry.set(pair[0], pair[1]);
515
- }
516
- } catch { }
517
- }
518
-
519
- async function checkAndRotateLogFile() {
520
- // Teammate 不做日志轮转,由 leader 负责
521
- if (_isTeammate) return;
522
- try {
523
- if (!existsSync(LOG_FILE) || statSync(LOG_FILE).size < MAX_LOG_SIZE) return;
524
- } catch { return; }
525
- await _writeQueue.flush();
526
- const { filePath } = generateNewLogFilePath();
527
- // Sentinel is baked into the new file's CREATION (rotateLogFile initial
528
- // content): queueing it after the fact races the watcher's rotation-follow
529
- // and can leave it undelivered. Synthetic url gives it a stable dedup key.
530
- const sentinel = JSON.stringify({
531
- ccvRotationContext: 1,
532
- url: 'ccv://rotation-context',
533
- from: basename(LOG_FILE),
534
- teammateNames: [..._agentSpawnRegistry.entries()],
535
- timestamp: new Date().toISOString(),
536
- }) + '\n---\n';
537
- const result = rotateLogFile(LOG_FILE, filePath, MAX_LOG_SIZE, sentinel);
538
- if (result.rotated) {
539
- LOG_FILE = result.newFile;
540
- // 重置 delta 状态,强制下一条 mainAgent 请求写完整 checkpoint
541
- if (_deltaStorageEnabled) {
542
- _lastMessagesCount = 0;
543
- _lastTailFp = '';
544
- _mainAgentDeltaCount = 0;
545
- }
546
- }
547
- }
548
-
549
- // Exposed for the /api/prev-segment-teammates route (belt-and-braces seed
550
- // delivery when the in-band sentinel falls outside the client's load window).
551
- export function getAgentSpawnRegistrySnapshot() {
552
- return [..._agentSpawnRegistry.entries()];
553
- }
554
-
555
442
  // 从环境变量 ANTHROPIC_BASE_URL 提取域名用于请求匹配
556
443
  function getBaseUrlHost() {
557
444
  try {
@@ -634,16 +521,25 @@ export function setupInterceptor() {
634
521
  }
635
522
  };
636
523
 
524
+ // The v2 queue drains on every exit path — close() falls back to synchronous
525
+ // drain, so a Ctrl-C cannot strand queued lines. The 2s race is insurance:
526
+ // a hung async appendFile (network FS, etc.) must never hold Ctrl-C hostage;
527
+ // the sync-drain fallback inside close() is blocking and cannot hang on the
528
+ // event loop.
529
+ const _closeQueuesBounded = () => Promise.race([
530
+ _v2Writer.close(),
531
+ new Promise(resolve => { const t = setTimeout(resolve, 2000); if (t.unref) t.unref(); }),
532
+ ]);
637
533
  process.on('SIGINT', () => {
638
- _writeQueue.close().then(() => cleanupViewer()).finally(() => process.exit(0));
534
+ _closeQueuesBounded().then(() => cleanupViewer()).finally(() => process.exit(0));
639
535
  });
640
536
 
641
537
  process.on('SIGTERM', () => {
642
- _writeQueue.close().then(() => cleanupViewer()).finally(() => process.exit(0));
538
+ _closeQueuesBounded().then(() => cleanupViewer()).finally(() => process.exit(0));
643
539
  });
644
540
 
645
541
  process.on('beforeExit', () => {
646
- _writeQueue.close().then(() => cleanupViewer());
542
+ _closeQueuesBounded().then(() => cleanupViewer());
647
543
  });
648
544
 
649
545
  const _originalFetch = globalThis.fetch;
@@ -753,9 +649,7 @@ export function setupInterceptor() {
753
649
  }
754
650
  } catch { }
755
651
 
756
- // 用户新指令边界:检查日志文件大小,超过 250MB 则切换新文件
757
652
  if (requestEntry?.mainAgent) {
758
- await checkAndRotateLogFile();
759
653
  // 仅 mainAgent 请求时缓存模型名,避免 SubAgent 覆盖
760
654
  if (requestEntry.body?.model && typeof requestEntry.body.model === 'string') {
761
655
  _cachedModel = requestEntry.body.model;
@@ -766,96 +660,10 @@ export function setupInterceptor() {
766
660
  }
767
661
  }
768
662
 
769
- // Delta storage:仅 mainAgent 且开关启用时,将 body.messages 转为增量格式
770
- let _deltaOriginalMessagesLength = 0; // 缓存本次请求的原始 messages 长度,用于 completed 后更新状态
771
- let _deltaOriginalTailFp = ''; // 缓存本次请求末位 message 的指纹,用于 completed 后更新 _lastTailFp
772
- if (_deltaStorageEnabled && requestEntry?.mainAgent && Array.isArray(requestEntry.body?.messages)) {
773
- const messages = requestEntry.body.messages;
774
- _deltaOriginalMessagesLength = messages.length;
775
- // 立即把末位 fp 算成字符串保存(不存对象引用),避免后续 mutation 风险
776
- _deltaOriginalTailFp = messages.length > 0 ? fingerprintMsg(messages[messages.length - 1]) : '';
777
- _mainAgentDeltaCount++;
778
-
779
- // 完成序倒置守卫:请求发起序号(必须与下方 Plan C eager 块同处一个同步段,中间不得插 await)
780
- if (!_isTeammate) {
781
- requestEntry._seq = ++_seqCounter;
782
- requestEntry._seqEpoch = _seqEpoch;
783
- }
784
-
785
- // 并发竞态修复(详见模块顶部注释 + history.md Unreleased 段 fix(interceptor) 条目):
786
- // snapshot 上一请求处理时的 count/fp 给 Plan C 用,然后 eager 把模块级状态推到本次值
787
- // (不等 _commitDeltaState)。BUG 来源:teammate 终止快速串行让 mainAgent 30ms 内连续
788
- // firing,旧 commit 时序使 Plan C 拿陈旧 prev 漏检 → client doubled-history。
789
- const _prevMessagesCount = _lastMessagesCount;
790
- const _prevTailFp = _lastTailFp;
791
- if (_deltaOriginalMessagesLength > 0) {
792
- _lastMessagesCount = _deltaOriginalMessagesLength;
793
- if (_deltaOriginalTailFp !== '') _lastTailFp = _deltaOriginalTailFp;
794
- }
795
-
796
- // In-place last-msg replace 检测:messages.length 不变但末位 fp 不同。
797
- // 触发场景:CLI 在 mainAgent 末位"原地替换"user msg(SUGGESTION MODE → 用户真实输入;
798
- // synthetic recap 通道注入;teammate 终止快速串行 → SUGGESTION MODE 多次替换;等),
799
- // wire 上长度未变内容变了。旧逻辑 messages.slice(_lastMessagesCount) 算出 delta=[],
800
- // 丢失了"末位换内容"信息 → 客户端重建拿到错误的"前态末位"。
801
- // 检测命中即强制写 checkpoint,让客户端拿到完整 wire 真实内容。
802
- const _sameLenInPlaceReplace =
803
- _tailFpCheckEnabled &&
804
- messages.length === _prevMessagesCount &&
805
- _prevMessagesCount > 0 &&
806
- _prevTailFp !== '' &&
807
- _deltaOriginalTailFp !== '' &&
808
- _deltaOriginalTailFp !== _prevTailFp;
809
-
810
- // 判断是否需要写 checkpoint
811
- const needsCheckpoint =
812
- _prevMessagesCount === 0 || // 进程重启 / 首次请求
813
- messages.length < _prevMessagesCount || // messages 缩短(/clear、context 压缩)
814
- (_mainAgentDeltaCount % CHECKPOINT_INTERVAL === 0) || // 定期 checkpoint
815
- _sameLenInPlaceReplace; // in-place last-msg replace 检测
816
-
817
- if (needsCheckpoint) {
818
- // checkpoint:保持完整 messages,标记 _isCheckpoint
819
- requestEntry._deltaFormat = 1;
820
- requestEntry._totalMessageCount = messages.length;
821
- requestEntry._conversationId = 'mainAgent';
822
- requestEntry._isCheckpoint = true;
823
- if (_sameLenInPlaceReplace) {
824
- // 诊断字段:标记此 checkpoint 是被 in-place replace 检测触发的(频率约 1-2%,
825
- // 用于在生产 jsonl 里事后核对触发率,不影响重建逻辑)。
826
- // 双方协议(KEEP IN SYNC: src/utils/sessionManager.js applyInPlaceLastMsgReplace):
827
- // 客户端 helper 看到此字段=true(与 _isCheckpoint:true 同时存在)时直接 in-place 替换
828
- // lastSession.messages 末位,跳过 sessionMerge prefix-overlap 算法(避开 doubled-history)。
829
- // 字段重命名 / 删除前需同步两端 + 重跑双向回归测试。
830
- requestEntry._inPlaceReplaceDetected = true;
831
- }
832
- } else {
833
- // delta:只保留新增的 messages(必须用 _prevMessagesCount,不是 eager 已更新的 _lastMessagesCount)
834
- const delta = messages.slice(_prevMessagesCount);
835
- requestEntry._deltaFormat = 1;
836
- requestEntry._totalMessageCount = messages.length;
837
- requestEntry._conversationId = 'mainAgent';
838
- requestEntry._isCheckpoint = false;
839
- requestEntry.body.messages = delta;
840
- }
841
- }
842
-
843
663
  // 生成唯一请求 ID,用于关联在途请求和完成请求
844
664
  const requestId = `${Date.now()}_${Math.random().toString(36).substring(2, 11)}`;
845
- if (requestEntry) {
846
- requestEntry.requestId = requestId;
847
- requestEntry.inProgress = true; // 标记为在途请求
848
- }
849
-
850
- // 在发起请求前先写入一条未完成的条目,让前端可以检测在途请求
851
- // 例外:live-streaming 场景下,placeholder 由 sendStreamChunk 通过 HTTP 即时投递,
852
- // 跳过磁盘预写可避免 log-watcher 500ms 后用空 placeholder 覆盖已显示的流式内容
853
- if (requestEntry) {
854
- const willLiveStream = !!_livePort && requestEntry.mainAgent && !_isTeammate;
855
- if (!willLiveStream) {
856
- _writeQueue.append(JSON.stringify(requestEntry) + '\n---\n');
857
- }
858
- }
665
+ let _v2Handle = null;
666
+ if (requestEntry) requestEntry.requestId = requestId;
859
667
 
860
668
  // 流式请求状态追踪(仅对 Claude API 流式请求)
861
669
  if (requestEntry?.isStream) {
@@ -954,6 +762,22 @@ export function setupInterceptor() {
954
762
  } catch { }
955
763
  }
956
764
 
765
+ if (requestEntry) {
766
+ // v2 req-phase ingest: journal seq is allocated inside, still in the
767
+ // fetch hook's synchronous segment (the proxy rewrite above is fully
768
+ // synchronous) — initiation order can never diverge from arrival order
769
+ // (§3.7 guard). Running AFTER the rewrite lets the journal req line
770
+ // capture proxyProfile/proxyUrl (they are assigned inside the rewrite;
771
+ // ingesting earlier silently dropped them — 1.8 review finding). The
772
+ // entry carries the pristine wire messages (the delta mutation retired
773
+ // with v1 write). Internally caught; the outer catch is belt-and-braces
774
+ // for a broken guard, same as _writeCompletedEntry — an escaping throw
775
+ // is a dropped entry and must be reported, never silent. The journal req
776
+ // line replaces the v1 in-flight placeholder pre-write.
777
+ try { _v2Handle = _v2Writer.ingestRequest(requestEntry, null); }
778
+ catch (err) { reportSwallowed('interceptor.ingest-request', err); }
779
+ }
780
+
957
781
  let response;
958
782
  try {
959
783
  response = await _originalFetch.call(this, _fetchUrl, _fetchOpts);
@@ -1016,7 +840,7 @@ export function setupInterceptor() {
1016
840
  body: { model: requestEntry.body?.model },
1017
841
  };
1018
842
  sendStreamChunk(chunkEntry, ++liveChunkSeq, (ok) => {
1019
- // 413 → 禁用当次流式,后续全由最终 appendFileSync 交付
843
+ // 413 → 禁用当次流式,后续全由最终 v2 completion 交付
1020
844
  if (!ok) liveStreamEnabled = false;
1021
845
  });
1022
846
  // 短延迟后清标志,允许下一次发送;若中途有新快照等待,立即再发
@@ -1088,11 +912,8 @@ export function setupInterceptor() {
1088
912
  collectAgentSpawns(requestEntry);
1089
913
 
1090
914
 
1091
- // 移除在途请求标记,保持原始报文
1092
- delete requestEntry.inProgress;
1093
- delete requestEntry.requestId;
1094
- { const _dl = _deltaOriginalMessagesLength, _tf = _deltaOriginalTailFp;
1095
- _writeQueue.append(JSON.stringify(requestEntry) + '\n---\n', () => _commitDeltaState(_dl, _tf)); }
915
+ // 移除在途请求标记,保持原始报文(seam 内先做 v2 completion ingest)
916
+ _writeCompletedEntry(requestEntry, _v2Handle);
1096
917
  // Release memory: clear large objects after disk write
1097
918
  streamedChunks = [];
1098
919
  streamedContentLen = 0;
@@ -1100,10 +921,7 @@ export function setupInterceptor() {
1100
921
  resetStreamingState();
1101
922
  } catch (err) {
1102
923
  requestEntry.response.body = fullContent.slice(0, 1000);
1103
- delete requestEntry.inProgress;
1104
- delete requestEntry.requestId;
1105
- { const _dl = _deltaOriginalMessagesLength, _tf = _deltaOriginalTailFp;
1106
- _writeQueue.append(JSON.stringify(requestEntry) + '\n---\n', () => _commitDeltaState(_dl, _tf)); }
924
+ _writeCompletedEntry(requestEntry, _v2Handle);
1107
925
  streamedChunks = [];
1108
926
  streamedContentLen = 0;
1109
927
  requestEntry.response = null;
@@ -1185,10 +1003,7 @@ export function setupInterceptor() {
1185
1003
  headers: Object.fromEntries(response.headers.entries()),
1186
1004
  body: '[Streaming Response - Capture failed]'
1187
1005
  };
1188
- delete requestEntry.inProgress;
1189
- delete requestEntry.requestId;
1190
- { const _dl = _deltaOriginalMessagesLength, _tf = _deltaOriginalTailFp;
1191
- _writeQueue.append(JSON.stringify(requestEntry) + '\n---\n', () => _commitDeltaState(_dl, _tf)); }
1006
+ _writeCompletedEntry(requestEntry, _v2Handle);
1192
1007
  resetStreamingState();
1193
1008
  }
1194
1009
  } else {
@@ -1213,16 +1028,9 @@ export function setupInterceptor() {
1213
1028
 
1214
1029
  collectAgentSpawns(requestEntry);
1215
1030
 
1216
- delete requestEntry.inProgress;
1217
- delete requestEntry.requestId;
1218
-
1219
- { const _dl = _deltaOriginalMessagesLength, _tf = _deltaOriginalTailFp;
1220
- _writeQueue.append(JSON.stringify(requestEntry) + '\n---\n', () => _commitDeltaState(_dl, _tf)); }
1031
+ _writeCompletedEntry(requestEntry, _v2Handle);
1221
1032
  } catch (err) {
1222
- delete requestEntry.inProgress;
1223
- delete requestEntry.requestId;
1224
- { const _dl = _deltaOriginalMessagesLength, _tf = _deltaOriginalTailFp;
1225
- _writeQueue.append(JSON.stringify(requestEntry) + '\n---\n', () => _commitDeltaState(_dl, _tf)); }
1033
+ _writeCompletedEntry(requestEntry, _v2Handle);
1226
1034
  }
1227
1035
  }
1228
1036
  }