cc-viewer 1.7.0 → 1.7.2

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 (123) hide show
  1. package/dist/assets/App-B224r-8M.js +2 -0
  2. package/dist/assets/{MdxEditorPanel-C5Q6uUyH.js → MdxEditorPanel-BUubiE6Y.js} +1 -1
  3. package/dist/assets/{Mobile-MoBp9c8k.js → Mobile-CKmfRRvo.js} +1 -1
  4. package/dist/assets/index-CjsQEQc2.js +2 -0
  5. package/dist/assets/seqResourceLoaders-C-T_EMiO.js +2 -0
  6. package/dist/assets/{seqResourceLoaders-CxuD1CYl.css → seqResourceLoaders-DFrHmgnK.css} +1 -1
  7. package/dist/index.html +1 -1
  8. package/package.json +2 -1
  9. package/server/lib/log-management.js +1 -0
  10. package/server/lib/log-watcher.js +29 -3
  11. package/server/lib/stats-worker.js +21 -2
  12. package/server/lib/v2/adapter.js +123 -18
  13. package/server/lib/v2/journal.js +9 -5
  14. package/server/lib/v2/jsonl-read.js +127 -0
  15. package/server/lib/v2/live-feed.js +222 -20
  16. package/server/lib/v2/meta-rows.js +361 -0
  17. package/server/lib/v2/replay.js +5 -5
  18. package/server/lib/v2/session-select.js +69 -2
  19. package/server/lib/v2/v2-writer.js +15 -0
  20. package/server/lib/wire-compress.js +167 -0
  21. package/server/routes/events.js +96 -26
  22. package/server/routes/im.js +1 -1
  23. package/server/routes/logs.js +15 -12
  24. package/server/routes/v2.js +60 -0
  25. package/server/routes/workspaces.js +7 -6
  26. package/server/server.js +23 -1
  27. package/server/workspace-registry.js +5 -0
  28. package/src/utils/apiUrl.js +26 -0
  29. package/src/utils/askFallback.js +21 -0
  30. package/src/utils/askOptionDesc.js +55 -0
  31. package/src/utils/askPortalMatcher.js +54 -0
  32. package/src/utils/autoApproveOptions.js +27 -0
  33. package/src/utils/avatarAnimationPostPass.js +49 -0
  34. package/src/utils/builtinPresets.js +18 -0
  35. package/src/utils/clearCheckpoint.js +7 -0
  36. package/src/utils/commandValidator.js +45 -0
  37. package/src/utils/contentFilter.js +620 -0
  38. package/src/utils/contextRaw.js +18 -0
  39. package/src/utils/contextSidebarNavigation.js +16 -0
  40. package/src/utils/contextTurns.js +117 -0
  41. package/src/utils/displayScaleHelper.js +47 -0
  42. package/src/utils/effectiveModel.js +13 -0
  43. package/src/utils/entry-slim.js +563 -0
  44. package/src/utils/entryCache.js +144 -0
  45. package/src/utils/errorReport.js +6 -0
  46. package/src/utils/fileExpandedPathsStorage.js +70 -0
  47. package/src/utils/fileIcons.jsx +36 -0
  48. package/src/utils/fileOpen.js +37 -0
  49. package/src/utils/formatters.js +48 -0
  50. package/src/utils/gitApi.js +50 -0
  51. package/src/utils/gitTreeBuilder.js +19 -0
  52. package/src/utils/helpers.js +846 -0
  53. package/src/utils/identityHeal.js +99 -0
  54. package/src/utils/imConnState.js +59 -0
  55. package/src/utils/imOrigin.js +24 -0
  56. package/src/utils/imTr.js +7 -0
  57. package/src/utils/imageCompress.js +83 -0
  58. package/src/utils/imageDownscale.js +28 -0
  59. package/src/utils/imageResize.js +112 -0
  60. package/src/utils/ingestPipeline.js +47 -0
  61. package/src/utils/lazyWithReload.js +53 -0
  62. package/src/utils/markdown.js +30 -0
  63. package/src/utils/markdownIncremental.js +39 -0
  64. package/src/utils/markdownProfiler.js +113 -0
  65. package/src/utils/mdExtensionDetect.js +64 -0
  66. package/src/utils/memoryLinkParser.js +44 -0
  67. package/src/utils/modalMask.js +15 -0
  68. package/src/utils/monotime.js +6 -0
  69. package/src/utils/pinnedMenu.js +55 -0
  70. package/src/utils/presetShortcuts.js +14 -0
  71. package/src/utils/projectAlias.js +135 -0
  72. package/src/utils/promptClassifier.js +112 -0
  73. package/src/utils/promptDetect.js +196 -0
  74. package/src/utils/promptNav.js +60 -0
  75. package/src/utils/ptyChunkBuilder.js +251 -0
  76. package/src/utils/quickMenuHoverIntent.js +35 -0
  77. package/src/utils/rateLimitParser.js +119 -0
  78. package/src/utils/readResultPool.js +106 -0
  79. package/src/utils/refreshCachedItemProp.js +30 -0
  80. package/src/utils/requestType.js +208 -0
  81. package/src/utils/resizeCalc.js +21 -0
  82. package/src/utils/resolveLocalized.js +17 -0
  83. package/src/utils/searchApi.js +47 -0
  84. package/src/utils/searchReplace.js +51 -0
  85. package/src/utils/seqResourceLoaders.js +79 -0
  86. package/src/utils/sessionManager.js +471 -0
  87. package/src/utils/sessionMerge.js +180 -0
  88. package/src/utils/skillModalController.js +111 -0
  89. package/src/utils/skillsParser.js +116 -0
  90. package/src/utils/slashCommandLabels.js +80 -0
  91. package/src/utils/splitDragCalc.js +73 -0
  92. package/src/utils/stickyBottomController.js +643 -0
  93. package/src/utils/svgSanitize.js +48 -0
  94. package/src/utils/systemTags.js +47 -0
  95. package/src/utils/tClaude.js +20 -0
  96. package/src/utils/teamModalBuilder.js +351 -0
  97. package/src/utils/teamSessionParser.js +190 -0
  98. package/src/utils/teammateAvatars.js +194 -0
  99. package/src/utils/teammateDetector.js +121 -0
  100. package/src/utils/termDiag.js +114 -0
  101. package/src/utils/terminalClipboard.js +117 -0
  102. package/src/utils/terminalWriteQueue.js +398 -0
  103. package/src/utils/thinkingMerge.js +49 -0
  104. package/src/utils/toolCatalog.js +29 -0
  105. package/src/utils/toolResultBuilder.js +393 -0
  106. package/src/utils/toolResultClassifier.js +16 -0
  107. package/src/utils/toolResultCore.js +167 -0
  108. package/src/utils/toolsDiff.js +44 -0
  109. package/src/utils/toolsXmlFormatter.js +3 -0
  110. package/src/utils/ultraplanController.js +109 -0
  111. package/src/utils/ultraplanExperts.js +86 -0
  112. package/src/utils/ultraplanTemplates.js +154 -0
  113. package/src/utils/userImageRefs.js +49 -0
  114. package/src/utils/v3Assembler.js +112 -0
  115. package/src/utils/v3Rows.js +48 -0
  116. package/src/utils/voicePackPlayer.js +233 -0
  117. package/src/utils/webSearchGrouping.js +111 -0
  118. package/src/utils/workflowFormat.js +52 -0
  119. package/src/utils/workflowRuns.js +118 -0
  120. package/src/utils/workflowStore.js +94 -0
  121. package/dist/assets/App-DTkacfRW.js +0 -2
  122. package/dist/assets/index-Ac8vnV3v.js +0 -2
  123. package/dist/assets/seqResourceLoaders-Cd_TAXTb.js +0 -2
