cc-viewer 1.6.248 → 1.6.249

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.
package/dist/index.html CHANGED
@@ -6,7 +6,7 @@
6
6
  <title>Claude Code Viewer</title>
7
7
  <link rel="icon" href="/favicon.ico?v=1">
8
8
  <link rel="shortcut icon" href="/favicon.ico?v=1">
9
- <script type="module" crossorigin src="/assets/index-1OtQ-y7A.js"></script>
9
+ <script type="module" crossorigin src="/assets/index-etQ0_qi5.js"></script>
10
10
  <link rel="modulepreload" crossorigin href="/assets/vendor-antd-BeN8xqGk.js">
11
11
  <link rel="modulepreload" crossorigin href="/assets/vendor-codemirror-2nbmPewy.js">
12
12
  <link rel="modulepreload" crossorigin href="/assets/vendor-mdxeditor-C7DYEBoH.js">
package/interceptor.js CHANGED
@@ -13,7 +13,7 @@ import { homedir } from 'node:os';
13
13
  import { fileURLToPath, pathToFileURL } from 'node:url';
14
14
  import { dirname, join, basename } from 'node:path';
15
15
  import { LOG_DIR } from './findcc.js';
16
- import { assembleStreamMessage, createStreamAssembler, cleanupTempFiles, findRecentLog, isAnthropicApiPath, isMainAgentRequest, rotateLogFile } from './lib/interceptor-core.js';
16
+ import { assembleStreamMessage, createStreamAssembler, cleanupTempFiles, findRecentLog, isAnthropicApiPath, isMainAgentRequest, rotateLogFile, fingerprintMsg } from './lib/interceptor-core.js';
17
17
 
18
18
 
19
19
 
