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
package/dist/index.html CHANGED
@@ -21,7 +21,7 @@
21
21
  // 整体显示大小已弃用 CSS zoom:Electron 改用 webFrame.setZoomFactor(首屏抢占见
22
22
  // electron/tab-content-preload.js),纯浏览器交由用户用浏览器自带快捷键缩放,故此处不再设 zoom。
23
23
  </script>
24
- <script type="module" crossorigin src="./assets/index-Ac8vnV3v.js"></script>
24
+ <script type="module" crossorigin src="./assets/index-B51Ci0GL.js"></script>
25
25
  <link rel="modulepreload" crossorigin href="./assets/vendor-antd-DI7JL-mE.js">
26
26
  <link rel="modulepreload" crossorigin href="./assets/vendor-codemirror-B9c49dtM.js">
27
27
  <link rel="modulepreload" crossorigin href="./assets/vendor-mdxeditor-B6cpBtIE.js">
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cc-viewer",
3
- "version": "1.7.0",
3
+ "version": "1.7.1",
4
4
  "description": "Claude Code logging, visualization, and management toolkit — launch a web viewer alongside Claude Code with full request/response tracing, proxy, and mobile support",
5
5
  "license": "MIT",
6
6
  "main": "server.js",
@@ -66,6 +66,7 @@
66
66
  "findcc.js",
67
67
  "interceptor.js",
68
68
  "server/",
69
+ "src/utils/",
69
70
  "plugins/",
70
71
  "concepts/",
71
72
  "ultraAgents/"
@@ -4,6 +4,7 @@ import { buildContextWindowEvent, getContextSizeForModel } from './context-watch
4
4
  import { reconstructEntries } from './delta-reconstructor.js';
5
5
  import { enrichEntry } from './enrich-plan-input.js';
6
6
  import { enrichEntry as enrichWorkflowEntry } from './enrich-workflow.js';
7
+ import { sseWrite } from './wire-compress.js';
7
8
 
8
9
  // 1.7.0: the v1 log-file tail (fs.watch + byte cursors + rotation follow)
9
10
  // retired with the v1 write path — the live channel is server/lib/v2/
@@ -60,7 +61,11 @@ function _safeSseWrite(clients, client, payload) {
60
61
  }
61
62
  let ok;
