cc-viewer 1.7.0 → 1.7.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (115) hide show
  1. package/dist/assets/App-gUSmU6Ny.js +2 -0
  2. package/dist/assets/{MdxEditorPanel-C5Q6uUyH.js → MdxEditorPanel-DAR39A3S.js} +1 -1
  3. package/dist/assets/{Mobile-MoBp9c8k.js → Mobile-qfRsRth0.js} +1 -1
  4. package/dist/assets/index-B51Ci0GL.js +2 -0
  5. package/dist/assets/{seqResourceLoaders-CxuD1CYl.css → seqResourceLoaders-DFrHmgnK.css} +1 -1
  6. package/dist/assets/seqResourceLoaders-DZJxMMaR.js +2 -0
  7. package/dist/index.html +1 -1
  8. package/package.json +2 -1
  9. package/server/lib/log-watcher.js +29 -3
  10. package/server/lib/v2/adapter.js +71 -6
  11. package/server/lib/v2/live-feed.js +110 -3
  12. package/server/lib/v2/meta-rows.js +360 -0
  13. package/server/lib/v2/v2-writer.js +15 -0
  14. package/server/lib/wire-compress.js +167 -0
  15. package/server/routes/events.js +96 -26
  16. package/server/routes/logs.js +15 -12
  17. package/server/routes/v2.js +60 -0
  18. package/server/routes/workspaces.js +7 -6
  19. package/server/server.js +23 -1
  20. package/src/utils/apiUrl.js +26 -0
  21. package/src/utils/askFallback.js +21 -0
  22. package/src/utils/askOptionDesc.js +55 -0
  23. package/src/utils/askPortalMatcher.js +54 -0
  24. package/src/utils/autoApproveOptions.js +27 -0
  25. package/src/utils/avatarAnimationPostPass.js +49 -0
  26. package/src/utils/builtinPresets.js +18 -0
  27. package/src/utils/clearCheckpoint.js +7 -0
  28. package/src/utils/commandValidator.js +45 -0
  29. package/src/utils/contentFilter.js +620 -0
  30. package/src/utils/contextRaw.js +18 -0
  31. package/src/utils/contextSidebarNavigation.js +16 -0
  32. package/src/utils/contextTurns.js +117 -0
  33. package/src/utils/displayScaleHelper.js +47 -0
  34. package/src/utils/effectiveModel.js +13 -0
  35. package/src/utils/entry-slim.js +563 -0
  36. package/src/utils/entryCache.js +144 -0
  37. package/src/utils/errorReport.js +6 -0
  38. package/src/utils/fileExpandedPathsStorage.js +70 -0
  39. package/src/utils/fileIcons.jsx +36 -0
  40. package/src/utils/fileOpen.js +37 -0
  41. package/src/utils/formatters.js +48 -0
  42. package/src/utils/gitApi.js +50 -0
  43. package/src/utils/gitTreeBuilder.js +19 -0
  44. package/src/utils/helpers.js +846 -0
  45. package/src/utils/identityHeal.js +99 -0
  46. package/src/utils/imConnState.js +59 -0
  47. package/src/utils/imOrigin.js +24 -0
  48. package/src/utils/imTr.js +7 -0
  49. package/src/utils/imageCompress.js +83 -0
  50. package/src/utils/imageDownscale.js +28 -0
  51. package/src/utils/imageResize.js +112 -0
  52. package/src/utils/ingestPipeline.js +47 -0
  53. package/src/utils/lazyWithReload.js +53 -0
  54. package/src/utils/markdown.js +30 -0
  55. package/src/utils/markdownIncremental.js +39 -0
  56. package/src/utils/markdownProfiler.js +113 -0
  57. package/src/utils/mdExtensionDetect.js +64 -0
  58. package/src/utils/memoryLinkParser.js +44 -0
  59. package/src/utils/modalMask.js +15 -0
  60. package/src/utils/monotime.js +6 -0
  61. package/src/utils/pinnedMenu.js +55 -0
  62. package/src/utils/presetShortcuts.js +14 -0
  63. package/src/utils/projectAlias.js +135 -0
  64. package/src/utils/promptClassifier.js +112 -0
  65. package/src/utils/promptDetect.js +196 -0
  66. package/src/utils/promptNav.js +60 -0
  67. package/src/utils/ptyChunkBuilder.js +251 -0
  68. package/src/utils/quickMenuHoverIntent.js +35 -0
  69. package/src/utils/rateLimitParser.js +119 -0
  70. package/src/utils/readResultPool.js +106 -0
  71. package/src/utils/refreshCachedItemProp.js +30 -0
  72. package/src/utils/requestType.js +208 -0
  73. package/src/utils/resizeCalc.js +21 -0
  74. package/src/utils/resolveLocalized.js +17 -0
  75. package/src/utils/searchApi.js +47 -0
  76. package/src/utils/searchReplace.js +51 -0
  77. package/src/utils/seqResourceLoaders.js +79 -0
  78. package/src/utils/sessionManager.js +471 -0
  79. package/src/utils/sessionMerge.js +180 -0
  80. package/src/utils/skillModalController.js +111 -0
  81. package/src/utils/skillsParser.js +116 -0
  82. package/src/utils/slashCommandLabels.js +80 -0
  83. package/src/utils/splitDragCalc.js +73 -0
  84. package/src/utils/stickyBottomController.js +643 -0
  85. package/src/utils/svgSanitize.js +48 -0
  86. package/src/utils/systemTags.js +47 -0
  87. package/src/utils/tClaude.js +20 -0
  88. package/src/utils/teamModalBuilder.js +351 -0
  89. package/src/utils/teamSessionParser.js +190 -0
  90. package/src/utils/teammateAvatars.js +194 -0
  91. package/src/utils/teammateDetector.js +121 -0
  92. package/src/utils/termDiag.js +114 -0
  93. package/src/utils/terminalClipboard.js +117 -0
  94. package/src/utils/terminalWriteQueue.js +398 -0
  95. package/src/utils/thinkingMerge.js +49 -0
  96. package/src/utils/toolCatalog.js +29 -0
  97. package/src/utils/toolResultBuilder.js +393 -0
  98. package/src/utils/toolResultClassifier.js +16 -0
  99. package/src/utils/toolResultCore.js +167 -0
  100. package/src/utils/toolsDiff.js +44 -0
  101. package/src/utils/toolsXmlFormatter.js +3 -0
  102. package/src/utils/ultraplanController.js +109 -0
  103. package/src/utils/ultraplanExperts.js +86 -0
  104. package/src/utils/ultraplanTemplates.js +154 -0
  105. package/src/utils/userImageRefs.js +49 -0
  106. package/src/utils/v3Assembler.js +112 -0
  107. package/src/utils/v3Rows.js +48 -0
  108. package/src/utils/voicePackPlayer.js +233 -0
  109. package/src/utils/webSearchGrouping.js +111 -0
  110. package/src/utils/workflowFormat.js +52 -0
  111. package/src/utils/workflowRuns.js +118 -0
  112. package/src/utils/workflowStore.js +94 -0
  113. package/dist/assets/App-DTkacfRW.js +0 -2
  114. package/dist/assets/index-Ac8vnV3v.js +0 -2
  115. package/dist/assets/seqResourceLoaders-Cd_TAXTb.js +0 -2