@@ -239,14 +239,21 @@ function resolveResumeChoice(choice) {
239
239
  // Delta storage: 增量存储开关和状态(默认开启,设置 CCV_DISABLE_DELTA=1 关闭)
240
240
  // 注意:delta 计算依赖 mainAgent 请求串行(Claude CLI 保证),不做并发互斥
241
241
  const _deltaStorageEnabled = process.env.CCV_DISABLE_DELTA !== '1';
242
+ // In-place last-msg replace 检测开关(默认开启,设置 CCV_DISABLE_TAIL_FP_CHECKPOINT=1 关闭)。
243
+ // 关闭后回退到旧行为(仅按长度算 delta,遇到末位原地替换会丢失"末位换内容"信息)。
244
+ const _tailFpCheckEnabled = process.env.CCV_DISABLE_TAIL_FP_CHECKPOINT !== '1';
242
245
  let _lastMessagesCount = 0; // 上一次 mainAgent 写入的完整 messages 数量
246
+ let _lastTailFp = ''; // 上一次 mainAgent 末位 message 的指纹(用于 in-place replace 检测)
243
247
  let _mainAgentDeltaCount = 0; // mainAgent 请求计数器(用于触发定期 checkpoint)
244
248
  const CHECKPOINT_INTERVAL = 10; // 每 N 条 mainAgent 请求写一个 checkpoint
245
249
 
246
250
  /** Delta storage: completed 写入成功后更新状态 */
247
- function _commitDeltaState(originalLength) {
251
+ function _commitDeltaState(originalLength, originalTailFp) {
248
252
  if (_deltaStorageEnabled && originalLength > 0) {
249
253
  _lastMessagesCount = originalLength;
254
+ if (typeof originalTailFp === 'string') {
255
+ _lastTailFp = originalTailFp;
256
+ }
250
257
  }
251
258
  }
252
259
 
@@ -374,6 +381,7 @@ function checkAndRotateLogFile() {
374
381
  // 重置 delta 状态,强制下一条 mainAgent 请求写完整 checkpoint
375
382
  if (_deltaStorageEnabled) {
376
383
  _lastMessagesCount = 0;
384
+ _lastTailFp = '';
377
385
  _mainAgentDeltaCount = 0;
378
386
  }
379
387
  }
@@ -599,16 +607,33 @@ export function setupInterceptor() {
599
607
 
600
608
  // Delta storage:仅 mainAgent 且开关启用时,将 body.messages 转为增量格式
601
609
  let _deltaOriginalMessagesLength = 0; // 缓存本次请求的原始 messages 长度,用于 completed 后更新状态
610
+ let _deltaOriginalTailFp = ''; // 缓存本次请求末位 message 的指纹,用于 completed 后更新 _lastTailFp
602
611
  if (_deltaStorageEnabled && requestEntry?.mainAgent && Array.isArray(requestEntry.body?.messages)) {
603
612
  const messages = requestEntry.body.messages;
604
613
  _deltaOriginalMessagesLength = messages.length;
614
+ // 立即把末位 fp 算成字符串保存(不存对象引用),避免后续 mutation 风险
615
+ _deltaOriginalTailFp = messages.length > 0 ? fingerprintMsg(messages[messages.length - 1]) : '';
605
616
  _mainAgentDeltaCount++;
606
617
 
618
+ // In-place last-msg replace 检测:messages.length 不变但末位 fp 不同。
619
+ // 触发场景:CLI 在 mainAgent 末位"原地替换"user msg(SUGGESTION MODE → 用户真实输入;
620
+ // synthetic recap 通道注入;等),wire 上长度未变内容变了。旧逻辑 messages.slice(_lastMessagesCount)
621
+ // 算出 delta=[],丢失了"末位换内容"信息 → 客户端重建拿到错误的"前态末位"。
622
+ // 检测命中即强制写 checkpoint,让客户端拿到完整 wire 真实内容。
623
+ const _sameLenInPlaceReplace =
624
+ _tailFpCheckEnabled &&
625
+ messages.length === _lastMessagesCount &&
626
+ _lastMessagesCount > 0 &&
627
+ _lastTailFp !== '' &&
628
+ _deltaOriginalTailFp !== '' &&
629
+ _deltaOriginalTailFp !== _lastTailFp;
630
+
607
631
  // 判断是否需要写 checkpoint
608
632
  const needsCheckpoint =
609
633
  _lastMessagesCount === 0 || // 进程重启 / 首次请求
610
634
  messages.length < _lastMessagesCount || // messages 缩短(/clear、context 压缩)
611
- (_mainAgentDeltaCount % CHECKPOINT_INTERVAL === 0); // 定期 checkpoint
635
+ (_mainAgentDeltaCount % CHECKPOINT_INTERVAL === 0) || // 定期 checkpoint
636
+ _sameLenInPlaceReplace; // in-place last-msg replace 检测
612
637
 
613
638
  if (needsCheckpoint) {
614
639
  // checkpoint:保持完整 messages,标记 _isCheckpoint
@@ -616,6 +641,11 @@ export function setupInterceptor() {
616
641
  requestEntry._totalMessageCount = messages.length;
617
642
  requestEntry._conversationId = 'mainAgent';
618
643
  requestEntry._isCheckpoint = true;
644
+ if (_sameLenInPlaceReplace) {
645
+ // 诊断字段:标记此 checkpoint 是被 in-place replace 检测触发的(频率约 1-2%,
646
+ // 用于在生产 jsonl 里事后核对触发率,不影响重建逻辑)
647
+ requestEntry._inPlaceReplaceDetected = true;
648
+ }
619
649
  } else {
620
650
  // delta:只保留新增的 messages
621
651
  const delta = messages.slice(_lastMessagesCount);
@@ -844,7 +874,7 @@ export function setupInterceptor() {
844
874
  delete requestEntry.inProgress;
845
875
  delete requestEntry.requestId;
846
876
  appendFileSync(LOG_FILE, JSON.stringify(requestEntry) + '\n---\n');
847
- _commitDeltaState(_deltaOriginalMessagesLength);
877
+ _commitDeltaState(_deltaOriginalMessagesLength, _deltaOriginalTailFp);
848
878
  // Release memory: clear large objects after disk write
849
879
  streamedChunks = [];
850
880
  streamedContentLen = 0;
@@ -855,7 +885,7 @@ export function setupInterceptor() {
855
885
  delete requestEntry.inProgress;
856
886
  delete requestEntry.requestId;
857
887
  appendFileSync(LOG_FILE, JSON.stringify(requestEntry) + '\n---\n');
858
- _commitDeltaState(_deltaOriginalMessagesLength);
888
+ _commitDeltaState(_deltaOriginalMessagesLength, _deltaOriginalTailFp);
859
889
  streamedChunks = [];
860
890
  streamedContentLen = 0;
861
891
  requestEntry.response = null;
@@ -925,7 +955,7 @@ export function setupInterceptor() {
925
955
  delete requestEntry.inProgress;
926
956
  delete requestEntry.requestId;
927
957
  appendFileSync(LOG_FILE, JSON.stringify(requestEntry) + '\n---\n');
928
- _commitDeltaState(_deltaOriginalMessagesLength);
958
+ _commitDeltaState(_deltaOriginalMessagesLength, _deltaOriginalTailFp);
929
959
  resetStreamingState();
930
960
  }
931
961
  } else {
@@ -953,12 +983,12 @@ export function setupInterceptor() {
953
983
  delete requestEntry.requestId;
954
984
 
955
985
  appendFileSync(LOG_FILE, JSON.stringify(requestEntry) + '\n---\n');
956
- _commitDeltaState(_deltaOriginalMessagesLength);
986
+ _commitDeltaState(_deltaOriginalMessagesLength, _deltaOriginalTailFp);
957
987
  } catch (err) {
958
988
  delete requestEntry.inProgress;
959
989
  delete requestEntry.requestId;
960
990
  appendFileSync(LOG_FILE, JSON.stringify(requestEntry) + '\n---\n');
961
- _commitDeltaState(_deltaOriginalMessagesLength);
991
+ _commitDeltaState(_deltaOriginalMessagesLength, _deltaOriginalTailFp);
962
992
  }
963
993
  }
964
994
  }
@@ -336,6 +336,44 @@ export function migrateConversationContext(oldFile, newFile) {
336
336
  } catch { }
337
337
  }
338
338
 
339
+ /**
340
+ * 计算单条 message 的轻量身份指纹,用于 delta storage 的 in-place last-msg replace 检测。
341
+ * 仅服务端 interceptor 使用 —— 触发 Plan C checkpoint 让客户端拿到 wire 真实内容。
342
+ * 历史上客户端 sessionManager.js 也复用过此算法做 isInPlaceLastMsgReplace 短路,
343
+ * 后被拆除(因 short-circuit 导致 same-ts 多记录被合并);现单层防御仅靠服务端。
344
+ *
345
+ * 80 字符前缀 + tool_use_id 后 8 字符 + tool_result body 下钻取真实文本(避开 String(array)
346
+ * 塌陷成 "[object Object]" 的 collision 坑)。
347
+ */
348
+ export function fingerprintMsg(m) {
349
+ if (!m) return '';
350
+ const c = m.content;
351
+ let snip = '';
352
+ if (Array.isArray(c) && c.length > 0) {
353
+ const f = c[0];
354
+ if (f && typeof f === 'object') {
355
+ if (f.type === 'text') {
356
+ snip = String(f.text || '').slice(0, 80);
357
+ } else if (f.type === 'tool_use') {
358
+ snip = '<tool_use:' + (f.name || '?') + ':' + (f.id || '').slice(-8) + '>';
359
+ } else if (f.type === 'tool_result') {
360
+ let body = '';
361
+ if (typeof f.content === 'string') body = f.content;
362
+ else if (Array.isArray(f.content) && f.content[0]) {
363
+ const cf = f.content[0];
364
+ body = (typeof cf === 'string') ? cf : (cf.text || cf.type || '');
365
+ }
366
+ snip = '<tool_result:' + (f.tool_use_id || '').slice(-8) + ':' + String(body).slice(0, 40) + '>';
367
+ } else {
368
+ snip = '<' + (f.type || '?') + '>';
369
+ }
370
+ }
371
+ } else if (typeof c === 'string') {
372
+ snip = c.slice(0, 80);
373
+ }
374
+ return (m.role || '?') + ':' + snip.replace(/\s+/g, ' ').slice(0, 80);
375
+ }
376
+
339
377
  /**
340
378
  * Rotate log file when it exceeds maxSize.
341
379
  * Creates an empty new file (no content migration) and appends '\n' to old file
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cc-viewer",
3
- "version": "1.6.248",
3
+ "version": "1.6.249",
4
4
  "description": "Claude Code Logger visualization management tool",
5
5
  "license": "MIT",
6
6
  "main": "server.js",