62
63
  try {
63
- ok = client.write(payload);
64
+ // sseWrite routes through the client's negotiated Content-Encoding
65
+ // (wire-compress.js) — a bare client.write here would corrupt compressed
66
+ // streams. The drain listener below stays on the res: the encoder is
67
+ // piped into it, so socket drain still propagates.
68
+ ok = sseWrite(client, payload);
64
69
  } catch {
65
70
  _removeClient(clients, client);
66
71
  return false;
@@ -68,9 +73,18 @@ function _safeSseWrite(clients, client, payload) {
68
73
  if (!ok) {
69
74
  if (!client._sseBackpressureSince) {
70
75
  client._sseBackpressureSince = Date.now();
71
- client.once('drain', () => { client._sseBackpressureSince = 0; });
76
+ // On compressed clients the false write() came from the ENCODER (its
77
+ // input buffer), whose own 'drain' fires when compression catches up —
78
+ // the res may never emit 'drain' at all (compressed output is 20-80x
79
+ // smaller than what the socket can absorb). Arming the reset on the
80
+ // wrong stream would let the timeout below kill healthy clients.
81
+ const pressured = client._wireEnc || client;
82
+ pressured.once('drain', () => { client._sseBackpressureSince = 0; });
72
83
  } else if (Date.now() - client._sseBackpressureSince > SSE_BACKPRESSURE_TIMEOUT_MS) {
73
84
  _removeClient(clients, client);
85
+ // Destroy the encoder first: end() alone leaves the 16MB brotli window
86
+ // alive until TCP teardown of the already-stuck socket.
87
+ try { if (client._wireEnc) client._wireEnc.destroy(); } catch {}
74
88
  try { client.end(); } catch {}
75
89
  return false;
76
90
  }
@@ -92,6 +106,15 @@ export function sendEventToClients(clients, eventName, data) {
92
106
  }
93
107
  }
94
108
 
109
+ /** Like sendEventToClients but `json` is an ALREADY-serialized JSON string —
110
+ * wire v3 forwards raw stored lines verbatim without a parse/stringify trip. */
111
+ export function sendEventRawToClients(clients, eventName, json) {
112
+ const payload = `event: ${eventName}\ndata: ${json}\n\n`;
113
+ for (let i = clients.length - 1; i >= 0; i--) {
114
+ _safeSseWrite(clients, clients[i], payload);
115
+ }
116
+ }
117
+
95
118
  export function sendChunkToClients(clients, dataJson) {
96
119
  const payload = `event: load_chunk\ndata: ${dataJson}\n\n`;
97
120
  for (let i = clients.length - 1; i >= 0; i--) {
@@ -115,7 +138,10 @@ export function processWatchedEntry(parsed, ctx) {
115
138
  reconstructor.reconstruct(parsed);
116
139
  try { enrichEntry(parsed); } catch {}
117
140
  try { enrichWorkflowEntry(parsed); } catch {}
118
- sendToClients(clients, parsed);
141
+ // Wire v3 (V3.S5): the flagged v2 live feed suppresses the full-entry
142
+ // broadcast — clients rebuild entries from native lines + rows. The kv/
143
+ // context side events below still fire (they are slim and file-agnostic).
144
+ if (!ctx.suppressEntryBroadcast) sendToClients(clients, parsed);
119
145
  runParallelHook('onNewEntry', parsed).catch(() => {});
120
146
  if (isMainAgentEntry(parsed) && !parsed.inProgress) {
121
147
  const cached = extractCachedContent(parsed);
@@ -402,12 +402,30 @@ export class SessionSynthesizer {
402
402
  // ---- body (blob backfill is per-request by journal ref — never carried) --
403
403
  const tools = this._loadBlob(req.blobs && req.blobs.tools);
404
404
  const system = this._loadBlob(req.blobs && req.blobs.sys);
405
+ // req.params carries every residual body field (params-era journals);
406
+ // dedicated fields override it so precedence is deterministic. user_id is
407
+ // ALWAYS meta.userIdRaw — the client does equality-based session-boundary
408
+ // detection on it, and a `-c` adopted folder mixes real user_ids that
409
+ // would otherwise split the timeline mid-session.
410
+ const params = req.params && typeof req.params === 'object' ? req.params : null;
405
411
  const body = {
412
+ ...params,
406
413
  ...(req.model && { model: req.model }),
407
414
  ...(system !== undefined && { system }),
408
415
  ...(tools !== undefined && { tools }),
409
- ...(meta.userIdRaw && { metadata: { user_id: meta.userIdRaw } }),
410
416
  };
417
+ if (meta.userIdRaw) {
418
+ const extra = body.metadata && typeof body.metadata === 'object' ? body.metadata : null;
419
+ body.metadata = { ...extra, user_id: meta.userIdRaw };
420
+ } else if (body.metadata && typeof body.metadata === 'object' && body.metadata.user_id !== undefined) {
421
+ // noid session (no parseable user_id at write time): a raw params
422
+ // user_id may vary per request, and surfacing it would re-open the
423
+ // spurious client boundary-split this block exists to prevent. Drop
424
+ // ONLY user_id; other metadata keys keep full fidelity.
425
+ const { user_id, ...restMeta } = body.metadata;
426
+ if (Object.keys(restMeta).length > 0) body.metadata = restMeta;
427
+ else delete body.metadata;
428
+ }
411
429
 
412
430
  const isTeammateEntry = isTeammate;
413
431
  const isMainKind = isMain;
@@ -523,6 +541,7 @@ export class SessionSynthesizer {
523
541
  }
524
542
  entry.response = {
525
543
  ...(typeof done.http === 'number' && { status: done.http }),
544
+ ...(resp && resp.statusText && { statusText: resp.statusText }),
526
545
  ...(resp && resp.headers && { headers: resp.headers }),
527
546
  body: resp ? resp.body : null,
528
547
  };
@@ -646,7 +665,7 @@ export function findTeammateSessionDirs(sessionDir, leaderUuid) {
646
665
  * Item-level iteration of one v2 session with teammate re-join — the shared
647
666
  * core of the raw-string generators and the window reader below.
648
667
  */
649
- function* iterateV2Items(sessionDir, opts = {}) {
668
+ export function* iterateV2Items(sessionDir, opts = {}) {
650
669
  const streams = [iterateSessionItems(sessionDir, opts)];
651
670
  const ownMeta = (() => {
652
671
  try { return JSON.parse(readFileSync(join(sessionDir, 'meta.json'), 'utf-8')); } catch { return null; }
@@ -735,7 +754,7 @@ export function _v2WindowStatsForTest(reset = false) {
735
754
  * The result is IMMUTABLE and shared across coalesced callers — window
736
755
  * selection must not mutate `recs`/`ring`.
737
756
  */
738
- async function scanV2Descriptors(sessionDir) {
757
+ export async function scanV2Descriptors(sessionDir) {
739
758
  _windowStats.scanRuns++;
740
759
  const dedup = new Map(); // key → descriptor (insertion order = output order)
741
760
  const ring = []; // newest ≤3 isMain descriptors, pre-dedup iteration order
@@ -796,7 +815,7 @@ function selectV2Window(scan, opts = {}) {
796
815
 
797
816
  /** Level-1 shared Pass A + per-caller selection. `cached` gates the TTL
798
817
  * micro-cache read (F5b: /events live-attach must never consume it). */
799
- async function computeV2Window(sessionDir, opts = {}) {
818
+ export async function computeV2Window(sessionDir, opts = {}) {
800
819
  const scan = await _scanFlight.run(`scan:${sessionDir}`, () => scanV2Descriptors(sessionDir), { cached: !!opts.cached });
801
820
  return selectV2Window(scan, opts);
802
821
  }
@@ -813,7 +832,7 @@ async function computeV2Window(sessionDir, opts = {}) {
813
832
  * equivalent (algorithm review F6). `collect` mode fills a slot array in
814
833
  * Pass A output order instead: byte-identical to the historical single pass.
815
834
  */
816
- async function materializeV2Window(sessionDir, win, { collect = false, onEntry = null } = {}) {
835
+ async function materializeV2Window(sessionDir, win, { collect = false, onEntry = null, promoteKeys = null } = {}) {
817
836
  const memberIdx = new Map();
818
837
  win.members.forEach((d, i) => memberIdx.set(itemKey(d.sessionId, d.seq), i));
819
838
  const ringKeys = new Set(win.ring.map((d) => itemKey(d.sessionId, d.seq)));
@@ -837,7 +856,12 @@ async function materializeV2Window(sessionDir, win, { collect = false, onEntry =
837
856
  // replayed state (item.stateRef aliases the live conversation state at
838
857
  // this seq — used transiently, never retained). Same construction as the
839
858
  // historical end-of-window rebuild, byte-for-byte.
840
- if (k === win.baselineKey) {
859
+ // `promoteKeys` (V3.S1): per-member force-checkpoint for single-entry
860
+ // reads — a mid-session main delta carries only its appended slice, but
861
+ // the detail view needs the full replayed state for BOTH the target and
862
+ // its prevMain (Body Diff / cacheLoss compare whole messages arrays).
863
+ // The window baseline promotes only win.baselineKey (the window start).
864
+ if (k === win.baselineKey || (promoteKeys && promoteKeys.has(k))) {
841
865
  const state = item.stateRef || [];
842
866
  const rebuilt = { ...item.entry, body: { ...item.entry.body, messages: state } };
843
867
  rebuilt._isCheckpoint = true;
@@ -889,6 +913,47 @@ async function materializeV2Window(sessionDir, win, { collect = false, onEntry =
889
913
  * @param {{limit?: number, before?: string|null, cached?: boolean}} [opts]
890
914
  * @returns {Promise<{entries: string[], hasMore: boolean, oldestTimestamp: string, totalCount: number, mainAgentRing: string[]}>}
891
915
  */
916
+ /**
917
+ * V3.S1: on-demand single-entry read — the detail view's data source once the
918
+ * request list is metadata rows. Locates the target (sessionId, seq) in the
919
+ * shared descriptor scan, pairs it with its preceding isMain descriptor
920
+ * (Body Diff / Context tab need the previous mainAgent, raw-seq adjacency),
921
+ * and materializes exactly those members with per-member checkpoint promotion
922
+ * (main deltas come back with the full replayed state, matching what the
923
+ * client holds after reconstruction on the legacy channel).
924
+ *
925
+ * @param {string} sessionDir - leader session dir (teammate rows resolve
926
+ * through the leader's k-way fold so tags/order match the list)
927
+ * @param {{seq: number, sessionId?: string|null, cached?: boolean}} opts -
928
+ * sessionId (UUID) disambiguates teammate rows; null = any session's seq
929
+ * @returns {Promise<{entry: string, prevMain: string|null}|null>} raw JSON
930
+ * strings (never re-parsed here), or null when the seq is unknown / gated
931
+ * out by synthesis (crash-orphan) — callers answer 404.
932
+ */
933
+ export async function readV2SingleEntry(sessionDir, opts = {}) {
934
+ const seq = opts.seq;
935
+ const sessionId = opts.sessionId || null;
936
+ const key = `entry:${sessionDir}|${sessionId || ''}|${seq}`;
937
+ return _windowFlight.run(key, async () => {
938
+ const scan = await _scanFlight.run(`scan:${sessionDir}`, () => scanV2Descriptors(sessionDir), { cached: true });
939
+ const recs = scan.recs;
940
+ const idx = recs.findIndex((d) => d.seq === seq && (!sessionId || d.sessionId === sessionId));
941
+ if (idx === -1) return null;
942
+ const target = recs[idx];
943
+ let prevMain = null;
944
+ for (let i = idx - 1; i >= 0; i--) {
945
+ if (recs[i].isMain) { prevMain = recs[i]; break; }
946
+ }
947
+ const members = prevMain ? [prevMain, target] : [target];
948
+ const win = { members, ring: [], baselineKey: null };
949
+ const promoteKeys = new Set(members.filter((d) => d.isMainDelta).map((d) => itemKey(d.sessionId, d.seq)));
950
+ const { slots } = await materializeV2Window(sessionDir, win, { collect: true, promoteKeys });
951
+ const entry = slots[members.length - 1];
952
+ if (entry === undefined) return null; // synthesis gate dropped it between passes
953
+ return { entry, prevMain: prevMain ? (slots[0] ?? null) : null };
954
+ }, { cached: opts.cached !== false });
955
+ }
956
+
892
957
  export async function readV2WindowedEntries(sessionDir, opts = {}) {
893
958
  const cached = !!opts.cached;
894
959
  const key = `win:${sessionDir}|${opts.limit || 0}|${opts.before || ''}`;
@@ -26,8 +26,10 @@ import { watch, existsSync, readdirSync, statSync, openSync, readSync, closeSync
26
26
  import { join } from 'node:path';
27
27
  import { reportSwallowed } from '../error-report.js';
28
28
  import { createIncrementalReconstructor } from '../delta-reconstructor.js';
29
- import { processWatchedEntry } from '../log-watcher.js';
29
+ import { processWatchedEntry, sendEventToClients, sendEventRawToClients } from '../log-watcher.js';
30
30
  import { SessionSynthesizer } from './adapter.js';
31
+ import { computeCacheLoss } from './meta-rows.js';
32
+ import { classifyRequest } from '../../../src/utils/requestType.js';
31
33
 
32
34
  const FSWATCH_DEBOUNCE_MS = 80;
33
35
  const SAFETY_POLL_MS = 5000;
@@ -91,6 +93,10 @@ export class V2LiveFeed {
91
93
  this._safetyPollMs = typeof opts.safetyPollMs === 'number' ? opts.safetyPollMs : SAFETY_POLL_MS;
92
94
  this._deferMs = typeof opts.deferMs === 'number' ? opts.deferMs : DEFER_MS;
93
95
  this._now = opts.now || Date.now;
96
+ // Wire v3 (V3.S2): when on, every emitted item ALSO broadcasts a metadata
97
+ // row (v2_requests_delta). Explicit ctor param — this module has no access
98
+ // to the server deps object.
99
+ this._wireV3 = !!opts.wireV3;
94
100
  this._sessions = new Map(); // dir → cursor bundle
95
101
  this._seenDirs = new Map(); // dir → last observed journal mtimeMs (attached or not)
96
102
  this._sessionsRoot = null;
@@ -299,7 +305,13 @@ export class V2LiveFeed {
299
305
  this._feedConvFiles(cur);
300
306
  const respLines = readNewLines(cur.responses);
301
307
  if (respLines === null) { this._rebuildCursor(cur); return; }
302
- for (const raw of respLines) cur.synth.ingestResponseLine(raw);
308
+ for (const raw of respLines) {
309
+ cur.synth.ingestResponseLine(raw);
310
+ // Wire v3 (V3.S4): forward the raw stored line — the flagged client
311
+ // assembles v1-shape entries from native lines locally. Suppressed
312
+ // during seed (history is covered by the cold-load channel).
313
+ if (this._wireV3 && !cur.suppress) this._forwardNative(cur, 'resp', null, raw);
314
+ }
303
315
  const journalLines = readNewLines(cur.journal);
304
316
  if (journalLines === null) { this._rebuildCursor(cur); return; }
305
317
  for (const raw of journalLines) {
@@ -351,7 +363,10 @@ export class V2LiveFeed {
351
363
  try { ev = JSON.parse(raw); } catch (err) {
352
364
  reportSwallowed('v2-live.conv-parse', new Error(`${cur.dir}/${key}: ${err.message}`));
353
365
  }
354
- if (ev) cur.synth.ingestConvLine(key, ev);
366
+ if (ev) {
367
+ cur.synth.ingestConvLine(key, ev);
368
+ if (this._wireV3 && !cur.suppress) this._forwardNative(cur, 'conv', key, raw);
369
+ }
355
370
  }
356
371
  }
357
372
  }
@@ -403,17 +418,109 @@ export class V2LiveFeed {
403
418
  clients: this._clients,
404
419
  getClaudePid: this._getClaudePid,
405
420
  runParallelHook: this._runParallelHook,
421
+ suppressEntryBroadcast: this._wireV3,
406
422
  });
407
423
  emitted++;
408
424
  } catch (err) {
409
425
  reportSwallowed('v2-live.emit', err);
410
426
  }
427
+ // Wire v3: derive the metadata row from the post-reconstruction entry
428
+ // (main deltas carry full messages here — classification/cacheLoss get
429
+ // the same inputs the legacy list sees). Row-level errors must not
430
+ // disturb the legacy broadcast above.
431
+ if (this._wireV3) {
432
+ try {
433
+ this._emitRow(cur, item, parsed);
434
+ } catch (err) {
435
+ reportSwallowed('v2-live.row', err);
436
+ }
437
+ }
411
438
  }
412
439
  if (emitted > 0 && this._notifyStatsWorker) {
413
440
  try { this._notifyStatsWorker(cur.dir); } catch (err) { reportSwallowed('v2-live.stats-notify', err); }
414
441
  }
415
442
  }
416
443
 
444
+ /** Wire v3 (V3.S4): broadcast one raw native line (conv event or responses
445
+ * line) — the flagged client's assembler replays these into v1-shape
446
+ * entries. `raw` is forwarded verbatim inside a JSON envelope carrying the
447
+ * session identity + channel. sessionId comes from the synthesizer (the
448
+ * UUID, matching the rows' sessionId and the entries' _seqEpoch). */
449
+ _forwardNative(cur, kind, channel, raw) {
450
+ try {
451
+ const payload = kind === 'conv'
452
+ ? `{"sessionId":${JSON.stringify(cur.synth.sessionId)},"channel":${JSON.stringify(channel)},"line":${raw}}`
453
+ : `{"sessionId":${JSON.stringify(cur.synth.sessionId)},"line":${raw}}`;
454
+ const eventName = kind === 'conv' ? 'v3_conv' : 'v3_resp';
455
+ sendEventRawToClients(this._clients, eventName, payload);
456
+ } catch (err) {
457
+ reportSwallowed('v2-live.native-forward', err);
458
+ }
459
+ }
460
+
461
+ /** Wire v3 (V3.S2): one metadata row per emitted item, derived from the
462
+ * post-reconstruction entry. Rows upsert client-side by (sessionId, seq):
463
+ * a placeholder row precedes its completed row, and a correction re-send
464
+ * follows when the NEXT request's arrival changes the previous row's
465
+ * classification (classifyRequest's Preflight/Plan cases read nextReq). */
466
+ _rowFrom(item, parsed) {
467
+ const usage = parsed.response?.body?.usage || null;
468
+ return {
469
+ seq: item.seq,
470
+ sessionId: item.sessionId,
471
+ timestamp: parsed.timestamp || '',
472
+ url: parsed.url || '',
473
+ method: parsed.method || 'POST',
474
+ kind: item.isMain ? 'main' : (parsed.teammate ? 'teammate' : 'sub'),
475
+ mainAgent: parsed.mainAgent === true,
476
+ teammate: parsed.teammate || undefined,
477
+ model: parsed.body?.model,
478
+ proxyUrl: parsed.proxyUrl || undefined,
479
+ status: parsed.response?.status,
480
+ duration: parsed.duration,
481
+ usage: usage ? {
482
+ input_tokens: usage.input_tokens || 0,
483
+ output_tokens: usage.output_tokens || 0,
484
+ cache_read_input_tokens: usage.cache_read_input_tokens || 0,
485
+ cache_creation_input_tokens: usage.cache_creation_input_tokens || 0,
486
+ } : null,
487
+ inProgress: !!parsed.inProgress,
488
+ typeTag: null,
489
+ cacheLoss: null,
490
+ };
491
+ }
492
+
493
+ _emitRow(cur, item, parsed) {
494
+ const row = this._rowFrom(item, parsed);
495
+ try {
496
+ const tag = classifyRequest(parsed, null);
497
+ row.typeTag = tag ? { type: tag.type, subType: tag.subType ?? null } : null;
498
+ } catch (err) { reportSwallowed('v2-live.row-classify', err); }
499
+ if (row.mainAgent && !row.inProgress) {
500
+ const u = parsed.response?.body?.usage;
501
+ const cw = (u && u.cache_creation_input_tokens) || 0;
502
+ const cr = (u && u.cache_read_input_tokens) || 0;
503
+ if (cur._v3PrevMain && cw > 0 && cw > cr) {
504
+ try { row.cacheLoss = computeCacheLoss(cur._v3PrevMain, parsed); } catch (err) { reportSwallowed('v2-live.row-cacheloss', err); }
505
+ }
506
+ cur._v3PrevMain = parsed; // one full entry retained per cursor (bounded)
507
+ }
508
+ // Lookahead correction: this entry is the previous row's nextReq.
509
+ const prev = cur._v3Last;
510
+ if (prev && (prev.row.sessionId !== row.sessionId || prev.row.seq !== row.seq)) {
511
+ try {
512
+ const tag = classifyRequest(prev.entry, parsed);
513
+ const corrected = tag ? { type: tag.type, subType: tag.subType ?? null } : null;
514
+ if (JSON.stringify(corrected) !== JSON.stringify(prev.row.typeTag)) {
515
+ prev.row.typeTag = corrected;
516
+ sendEventToClients(this._clients, 'v2_requests_delta', prev.row);
517
+ }
518
+ } catch (err) { reportSwallowed('v2-live.row-classify', err); }
519
+ }
520
+ cur._v3Last = { row, entry: parsed };
521
+ sendEventToClients(this._clients, 'v2_requests_delta', row);
522
+ }
523
+
417
524
  // ── safety poll ────────────────────────────────────────────────────────────
418
525
 
419
526
  _safetyTick() {