@@ -26,8 +26,11 @@ 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 { isDiscardableSession } from './session-select.js';
32
+ import { computeCacheLoss } from './meta-rows.js';
33
+ import { classifyRequest } from '../../../src/utils/requestType.js';
31
34
 
32
35
  const FSWATCH_DEBOUNCE_MS = 80;
33
36
  const SAFETY_POLL_MS = 5000;
@@ -36,11 +39,26 @@ const ATTACH_RECENT_MS = 5 * 60 * 1000; // pre-existing dirs considered "live"
36
39
  const TICK_RETRY_MS = 250; // in-process tick raced the first queue drain
37
40
  const TICK_RETRY_MAX = 8;
38
41
 
39
- /** Read newly appended complete lines from a file cursor {path, offset,
40
- * pending}. Returns an array of raw line strings (possibly empty). A file
42
+ const READ_CHUNK_BYTES = 8 * 1024 * 1024;
43
+ // Node's max string length (~512MiB) a longer line can never be decoded.
44
+ const MAX_LINE_BYTES = 0x1fffffe8;
45
+
46
+ /** Read newly appended complete lines from a file cursor {path, offset}
47
+ * (partial-line carry lives on lazily-initialized _pendBufs/_pendBytes/
48
+ * _skipLine cursor fields). Returns an array of raw line strings (possibly
49
+ * empty). A file
41
50
  * that shrank (should never happen — v2 files are append-only) returns null
42
- * so the caller can rebuild the cursor. */
43
- function readNewLines(cursor) {
51
+ * so the caller can rebuild the cursor.
52
+ *
53
+ * Chunked + byte-level newline split (issue #129 twin): the first attach to
54
+ * a session seeds from offset 0, so one whole-file read + decode would throw
55
+ * ERR_STRING_TOO_LONG on an oversized journal and crash-loop the boot path
56
+ * exactly like the cold-scan crash the streaming reader fixed. The partial
57
+ * tail line is carried on the cursor as Buffer fragments (never decoded
58
+ * until its newline lands); a single line past the string cap is skipped
59
+ * with a report instead of thrown. Exported for tests (chunkBytes seam).
60
+ */
61
+ export function readNewLines(cursor, chunkBytes = READ_CHUNK_BYTES, maxLineBytes = MAX_LINE_BYTES) {
44
62
  let size;
45
63
  try {
46
64
  size = statSync(cursor.path).size;
@@ -49,23 +67,74 @@ function readNewLines(cursor) {
49
67
  }
50
68
  if (size < cursor.offset) return null;
51
69
  if (size === cursor.offset) return [];
52
- const toRead = size - cursor.offset;
53
- const buf = Buffer.alloc(toRead);
70
+ if (cursor._pendBufs === undefined) {
71
+ cursor._pendBufs = [];
72
+ cursor._pendBytes = 0;
73
+ cursor._skipLine = false;
74
+ }
75
+ const out = [];
54
76
  let fd;
55
77
  try {
56
78
  fd = openSync(cursor.path, 'r');
57
- readSync(fd, buf, 0, toRead, cursor.offset);
79
+ const chunk = Buffer.alloc(Math.min(chunkBytes, size - cursor.offset));
80
+ while (cursor.offset < size) {
81
+ const toRead = Math.min(chunk.length, size - cursor.offset);
82
+ const n = readSync(fd, chunk, 0, toRead, cursor.offset);
83
+ if (n === 0) break;
84
+ cursor.offset += n;
85
+ const view = chunk.subarray(0, n);
86
+ let from = 0;
87
+ while (from < n) {
88
+ const nl = view.indexOf(10, from);
89
+ if (nl === -1) {
90
+ if (!cursor._skipLine) {
91
+ const restLen = n - from;
92
+ if (cursor._pendBytes + restLen > maxLineBytes) {
93
+ cursor._skipLine = true;
94
+ cursor._pendBufs = [];
95
+ cursor._pendBytes = 0;
96
+ reportSwallowed('v2-live.read-line-too-long', new Error(`${cursor.path}: line exceeds ${maxLineBytes} bytes — skipped`));
97
+ } else {
98
+ // chunk is reused next readSync — the carried slice must own its bytes
99
+ cursor._pendBufs.push(Buffer.from(view.subarray(from)));
100
+ cursor._pendBytes += restLen;
101
+ }
102
+ }
103
+ break;
104
+ }
105
+ if (cursor._skipLine) {
106
+ cursor._skipLine = false; // the oversized line ends at this newline
107
+ } else {
108
+ const seg = view.subarray(from, nl);
109
+ let text = null;
110
+ try {
111
+ if (cursor._pendBufs.length > 0) {
112
+ cursor._pendBufs.push(seg);
113
+ text = Buffer.concat(cursor._pendBufs).toString('utf-8');
114
+ } else {
115
+ text = seg.toString('utf-8');
116
+ }
117
+ } catch (err) {
118
+ reportSwallowed('v2-live.read-line-too-long', err);
119
+ }
120
+ cursor._pendBufs = [];
121
+ cursor._pendBytes = 0;
122
+ if (text !== null) {
123
+ const t = text.trim();
124
+ if (t) out.push(t);
125
+ }
126
+ }
127
+ from = nl + 1;
128
+ }
129
+ }
58
130
  } catch (err) {
131
+ // Deliver what was already parsed; offset only advanced past read bytes,
132
+ // so the next poll resumes from the failure point.
59
133
  reportSwallowed('v2-live.read', err);
60
- return [];
61
134
  } finally {
62
135
  if (fd !== undefined) { try { closeSync(fd); } catch { /* already closed */ } }
63
136
  }
64
- cursor.offset = size;
65
- const chunk = cursor.pending + buf.toString('utf-8');
66
- const parts = chunk.split('\n');
67
- cursor.pending = parts.pop() || '';
68
- return parts.map((l) => l.trim()).filter(Boolean);
137
+ return out;
69
138
  }
70
139
 
71
140
  export class V2LiveFeed {
@@ -91,8 +160,13 @@ export class V2LiveFeed {
91
160
  this._safetyPollMs = typeof opts.safetyPollMs === 'number' ? opts.safetyPollMs : SAFETY_POLL_MS;
92
161
  this._deferMs = typeof opts.deferMs === 'number' ? opts.deferMs : DEFER_MS;
93
162
  this._now = opts.now || Date.now;
163
+ // Wire v3 (V3.S2): when on, every emitted item ALSO broadcasts a metadata
164
+ // row (v2_requests_delta). Explicit ctor param — this module has no access
165
+ // to the server deps object.
166
+ this._wireV3 = !!opts.wireV3;
94
167
  this._sessions = new Map(); // dir → cursor bundle
95
168
  this._seenDirs = new Map(); // dir → last observed journal mtimeMs (attached or not)
169
+ this._discardGated = new Set(); // dirs refused by the discard gate — first real attach must NOT suppress history
96
170
  this._sessionsRoot = null;
97
171
  this._rootWatcher = null;
98
172
  this._safetyTimer = null;
@@ -120,6 +194,7 @@ export class V2LiveFeed {
120
194
  for (const cur of this._sessions.values()) this._closeCursor(cur);
121
195
  this._sessions.clear();
122
196
  this._seenDirs.clear();
197
+ this._discardGated.clear();
123
198
  this._sessionsRoot = null;
124
199
  }
125
200
 
@@ -240,6 +315,23 @@ export class V2LiveFeed {
240
315
  _attach(dir, { suppressExisting }) {
241
316
  if (this._sessions.has(dir)) return this._sessions.get(dir);
242
317
  if (!existsSync(join(dir, 'journal.jsonl'))) return null;
318
+ // Discardable sessions (quota-probe orphans — no main/teammate req, no
319
+ // meta.leader) are never followed: single choke point, every attach path
320
+ // (_initialScan / _maybeAttachNew / tick / _safetyTick / _rebuildCursor)
321
+ // funnels here and handles null. Self-healing when a dir later gains its
322
+ // first main: the leader's own dir re-attaches via tick's retry chain
323
+ // (sub-second); cross-process dirs via the 5s safety poll's mtime-bump
324
+ // scan (_seenDirs bookkeeping stays in the callers, so the bump fires).
325
+ if (isDiscardableSession(dir)) {
326
+ this._discardGated.add(dir);
327
+ return null;
328
+ }
329
+ // A dir previously refused by the discard gate is attaching for the FIRST
330
+ // time — its first renderable turn was never broadcast, so the safety
331
+ // poll's suppressExisting:true (meant for "old stale dir resumed") must
332
+ // not swallow it: cross-process producers (IM worker, second ccv) have no
333
+ // cold-load fallback for a connected client.
334
+ if (this._discardGated.delete(dir)) suppressExisting = false;
243
335
  const cur = {
244
336
  dir,
245
337
  synth: new SessionSynthesizer(dir, { deferMs: this._deferMs, now: this._now }),
@@ -248,9 +340,9 @@ export class V2LiveFeed {
248
340
  // rebase each other's accumulated baseline — v1's tail followed exactly
249
341
  // one file, so a single shared reconstructor was a new interleave surface.
250
342
  reconstructor: createIncrementalReconstructor(),
251
- journal: { path: join(dir, 'journal.jsonl'), offset: 0, pending: '' },
252
- responses: { path: join(dir, 'responses.jsonl'), offset: 0, pending: '' },
253
- convFiles: new Map(), // path → {key, offset, pending}
343
+ journal: { path: join(dir, 'journal.jsonl'), offset: 0 },
344
+ responses: { path: join(dir, 'responses.jsonl'), offset: 0 },
345
+ convFiles: new Map(), // path → {key, offset} (+ lazy _pendBufs carry)
254
346
  watcher: null,
255
347
  debounce: null,
256
348
  reading: false,
@@ -299,7 +391,13 @@ export class V2LiveFeed {
299
391
  this._feedConvFiles(cur);
300
392
  const respLines = readNewLines(cur.responses);
301
393
  if (respLines === null) { this._rebuildCursor(cur); return; }
302
- for (const raw of respLines) cur.synth.ingestResponseLine(raw);
394
+ for (const raw of respLines) {
395
+ cur.synth.ingestResponseLine(raw);
396
+ // Wire v3 (V3.S4): forward the raw stored line — the flagged client
397
+ // assembles v1-shape entries from native lines locally. Suppressed
398
+ // during seed (history is covered by the cold-load channel).
399
+ if (this._wireV3 && !cur.suppress) this._forwardNative(cur, 'resp', null, raw);
400
+ }
303
401
  const journalLines = readNewLines(cur.journal);
304
402
  if (journalLines === null) { this._rebuildCursor(cur); return; }
305
403
  for (const raw of journalLines) {
@@ -341,7 +439,7 @@ export class V2LiveFeed {
341
439
  const p = join(convRoot, key, f);
342
440
  let fc = cur.convFiles.get(p);
343
441
  if (!fc) {
344
- fc = { key, path: p, offset: 0, pending: '' };
442
+ fc = { key, path: p, offset: 0 };
345
443
  cur.convFiles.set(p, fc);
346
444
  }
347
445
  const lines = readNewLines(fc);
@@ -351,7 +449,10 @@ export class V2LiveFeed {
351
449
  try { ev = JSON.parse(raw); } catch (err) {
352
450
  reportSwallowed('v2-live.conv-parse', new Error(`${cur.dir}/${key}: ${err.message}`));
353
451
  }
354
- if (ev) cur.synth.ingestConvLine(key, ev);
452
+ if (ev) {
453
+ cur.synth.ingestConvLine(key, ev);
454
+ if (this._wireV3 && !cur.suppress) this._forwardNative(cur, 'conv', key, raw);
455
+ }
355
456
  }
356
457
  }
357
458
  }
@@ -403,17 +504,118 @@ export class V2LiveFeed {
403
504
  clients: this._clients,
404
505
  getClaudePid: this._getClaudePid,
405
506
  runParallelHook: this._runParallelHook,
507
+ suppressEntryBroadcast: this._wireV3,
406
508
  });
407
509
  emitted++;
408
510
  } catch (err) {
409
511
  reportSwallowed('v2-live.emit', err);
410
512
  }
513
+ // Wire v3: derive the metadata row from the post-reconstruction entry
514
+ // (main deltas carry full messages here — classification/cacheLoss get
515
+ // the same inputs the legacy list sees). Row-level errors must not
516
+ // disturb the legacy broadcast above.
517
+ if (this._wireV3) {
518
+ try {
519
+ this._emitRow(cur, item, parsed);
520
+ } catch (err) {
521
+ reportSwallowed('v2-live.row', err);
522
+ }
523
+ }
411
524
  }
412
525
  if (emitted > 0 && this._notifyStatsWorker) {
413
526
  try { this._notifyStatsWorker(cur.dir); } catch (err) { reportSwallowed('v2-live.stats-notify', err); }
414
527
  }
415
528
  }
416
529
 
530
+ /** Wire v3 (V3.S4): broadcast one raw native line (conv event or responses
531
+ * line) — the flagged client's assembler replays these into v1-shape
532
+ * entries. `raw` is forwarded verbatim inside a JSON envelope carrying the
533
+ * session identity + channel. sessionId comes from the synthesizer (the
534
+ * UUID, matching the rows' sessionId and the entries' _seqEpoch). */
535
+ _forwardNative(cur, kind, channel, raw) {
536
+ try {
537
+ const payload = kind === 'conv'
538
+ ? `{"sessionId":${JSON.stringify(cur.synth.sessionId)},"channel":${JSON.stringify(channel)},"line":${raw}}`
539
+ : `{"sessionId":${JSON.stringify(cur.synth.sessionId)},"line":${raw}}`;
540
+ const eventName = kind === 'conv' ? 'v3_conv' : 'v3_resp';
541
+ sendEventRawToClients(this._clients, eventName, payload);
542
+ } catch (err) {
543
+ reportSwallowed('v2-live.native-forward', err);
544
+ }
545
+ }
546
+
547
+ /** Wire v3 (V3.S2): one metadata row per emitted item, derived from the
548
+ * post-reconstruction entry. Rows upsert client-side by (sessionId, seq):
549
+ * a placeholder row precedes its completed row, and a correction re-send
550
+ * follows when the NEXT request's arrival changes the previous row's
551
+ * classification (classifyRequest's Preflight/Plan cases read nextReq). */
552
+ _rowFrom(item, parsed) {
553
+ const usage = parsed.response?.body?.usage || null;
554
+ return {
555
+ seq: item.seq,
556
+ sessionId: item.sessionId,
557
+ timestamp: parsed.timestamp || '',
558
+ url: parsed.url || '',
559
+ method: parsed.method || 'POST',
560
+ // conv/evt/kind/mainAgent mirror the cold fold (meta-rows.js foldDir) —
561
+ // journal truth, NOT re-derivation. conv is load-bearing: the client
562
+ // assembler's buildEntry is `if (row.conv)`-gated, so a conv-less live
563
+ // row rebuilt every entry with EMPTY messages (chat vanished at stream
564
+ // end). mainAgent must be kind-derived like cold: parsed.mainAgent
565
+ // re-derives from the body and mis-tags countTokens (main system/tools
566
+ // aboard) as true, which would merge its turn into the chat live-only.
567
+ conv: item.conv,
568
+ evt: item.evt,
569
+ kind: item.kind || (item.isMain ? 'main' : (parsed.teammate ? 'teammate' : 'sub')),
570
+ mainAgent: item.isMain === true,
571
+ teammate: parsed.teammate || undefined,
572
+ model: parsed.body?.model,
573
+ proxyUrl: parsed.proxyUrl || undefined,
574
+ status: parsed.response?.status,
575
+ duration: parsed.duration,
576
+ usage: usage ? {
577
+ input_tokens: usage.input_tokens || 0,
578
+ output_tokens: usage.output_tokens || 0,
579
+ cache_read_input_tokens: usage.cache_read_input_tokens || 0,
580
+ cache_creation_input_tokens: usage.cache_creation_input_tokens || 0,
581
+ } : null,
582
+ inProgress: !!parsed.inProgress,
583
+ typeTag: null,
584
+ cacheLoss: null,
585
+ };
586
+ }
587
+
588
+ _emitRow(cur, item, parsed) {
589
+ const row = this._rowFrom(item, parsed);
590
+ try {
591
+ const tag = classifyRequest(parsed, null);
592
+ row.typeTag = tag ? { type: tag.type, subType: tag.subType ?? null } : null;
593
+ } catch (err) { reportSwallowed('v2-live.row-classify', err); }
594
+ if (row.mainAgent && !row.inProgress) {
595
+ const u = parsed.response?.body?.usage;
596
+ const cw = (u && u.cache_creation_input_tokens) || 0;
597
+ const cr = (u && u.cache_read_input_tokens) || 0;
598
+ if (cur._v3PrevMain && cw > 0 && cw > cr) {
599
+ try { row.cacheLoss = computeCacheLoss(cur._v3PrevMain, parsed); } catch (err) { reportSwallowed('v2-live.row-cacheloss', err); }
600
+ }
601
+ cur._v3PrevMain = parsed; // one full entry retained per cursor (bounded)
602
+ }
603
+ // Lookahead correction: this entry is the previous row's nextReq.
604
+ const prev = cur._v3Last;
605
+ if (prev && (prev.row.sessionId !== row.sessionId || prev.row.seq !== row.seq)) {
606
+ try {
607
+ const tag = classifyRequest(prev.entry, parsed);
608
+ const corrected = tag ? { type: tag.type, subType: tag.subType ?? null } : null;
609
+ if (JSON.stringify(corrected) !== JSON.stringify(prev.row.typeTag)) {
610
+ prev.row.typeTag = corrected;
611
+ sendEventToClients(this._clients, 'v2_requests_delta', prev.row);
612
+ }
613
+ } catch (err) { reportSwallowed('v2-live.row-classify', err); }
614
+ }
615
+ cur._v3Last = { row, entry: parsed };
616
+ sendEventToClients(this._clients, 'v2_requests_delta', row);
617
+ }
618
+
417
619
  // ── safety poll ────────────────────────────────────────────────────────────
418
620
 
419
621
  _safetyTick() {