@@ -6,7 +6,9 @@ import { LOG_DIR } from '../../findcc.js';
6
6
  import { streamRawEntriesAsync } from '../lib/log-stream.js';
7
7
  import { migrationStatus } from '../lib/v2/migrate-prompt.js';
8
8
  import { reportSwallowed } from '../lib/error-report.js';
9
- import { awaitDrainOrClose } from '../lib/sse-backpressure.js';
9
+ import { sseHead, sseWrite, needsDrain, wireEnd, awaitWireDrain } from '../lib/wire-compress.js';
10
+ import { readV2ColdBundle } from '../lib/v2/meta-rows.js';
11
+ import { readV2SingleEntry } from '../lib/v2/adapter.js';
10
12
  import { enrichRawIfNeeded } from '../lib/enrich-plan-input.js';
11
13
  import { validateLogPath } from '../lib/log-management.js';
12
14
  import { isMainAgentEntry, extractCachedContent } from '../lib/kv-cache-analyzer.js';
@@ -60,7 +62,10 @@ export function sseUpdateBadgeFrame(pending) {
60
62
  }
61
63
 
62
64
  async function events(req, res, parsedUrl, isLocal, deps) {
63
- res.writeHead(200, {
65
+ // Negotiated Content-Encoding (br|identity). From here on, every byte of
66
+ // this response MUST go through sseWrite — a bare res.write would corrupt
67
+ // the compressed stream (see server/lib/wire-compress.js).
68
+ sseHead(req, res, 200, {
64
69
  'Content-Type': 'text/event-stream',
65
70
  'Cache-Control': 'no-cache',
66
71
  'Connection': 'keep-alive',
@@ -74,7 +79,7 @@ async function events(req, res, parsedUrl, isLocal, deps) {
74
79
 
75
80
  // SSE 心跳保活:每 30s 发送 ping 事件,防止连接被 OS/代理/浏览器静默断开
76
81
  const pingTimer = setInterval(() => {
77
- try { res.write('event: ping\ndata: {}\n\n'); } catch {}
82
+ try { sseWrite(res, 'event: ping\ndata: {}\n\n'); } catch {}
78
83
  }, 30000);
79
84
 
80
85
  // server_config: 给前端推一次性的关键运行时常量,让前端 cooldown / debounce 等
@@ -82,7 +87,7 @@ async function events(req, res, parsedUrl, isLocal, deps) {
82
87
  // 见 server.js 顶部 turnEnd debounce 的 SUNSET-MARKER 注释。
83
88
  // write 失败要 warn:之前静默吞会让 env override 漂移到前端硬常量 10s 而无人发觉。
84
89
  try {
85
- res.write(`event: server_config\ndata: ${JSON.stringify({ turnEndDebounceMs: deps.turnEndDebounceMs })}\n\n`);
90
+ sseWrite(res, `event: server_config\ndata: ${JSON.stringify({ turnEndDebounceMs: deps.turnEndDebounceMs, wireV3: !!deps.wireV3, build: deps.serverBuild || '' })}\n\n`);
86
91
  } catch (err) {
87
92
  console.warn(`[server_config] SSE write failed (turnEndDebounceMs=${deps.turnEndDebounceMs}):`, err && err.message);
88
93
  }
@@ -90,7 +95,7 @@ async function events(req, res, parsedUrl, isLocal, deps) {
90
95
  // 补推「有新版」徽标:复用启动检查缓存的结果,让刷新/新标签页连上即恢复徽标。
91
96
  // 置于 deps.clients.push(res) 之前 → 新客户端仅得一份,不与一次性广播竞态。
92
97
  const updFrame = sseUpdateBadgeFrame(deps.pendingMajorUpdate);
93
- if (updFrame) res.write(updFrame);
98
+ if (updFrame) sseWrite(res, updFrame);
94
99
 
95
100
  // 1.7.0 迁移引导(P2):当前项目仍有未转换的 v1 日志 → 连接即推 migrate_prompt。
96
101
  // 是否弹窗由客户端决定(「不再提醒」偏好在客户端;continued=true 时无视 dismissed
@@ -99,7 +104,7 @@ async function events(req, res, parsedUrl, isLocal, deps) {
99
104
  try {
100
105
  const mig = migrationStatus(LOG_DIR, _projectName || '');
101
106
  if (mig.pending) {
102
- res.write(`event: migrate_prompt\ndata: ${JSON.stringify({ ...mig, continued: isContinuedLaunch() })}\n\n`);
107
+ sseWrite(res, `event: migrate_prompt\ndata: ${JSON.stringify({ ...mig, continued: isContinuedLaunch() })}\n\n`);
103
108
  }
104
109
  } catch (e) { reportSwallowed('sse.migrate_prompt', e); }
105
110
 
@@ -144,9 +149,15 @@ async function events(req, res, parsedUrl, isLocal, deps) {
144
149
  const MAINAGENT_SCAN_RING = 3;
145
150
  const mainAgentRawRing = [];
146
151
 
152
+ // Wire v3 (V3.S5): flagged + v2 source ⇒ the legacy full-entry cold stream
153
+ // is REPLACED by rows + native lines (the byte win); the client assembler
154
+ // rebuilds entries locally. v1 legacy files keep the entry pipeline.
155
+ const _v3Src = deps.wireV3 ? getLiveLogSource() : null;
156
+ const v3Cold = !!(_v3Src && existsSync(join(_v3Src, 'journal.jsonl')));
157
+
147
158
  // S6b: the cold-load source is the current v2 session dir when the v2
148
159
  // writer is active (adapter stream), else the v1 file.
149
- const coldLoadResult = await streamRawEntriesAsync(getLiveLogSource(), async (raw) => {
160
+ const coldLoadResult = v3Cold ? null : await streamRawEntriesAsync(getLiveLogSource(), async (raw) => {
150
161
  // 直接发送原始 JSON 字符串,不做 parse/reconstruct/stringify
151
162
  // ExitPlanMode V2 空 input 的条目按需补全 plan / planFilePath,其它原样透传
152
163
  if (res.destroyed || !res.writable) return;
@@ -156,9 +167,15 @@ async function events(req, res, parsedUrl, isLocal, deps) {
156
167
  // 把 EPIPE 抛穿 async callback;res.on('close'|'error') 已会做 clients 数组清理。
157
168
  let drained = true;
158
169
  try {
159
- res.write('event: load_chunk\ndata: [');
160
- drained = res.write(out.includes('\n') ? out.replace(/\n/g, '') : out);
161
- res.write(']\n\n');
170
+ sseWrite(res, 'event: load_chunk\ndata: [');
171
+ const ok = sseWrite(res, out.includes('\n') ? out.replace(/\n/g, '') : out);
172
+ sseWrite(res, ']\n\n');
173
+ // Two pressure signals: `ok` is the write target's input buffer (the
174
+ // ENCODER on compressed paths — its backlog would otherwise be invisible
175
+ // because compressed output is 20-80x smaller and rarely fills the
176
+ // socket), needsDrain(res) is the socket itself. awaitWireDrain below
177
+ // waits on whichever stream actually applies the pressure.
178
+ drained = ok && !needsDrain(res);
162
179
  } catch {
163
180
  return;
164
181
  }
@@ -166,7 +183,7 @@ async function events(req, res, parsedUrl, isLocal, deps) {
166
183
  // helper 内部会在 fulfill 时把另外两个监听器从 res 上摘掉,避免 N 次 backpressure
167
184
  // 累积出 N 个 stale close/error listener 触发 MaxListenersExceededWarning。
168
185
  if (!drained) {
169
- await awaitDrainOrClose(res, deps.SSE_BACKPRESSURE_TIMEOUT_MS);
186
+ await awaitWireDrain(res, deps.SSE_BACKPRESSURE_TIMEOUT_MS);
170
187
  }
171
188
  }, {
172
189
  since: useIncremental ? sinceParam : undefined,
@@ -190,11 +207,54 @@ async function events(req, res, parsedUrl, isLocal, deps) {
190
207
  loadStartData.hasMore = !!hasMore;
191
208
  loadStartData.oldestTs = oldestTs || '';
192
209
  }
193
- res.write(`event: load_start\ndata: ${JSON.stringify(loadStartData)}\n\n`);
210
+ sseWrite(res, `event: load_start\ndata: ${JSON.stringify(loadStartData)}\n\n`);
194
211
  },
195
212
  });
196
213
 
197
- res.write(`event: load_end\ndata: {}\n\n`);
214
+ // Wire v3 (V3.S2/S4/S5): cold rows + native lines land BEFORE load_end so
215
+ // the flagged client can assemble its window in the same load cycle. The
216
+ // legacy chunk stream was skipped above (v3Cold) — load_start is emitted
217
+ // here from the rows metadata instead.
218
+ if (v3Cold) {
219
+ try {
220
+ const src = _v3Src;
221
+ // Single-flighted bundle (review P2-b): a reconnect storm coalesces to
222
+ // one journal fold + one native read per (dir, window). `since` scopes
223
+ // an incremental reconnect to the delta window (review P1-2) — the
224
+ // client upserts those rows instead of resetting the list.
225
+ const { meta, native } = await readV2ColdBundle(src, {
226
+ limit: useLimit ? effectiveLimit : 0,
227
+ since: useIncremental ? sinceParam : null,
228
+ });
229
+ // Build every payload BEFORE load_start so it can carry the exact byte
230
+ // total — the client renders a real received/total progress (the legacy
231
+ // wire's per-entry count-up has no per-frame granularity here).
232
+ const rowsPayload = JSON.stringify({ rows: meta.rows, totalCount: meta.totalCount, hasMore: meta.hasMore, oldestTs: meta.oldestTimestamp, incremental: !!useIncremental });
233
+ let v3Bytes = rowsPayload.length;
234
+ for (const p of native.convPayloads) v3Bytes += p.length;
235
+ for (const p of native.respPayloads) v3Bytes += p.length;
236
+ const loadStartData = { total: meta.totalCount, incremental: !!useIncremental, v3Bytes };
237
+ if (useLimit) {
238
+ loadStartData.hasMore = !!meta.hasMore;
239
+ loadStartData.oldestTs = meta.oldestTimestamp || '';
240
+ }
241
+ sseWrite(res, `event: load_start\ndata: ${JSON.stringify(loadStartData)}\n\n`);
242
+ sseWrite(res, `event: v2_requests\ndata: ${rowsPayload}\n\n`);
243
+ for (const payload of native.convPayloads) sseWrite(res, `event: v3_conv\ndata: ${payload}\n\n`);
244
+ for (const payload of native.respPayloads) sseWrite(res, `event: v3_resp\ndata: ${payload}\n\n`);
245
+ // kv-cache / context_window sources: rebuild the newest completed
246
+ // mainAgent rows. Depth 3 mirrors the legacy scan-ring fallback (review
247
+ // P2-e): the newest main may lack usage/cached-content or be a
248
+ // synthesis-gated orphan — earlier candidates then provide the values.
249
+ const mains = meta.rows.filter((r) => r.mainAgent && !r.inProgress).slice(-3);
250
+ for (const m of mains) {
251
+ const detail = await readV2SingleEntry(src, { seq: m.seq, sessionId: m.sessionId });
252
+ if (detail && detail.entry) mainAgentRawRing.push(detail.entry);
253
+ }
254
+ } catch (err) { reportSwallowed('sse.v2_requests', err); }
255
+ }
256
+
257
+ sseWrite(res, `event: load_end\ndata: {}\n\n`);
198
258
 
199
259
  // S10a: v2 sources no longer invoke onScan (the two-pass window never
200
260
  // stringifies out-of-window entries) — the adapter returns the newest-main
@@ -227,10 +287,10 @@ async function events(req, res, parsedUrl, isLocal, deps) {
227
287
 
228
288
  // 发送最新 MainAgent 的 KV-Cache 和 context_window
229
289
  if (latestKvCache) {
230
- res.write(`event: kv_cache_content\ndata: ${JSON.stringify(latestKvCache)}\n\n`);
290
+ sseWrite(res, `event: kv_cache_content\ndata: ${JSON.stringify(latestKvCache)}\n\n`);
231
291
  }
232
292
  if (latestContextWindow) {
233
- res.write(`event: context_window\ndata: ${JSON.stringify(latestContextWindow)}\n\n`);
293
+ sseWrite(res, `event: context_window\ndata: ${JSON.stringify(latestContextWindow)}\n\n`);
234
294
  pushedContextWindow = true;
235
295
  }
236
296
  // Fallback: no MainAgent in log (e.g. fresh session after -c), read context-window.json
@@ -250,7 +310,7 @@ async function events(req, res, parsedUrl, isLocal, deps) {
250
310
  const effectiveSize = adaptContextWindow(contextSize, inputTokens);
251
311
  const usedPct = effectiveSize > 0 ? Math.round((totalTokens / effectiveSize) * 100) : 0;
252
312
  const data = { ...cw, context_window_size: effectiveSize, used_percentage: usedPct, remaining_percentage: 100 - usedPct };
253
- res.write(`event: context_window\ndata: ${JSON.stringify(data)}\n\n`);
313
+ sseWrite(res, `event: context_window\ndata: ${JSON.stringify(data)}\n\n`);
254
314
  }
255
315
  } catch { }
256
316
  }
@@ -274,16 +334,26 @@ async function events(req, res, parsedUrl, isLocal, deps) {
274
334
  // API endpoint
275
335
  async function requests(req, res) {
276
336
  // 异步流式 JSON 数组输出,不做 reconstruct,发原始条目
277
- res.writeHead(200, { 'Content-Type': 'application/json' });
278
- res.write('[');
279
- let first = true;
280
- await streamRawEntriesAsync(getLiveLogSource(), (raw) => {
281
- if (!first) res.write(',');
282
- res.write(enrichRawIfNeeded(raw));
283
- first = false;
284
- });
285
- res.write(']');
286
- res.end();
337
+ // flush:false —— whole-stream response: the client parses the array only
338
+ // when complete, so per-macrotask flush boundaries would just cost ratio.
339
+ sseHead(req, res, 200, { 'Content-Type': 'application/json' }, { flush: false });
340
+ try {
341
+ sseWrite(res, '[');
342
+ let first = true;
343
+ await streamRawEntriesAsync(getLiveLogSource(), (raw) => {
344
+ if (!first) sseWrite(res, ',');
345
+ sseWrite(res, enrichRawIfNeeded(raw));
346
+ first = false;
347
+ });
348
+ sseWrite(res, ']');
349
+ wireEnd(res);
350
+ } catch (err) {
351
+ // Mid-stream failure (e.g. session dir converted/removed while streaming):
352
+ // without this catch the rejection propagates through the dispatcher and
353
+ // kills the process. Headers are already sent — close the stream cleanly.
354
+ console.error('[api-requests]', err && err.stack || err);
355
+ wireEnd(res);
356
+ }
287
357
  }
288
358
 
289
359
  export const eventsRoutes = [
@@ -7,6 +7,7 @@ import { LOG_DIR } from '../../findcc.js';
7
7
  import { _projectName, _v2Writer } from '../interceptor.js';
8
8
  import { listV2Logs, listLocalLogs, countListedV1Files, deleteLogFiles, validateLogPath } from '../lib/log-management.js';
9
9
  import { countLogEntries, streamRawEntriesAsync, readTailEntries } from '../lib/log-stream.js';
10
+ import { sseHead, sseWrite, wireEnd } from '../lib/wire-compress.js';
10
11
  import { dirSizeSync } from '../lib/v2/layout.js';
11
12
  import { extractV2Zip } from '../lib/log-zip.js';
12
13
  import { startConvert, stopConvert, convertStatus } from '../lib/v2/convert-manager.js';
@@ -191,7 +192,9 @@ async function localLog(req, res, parsedUrl) {
191
192
  const filePath = validateLogPath(LOG_DIR, file);
192
193
  const limitVal = Math.min(parseInt(parsedUrl.searchParams.get('limit'), 10) || 0, 500);
193
194
 
194
- res.writeHead(200, {
195
+ // Negotiated Content-Encoding (br|identity) — every subsequent byte MUST
196
+ // go through sseWrite/wireEnd (see server/lib/wire-compress.js).
197
+ sseHead(req, res, 200, {
195
198
  'Content-Type': 'text/event-stream',
196
199
  'Cache-Control': 'no-cache',
197
200
  'Connection': 'keep-alive',
@@ -200,24 +203,24 @@ async function localLog(req, res, parsedUrl) {
200
203
  if (limitVal > 0) {
201
204
  // 尾部加载模式:跳过 countLogEntries,只读文件末尾
202
205
  const { entries, hasMore, oldestTimestamp, estimatedTotal } = await readTailEntries(filePath, { limit: limitVal });
203
- res.write(`event: load_start\ndata: ${JSON.stringify({ total: estimatedTotal, incremental: false, hasMore, oldestTs: oldestTimestamp })}\n\n`);
206
+ sseWrite(res, `event: load_start\ndata: ${JSON.stringify({ total: estimatedTotal, incremental: false, hasMore, oldestTs: oldestTimestamp })}\n\n`);
204
207
  for (const raw of entries) {
205
- res.write('event: load_chunk\ndata: [');
206
- res.write(raw.includes('\n') ? raw.replace(/\n/g, '') : raw);
207
- res.write(']\n\n');
208
+ sseWrite(res, 'event: load_chunk\ndata: [');
209
+ sseWrite(res, raw.includes('\n') ? raw.replace(/\n/g, '') : raw);
210
+ sseWrite(res, ']\n\n');
208
211
  }
209
212
  } else {
210
213
  // 全量加载模式(向后兼容)
211
214
  const total = await countLogEntries(filePath);
212
- res.write(`event: load_start\ndata: ${JSON.stringify({ total, incremental: false })}\n\n`);
215
+ sseWrite(res, `event: load_start\ndata: ${JSON.stringify({ total, incremental: false })}\n\n`);
213
216
  await streamRawEntriesAsync(filePath, (raw) => {
214
- res.write('event: load_chunk\ndata: [');
215
- res.write(raw.includes('\n') ? raw.replace(/\n/g, '') : raw);
216
- res.write(']\n\n');
217
+ sseWrite(res, 'event: load_chunk\ndata: [');
218
+ sseWrite(res, raw.includes('\n') ? raw.replace(/\n/g, '') : raw);
219
+ sseWrite(res, ']\n\n');
217
220
  });
218
221
  }
219
- res.write(`event: load_end\ndata: {}\n\n`);
220
- res.end();
222
+ sseWrite(res, `event: load_end\ndata: {}\n\n`);
223
+ wireEnd(res);
221
224
  } catch (err) {
222
225
  // 如果 headers 未发送,返回 JSON 错误;否则关闭连接
223
226
  // 落 stderr 让用户在 ccv 终端能看到具体原因(SSE onerror 在客户端
@@ -228,7 +231,7 @@ async function localLog(req, res, parsedUrl) {
228
231
  res.writeHead(status, { 'Content-Type': 'application/json' });
229
232
  res.end(JSON.stringify({ error: err.message }));
230
233
  } else {
231
- res.end();
234
+ wireEnd(res); // flush the encoder trailer if the stream was compressed
232
235
  }
233
236
  }
234
237
  }
@@ -0,0 +1,60 @@
1
+ // Wire v3 routes (V3.S1+): on-demand single-entry detail for the metadata-row
2
+ // request list. Registered unconditionally — harmless while CCV_WIRE_V3 is off
3
+ // (nothing calls it), so the route itself needs no flag gate.
4
+ import { join } from 'node:path';
5
+ import { LOG_DIR } from '../../findcc.js';
6
+ import { validateLogPath } from '../lib/log-management.js';
7
+ import { resolveSessionDirName } from '../lib/v2/session-select.js';
8
+ import { readV2SingleEntry } from '../lib/v2/adapter.js';
9
+ import { sseHead, sseWrite, wireEnd } from '../lib/wire-compress.js';
10
+
11
+ /**
12
+ * GET /api/v2-entry?file=v2:<project>/<dirToken>&seq=N[&sid=<uuid>]
13
+ * dirToken is normally the session dir basename (`<ts>_<uuid>`); a bare UUID
14
+ * (the client only holds `_seqEpoch="v2:<uuid>"`) is resolved to the basename
15
+ * BEFORE validateLogPath — validation expects an existing directory path.
16
+ * `sid` disambiguates teammate rows: the fold runs over the leader dir, the
17
+ * target is keyed (sid, seq); omitted = match seq in any folded session.
18
+ */
19
+ async function v2Entry(req, res, parsedUrl) {
20
+ const file = parsedUrl.searchParams.get('file') || '';
21
+ const seq = parseInt(parsedUrl.searchParams.get('seq'), 10);
22
+ const sid = parsedUrl.searchParams.get('sid') || null;
23
+ const m = /^v2:([^/]+)\/([^/]+)$/.exec(file);
24
+ if (!m || !Number.isInteger(seq) || seq < 0) {
25
+ res.writeHead(400, { 'Content-Type': 'application/json' });
26
+ res.end(JSON.stringify({ error: 'expected file=v2:<project>/<session>&seq=<n>' }));
27
+ return;
28
+ }
29
+ try {
30
+ // UUID → dir-basename resolution first (rename/-c adoption safe), then the
31
+ // same path validation every other v2 ref goes through.
32
+ const [, project, dirToken] = m;
33
+ const resolved = resolveSessionDirName(join(LOG_DIR, project), dirToken) || dirToken;
34
+ const sessionDir = validateLogPath(LOG_DIR, `v2:${project}/${resolved}`);
35
+ const result = await readV2SingleEntry(sessionDir, { seq, sessionId: sid });
36
+ if (!result) {
37
+ res.writeHead(404, { 'Content-Type': 'application/json' });
38
+ res.end(JSON.stringify({ error: 'entry not found' }));
39
+ return;
40
+ }
41
+ // Whole-stream JSON (like /api/requests): compress without per-event flush.
42
+ sseHead(req, res, 200, { 'Content-Type': 'application/json' }, { flush: false });
43
+ // slots are raw JSON strings — compose without re-parse/re-stringify.
44
+ sseWrite(res, `{"entry":${result.entry},"prevMain":${result.prevMain ?? 'null'}}`);
45
+ wireEnd(res);
46
+ } catch (err) {
47
+ console.error('[v2-entry]', file, err && err.stack || err);
48
+ if (!res.headersSent) {
49
+ const status = err.code === 'NOT_FOUND' ? 404 : err.code === 'ACCESS_DENIED' ? 403 : 500;
50
+ res.writeHead(status, { 'Content-Type': 'application/json' });
51
+ res.end(JSON.stringify({ error: err.message }));
52
+ } else {
53
+ wireEnd(res);
54
+ }
55
+ }
56
+ }
57
+
58
+ export const v2Routes = [
59
+ { method: 'GET', match: 'exact', path: '/api/v2-entry', handler: v2Entry },
60
+ ];
@@ -17,6 +17,7 @@ const RESUME_FLAGS = new Set(['-r', '--resume']);
17
17
  import { unwatchAllWorkflows } from '../lib/workflow-watcher.js';
18
18
  import { readClaudeProjectModel } from '../lib/context-watcher.js';
19
19
  import { countLogEntries, streamRawEntriesAsync } from '../lib/log-stream.js';
20
+ import { sseWrite } from '../lib/wire-compress.js';
20
21
 
21
22
  function workspacesList(req, res, parsedUrl, isLocal, deps) {
22
23
  import('../workspace-registry.js').then(async ({ getWorkspaces }) => {
@@ -91,7 +92,7 @@ function workspacesLaunch(req, res, parsedUrl, isLocal, deps) {
91
92
  const startedPayload = `event: workspace_started\ndata: ${JSON.stringify({ projectName: result.projectName, path: wsPath, claudeProjectModel: readClaudeProjectModel(wsPath) })}\n\n`;
92
93
  deps.clients.forEach(client => {
93
94
  try {
94
- client.write(startedPayload);
95
+ sseWrite(client, startedPayload);
95
96
  } catch {}
96
97
  });
97
98
 
@@ -102,15 +103,15 @@ function workspacesLaunch(req, res, parsedUrl, isLocal, deps) {
102
103
  const wsReloadSource = getLiveLogSource();
103
104
  const wsReloadTotal = await countLogEntries(wsReloadSource);
104
105
  deps.clients.forEach(client => {
105
- try { client.write(`event: load_start\ndata: ${JSON.stringify({ total: wsReloadTotal, incremental: false })}\n\n`); } catch {}
106
+ try { sseWrite(client, `event: load_start\ndata: ${JSON.stringify({ total: wsReloadTotal, incremental: false })}\n\n`); } catch {}
106
107
  });
107
108
  await streamRawEntriesAsync(wsReloadSource, (raw) => {
108
109
  deps.clients.forEach(client => {
109
- try { client.write('event: load_chunk\ndata: ['); client.write(raw.replace(/\n/g, '')); client.write(']\n\n'); } catch {}
110
+ try { sseWrite(client, 'event: load_chunk\ndata: ['); sseWrite(client, raw.replace(/\n/g, '')); sseWrite(client, ']\n\n'); } catch {}
110
111
  });
111
112
  });
112
113
  deps.clients.forEach(client => {
113
- try { client.write(`event: load_end\ndata: {}\n\n`); } catch {}
114
+ try { sseWrite(client, `event: load_end\ndata: {}\n\n`); } catch {}
114
115
  });
115
116
 
116
117
  // 1.7.0 迁移引导(P2):切进的项目仍有未转换 v1 日志 → 对存量连接广播
@@ -119,7 +120,7 @@ function workspacesLaunch(req, res, parsedUrl, isLocal, deps) {
119
120
  const mig = migrationStatus(LOG_DIR, result.projectName || '');
120
121
  if (mig.pending) {
121
122
  const frame = `event: migrate_prompt\ndata: ${JSON.stringify({ ...mig, continued: isContinuedLaunch() })}\n\n`;
122
- deps.clients.forEach(client => { try { client.write(frame); } catch {} });
123
+ deps.clients.forEach(client => { try { sseWrite(client, frame); } catch {} });
123
124
  }
124
125
  } catch (e) { reportSwallowed('sse.migrate_prompt', e); }
125
126
 
@@ -185,7 +186,7 @@ function workspacesStop(req, res, parsedUrl, isLocal, deps) {
185
186
  // 通知所有 SSE 客户端
186
187
  deps.clients.forEach(client => {
187
188
  try {
188
- client.write(`event: workspace_stopped\ndata: {}\n\n`);
189
+ sseWrite(client, `event: workspace_stopped\ndata: {}\n\n`);
189
190
  } catch {}
190
191
  });
191
192
 
package/server/server.js CHANGED
@@ -9,6 +9,7 @@ import { execFile, exec, spawn } from 'node:child_process';
9
9
  import { promisify } from 'node:util';
10
10
  import { Worker } from 'node:worker_threads';
11
11
  import { isPathContained } from './lib/file-api.js';
12
+ import { sseWrite, isWireV3Enabled } from './lib/wire-compress.js';
12
13
  import { setEntry as askStoreSetEntry, deleteEntry as askStoreDeleteEntry, pruneStale as askStorePruneStale, markAnswered as askStoreMarkAnswered, markCancelled as askStoreMarkCancelled, loadAskStore as askStoreLoad } from './lib/ask-store.js';
13
14
  import { ASK_TIMEOUT_MS, ASK_WAITER_REAP_INTERVAL_MS, ASK_WAITER_LIVENESS_MS } from './lib/ask-constants.js';
14
15
  import { reapDeadAskWaiters, sweepOrphanedDiskAsks } from './lib/ask-reaper.js';
@@ -33,6 +34,7 @@ import { searchRoutes } from './routes/search.js';
33
34
  import { workspacesRoutes } from './routes/workspaces.js';
34
35
  import { expertRoutes } from './routes/expert.js';
35
36
  import { eventsRoutes } from './routes/events.js';
37
+ import { v2Routes } from './routes/v2.js';
36
38
  import { askPermRoutes } from './routes/ask-perm.js';
37
39
  import { teamRoutes } from './routes/team.js';
38
40
  import { authRoutes } from './routes/auth.js';
@@ -315,6 +317,22 @@ const MAX_POST_BODY = 10 * 1024 * 1024;
315
317
  // 防止长会话把数十 MB 历史一次性灌进浏览器导致 renderer OOM。
316
318
  // 用户显式 ?limit=0 可恢复全量加载(power-user 逃生口)。
317
319
  const DEFAULT_EVENTS_LIMIT = 1000;
320
+ // Wire v3 flag (V3.S6: DEFAULT ON) — read ONCE at startup; a read/UI-shape
321
+ // toggle the server and client must agree on per process, not per request
322
+ // (unlike wire-compress's per-connection content negotiation). Broadcast to
323
+ // clients via the server_config SSE frame; tests inject deps.wireV3 directly.
324
+ // CCV_WIRE_V3=0 (or =off) is the escape hatch back to the legacy full-entry
325
+ // wire — kept (not deleted) for one release cycle before the legacy live UI
326
+ // path is removed; v1 legacy FILES always use the legacy pipeline regardless.
327
+ const WIRE_V3 = isWireV3Enabled(process.env.CCV_WIRE_V3);
328
+ // Build stamp broadcast in server_config: a tab that survives a server
329
+ // upgrade reconnects with a STALE JS bundle that lacks the new wire's
330
+ // listeners (silent empty session). The fresh bundle compares this stamp on
331
+ // reconnect and reloads itself on mismatch (review P2-a; heals upgrades from
332
+ // this version onward — older bundles predate the guard).
333
+ const SERVER_BUILD = (() => {
334
+ try { return JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf-8')).version || ''; } catch { return ''; }
335
+ })();
318
336
  // SSE 单客户端 backpressure 容忍上限:连续未排空 > 此时长则视为 dead 客户端剔除。
319
337
  // 调高至 30s:大会话首屏/重连重放时,渲染器(尤其 Windows 浏览器,大 DOM layout 更重)
320
338
  // 可能短暂忙到来不及排空 socket。过早剔除会触发「断开→EventSource 自动重连→再次重放」
@@ -427,6 +445,7 @@ function _ensureV2LiveFeed() {
427
445
  runParallelHook,
428
446
  // The stats worker's v2 units are session dirs (stats-worker.js v9).
429
447
  notifyStatsWorker,
448
+ wireV3: WIRE_V3,
430
449
  });
431
450
  }
432
451
  return _v2LiveFeed;
@@ -591,6 +610,8 @@ const deps = {
591
610
  WINDOWS_RESERVED_NAMES,
592
611
  DEFAULT_EVENTS_LIMIT,
593
612
  SSE_BACKPRESSURE_TIMEOUT_MS,
613
+ wireV3: WIRE_V3,
614
+ serverBuild: SERVER_BUILD,
594
615
  IGNORED_PATTERNS,
595
616
  isCliMode,
596
617
  isSdkMode,
@@ -623,6 +644,7 @@ const _routes = [
623
644
  ...workspacesRoutes,
624
645
  ...expertRoutes,
625
646
  ...eventsRoutes,
647
+ ...v2Routes,
626
648
  ...askPermRoutes,
627
649
  ...teamRoutes,
628
650
  ...dingtalkRoutes,
@@ -2375,7 +2397,7 @@ if (!isWorkspaceMode) {
2375
2397
  pendingMajorUpdate = { version: result.remoteVersion, source: result.status };
2376
2398
  const payload = JSON.stringify(pendingMajorUpdate);
2377
2399
  clients.forEach(client => {
2378
- try { client.write(`event: update_major_available\ndata: ${payload}\n\n`); } catch { }
2400
+ try { sseWrite(client, `event: update_major_available\ndata: ${payload}\n\n`); } catch { }
2379
2401
  });
2380
2402
  } else if (result.status === 'upgrading_in_background') {
2381
2403
  console.error(`[CC Viewer] background upgrade to ${result.remoteVersion} started (active after next launch)`);
@@ -0,0 +1,26 @@
1
+ // 从 URL 中提取 LAN 访问 token,附加到所有 API 请求 / WebSocket 握手
2
+ const _urlToken = new URLSearchParams(window.location.search).get('token');
3
+
4
+ // 反向代理子路径部署: 从 <base> 标签或 SSR 注入的全局变量读取 base path,
5
+ // 使 API/WebSocket 等动态请求也能正确路由到代理后端。
6
+ export function getBasePath() {
7
+ if (typeof window !== 'undefined' && window.__CCV_BASE_PATH__) return window.__CCV_BASE_PATH__;
8
+ if (typeof document === 'undefined' || !document.querySelector) return '';
9
+ const base = document.querySelector('base');
10
+ if (base && base.href) return base.getAttribute('href');
11
+ return '';
12
+ }
13
+
14
+ // 把 token 追加到任意 URL(HTTP path 或 ws:// 完整 URL 皆可)。无 token 时原样返回。
15
+ // WS 握手必须也带 token —— 否则启用鉴权后远程「?token=」终端会被 socket.destroy()。
16
+ export function appendToken(url) {
17
+ if (!_urlToken) return url;
18
+ const sep = url.includes('?') ? '&' : '?';
19
+ return `${url}${sep}token=${_urlToken}`;
20
+ }
21
+
22
+ export function apiUrl(path) {
23
+ const base = getBasePath();
24
+ const fullPath = base ? base.replace(/\/$/, '') + path : path;
25
+ return appendToken(fullPath);
26
+ }
@@ -0,0 +1,21 @@
1
+ // Decision helper for ApprovalModal's fallback AskQuestionForm.
2
+ //
3
+ // The Question modal body is normally filled by the transcript tool_use block
4
+ // portaling an AskQuestionForm into the modal's ask slot (see askPortalMatcher.js).
5
+ // When no block portals in — a stale replayed ask whose block is old history, or a
6
+ // fresh ask arriving before transcript ingest — the modal would show an empty body.
7
+ // The fallback renders a form directly from the authoritative pending-ask broadcast.
8
+ //
9
+ // Contract: `slotOccupied` is DOM occupancy of the ask slot (MutationObserver on
10
+ // childList + sync initial read); `graceElapsed` flips true ASK_FALLBACK_GRACE_MS
11
+ // after the ask becomes visible, giving the real portal one commit cycle so fresh
12
+ // asks never flash the fallback before the portaled form mounts.
13
+
14
+ export const ASK_FALLBACK_GRACE_MS = 120;
15
+
16
+ export function shouldRenderAskFallback({ isAskActive, slotOccupied, graceElapsed, questions, submitHandler }) {
17
+ if (!isAskActive || slotOccupied || !graceElapsed) return false;
18
+ if (!Array.isArray(questions) || questions.length === 0) return false;
19
+ if (typeof submitHandler !== 'function') return false;
20
+ return true;
21
+ }
@@ -0,0 +1,55 @@
1
+ import { isPlaceholderAskId } from './askPortalMatcher.js';
2
+
3
+ // AskUserQuestion options[].description is schema-optional; centralize fallback
4
+ // so AskQuestionForm and ChatMessage recap stay aligned.
5
+
6
+ export function optionAriaLabel(opt) {
7
+ if (!opt || opt.label == null) return '';
8
+ return opt.description
9
+ ? `${opt.label}: ${opt.description}`
10
+ : String(opt.label);
11
+ }
12
+
13
+ export function hasOptionDescription(opt) {
14
+ return Boolean(opt && opt.description);
15
+ }
16
+
17
+ // Pick the questions the interactive AskUserQuestion card actually renders.
18
+ //
19
+ // The modal body is filled by the inline tool_use block (reconstructed from the log
20
+ // stream) portaling into the modal's askSlot — it does not read pendingAsk.questions
21
+ // (the authoritative copy broadcast over WS ask-hook-pending / sdk-ask-pending, fully
22
+ // parsed server-side before the hook fires) directly. During streaming of a large
23
+ // payload there is a window where the modal is already open but the reconstructed
24
+ // block's input is still partial JSON. Crucially, partial-JSON parsing materializes
25
+ // the outer questions[] array shape before the element content arrives, so the
26
+ // streamed copy can be HOLLOW AT EQUAL LENGTH (e.g. two question objects with empty
27
+ // text/options while big options[].preview strings are still streaming — the
28
+ // 2026-07-04 blank-popup regression; a strictly-longer length heuristic missed it).
29
+ //
30
+ // So: for the currently pending ask (toolId === lastPendingAskId, id-matched), always
31
+ // prefer the authoritative copy. It is complete by construction, and the submit path
32
+ // (handleAskQuestionSubmit in askFlowController) maps answer indices through the same
33
+ // authoritative _askHookQuestions copy — rendering it keeps rendered indices aligned
34
+ // with the mapping array. Historical blocks never match lastPendingAskId and always
35
+ // keep their own streamed questions (history view unchanged).
36
+ //
37
+ // Legacy/no-id servers use placeholder pendingAsk ids ('__ask__', 'ask_<ts>_<rnd>')
38
+ // that can never equal the real tool_use id. There the authoritative copy is only
39
+ // borrowed when the streamed render would otherwise be completely empty — it can fill
40
+ // a blank popup but never override visible content (a stale legacy pendingAsk must
41
+ // not inject a previous ask's questions over a rendered one).
42
+ export function resolveAskQuestions(streamedQuestions, toolId, lastPendingAskId, pendingAsk) {
43
+ const streamed = Array.isArray(streamedQuestions) ? streamedQuestions : [];
44
+ if (!pendingAsk || toolId == null || toolId !== lastPendingAskId) {
45
+ return streamed;
46
+ }
47
+ const idMatches = pendingAsk.id === toolId;
48
+ if (!idMatches && !(isPlaceholderAskId(pendingAsk.id) && streamed.length === 0)) {
49
+ return streamed;
50
+ }
51
+ const authoritative = Array.isArray(pendingAsk.questions) ? pendingAsk.questions : [];
52
+ if (authoritative.length === 0) return streamed; // defensive: WS handlers guarantee non-empty
53
+ if (streamed.length > authoritative.length) return streamed; // never shrink (belt-and-braces)
54
+ return authoritative;
55
+ }
@@ -0,0 +1,54 @@
1
+ /**
2
+ * Portal 决策:判断 inline AskQuestionForm(在 ChatMessage 里渲染的某条 tool_use)
3
+ * 是否应该 createPortal 到 ApprovalModal 的 askSlot。
4
+ *
5
+ * activeAskId 三种合法形态(与 server 端协议绑定,改一边必须改另一边):
6
+ *
7
+ * 1. `toolu_xxx` — SDK 模式 / 新版 Claude Code 透传的真实 tool_use_id
8
+ * → 走 strict 匹配:activeAskId === toolId
9
+ *
10
+ * 2. `__ask__` — LEGACY 老 server(pre-Map 协议)的单槽占位
11
+ * → 走 owner 通配:仅 owner-idx 锁定的那条 ChatMessage(lastPendingAskId === toolId)命中
12
+ *
13
+ * 3. `ask_${ts}_${rnd}` — 老 Claude Code PreToolUse hook payload 不带 tool_use_id 时
14
+ * server.js 自生成的 fallback id(修复 modal 标题在但内容空白的根因)
15
+ * → 走 owner 通配:与 __ask__ 同语义
16
+ *
17
+ * 协议锚点:
18
+ * - server 端 fallback id 生成:server.js(搜索 `ask_${Date.now()}` 定位)
19
+ * - server 端 LEGACY 占位推送:server.js 推送 ask-hook-pending 事件时 id 为 '__ask__'
20
+ * - 前端 setState pendingAsk:src/components/chat/ChatView.jsx
21
+ *
22
+ * 改 server 端 fallback id 格式时,必须同步更新此文件的 isFallbackId 检测。
23
+ */
24
+
25
+ const FALLBACK_ID_PREFIX = 'ask_';
26
+ const LEGACY_PLACEHOLDER_ID = '__ask__';
27
+
28
+ /**
29
+ * True when the given pending-ask id is a placeholder that can never equal a real
30
+ * tool_use id: the LEGACY single-slot '__ask__' or a server-generated 'ask_<ts>_<rnd>'
31
+ * fallback id. Consumers (e.g. resolveAskQuestions) use this to apply the same
32
+ * owner-wildcard semantics as shouldPortalAskForm on the legacy paths.
33
+ */
34
+ export function isPlaceholderAskId(id) {
35
+ return id === LEGACY_PLACEHOLDER_ID
36
+ || (typeof id === 'string' && id.startsWith(FALLBACK_ID_PREFIX));
37
+ }
38
+
39
+ /**
40
+ * @param {*} activeAskId ApprovalPortalContext.Consumer 提供的 activeAskId(modal 当前 active ask 的 id)
41
+ * @param {*} toolId 当前 ChatMessage 里 tool_use 块的 id(toolu_xxx)
42
+ * @param {*} lastPendingAskId ChatMessage 收到的 owner-idx 派生信号;非 owner 永远为 null
43
+ * @returns {boolean} true 表示当前 ChatMessage 应该 portal 到 askSlot
44
+ */
45
+ export function shouldPortalAskForm(activeAskId, toolId, lastPendingAskId) {
46
+ if (activeAskId == null) return false;
47
+ // strict:SDK / 新 Claude Code 真实 tool_use_id
48
+ if (String(activeAskId) === String(toolId)) return true;
49
+ // owner 通配:__ask__(LEGACY 老 server)∪ ask_*(server fallback)
50
+ // 必须同时满足 owner-idx 锁定(lastPendingAskId === toolId),否则会让历史所有真实 toolId
51
+ // 都被通配命中,重现旧的双份 portal bug
52
+ if (isPlaceholderAskId(activeAskId) && lastPendingAskId === toolId) return true;
53
+ return false;
54
+ }