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
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-CjsQEQc2.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.2",
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/"
@@ -99,6 +99,7 @@ export function listV2Logs(logDir, currentProjectName) {
99
99
  for (const s of listV2Sessions(join(logDir, project))) {
100
100
  if (s.leader) continue;
101
101
  if (s.size === 0) continue;
102
+ if (s.discard) continue; // quota-probe orphans: never listed (2026-07-16)
102
103
  if (!grouped[project]) grouped[project] = [];
103
104
  grouped[project].push({
104
105
  file: `v2:${project}/${s.sid}`,
@@ -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);
@@ -9,6 +9,8 @@ import { readFileSync, writeFileSync, existsSync, readdirSync, statSync } from '
9
9
  import { join, basename } from 'node:path';
10
10
  import { readJsonlTolerant, listSessionIds } from './v2/replay.js';
11
11
  import { dirSizeSync } from './v2/layout.js';
12
+ import { isDiscardableSession } from './v2/session-select.js';
13
+ import { reportSwallowed } from './error-report.js';
12
14
  import {
13
15
  INTER_SESSION_TYPES, isSystemText, extractUserTexts, isSuggestionMode,
14
16
  collectPromptsFromEvents, sortEpochFiles,
@@ -23,7 +25,11 @@ export { INTER_SESSION_TYPES, isSystemText, extractUserTexts };
23
25
  // v9: v2 session-dir units (files map keyed `sessions/<sid>`), journal-based counts
24
26
  // v10: per-session `size` = recursive session-dir bytes (was journal-only);
25
27
  // `journalSize` carries the incremental-cache key
26
- const STATS_VERSION = 10;
28
+ // v11: discardable sessions (quota-probe orphans) excluded — the bump
29
+ // invalidates pre-discard caches so their probe units can't be reused
30
+ // back into filesStats, which lets the discard check sit AFTER the
31
+ // cache-reuse branch (cache hits skip the journal head scan entirely)
32
+ const STATS_VERSION = 11;
27
33
 
28
34
  /**
29
35
  * Parse one v2 session directory into the same stats shape parseJsonlFile
@@ -199,7 +205,20 @@ function generateProjectStats(projectDir, projectName, onlyFile) {
199
205
  }
200
206
 
201
207
  const sessionDir = join(projectDir, 'sessions', sid);
202
- const parsed = parseSessionDir(sessionDir);
208
+ // Discardable sessions (quota-probe orphans — no main/teammate req, no
209
+ // leader) never count toward stats. Runs only on cache misses: v11+
210
+ // caches are written exclusively by post-discard code, so a cache hit can
211
+ // never resurrect a probe unit (the v10→v11 bump invalidated older ones).
212
+ if (isDiscardableSession(sessionDir)) continue;
213
+ let parsed;
214
+ try {
215
+ parsed = parseSessionDir(sessionDir);
216
+ } catch (err) {
217
+ // One unreadable session must not kill the whole worker (issue #129) —
218
+ // skip its stats and keep counting the healthy ones.
219
+ reportSwallowed('stats-worker.session-parse-failed', new Error(`${sid}: ${err.message}`));
220
+ continue;
221
+ }
203
222
  filesStats[unitKey] = {
204
223
  models: parsed.models,
205
224
  summary: parsed.summary,
@@ -33,6 +33,8 @@ import { reportSwallowed } from '../error-report.js';
33
33
  import { isMainAgentRequest } from '../interceptor-core.js';
34
34
  import { readPromptsHead, collectPromptsFromEvents } from '../user-prompt-extract.js';
35
35
  import { readSession, readJsonlTolerant, listSessionIds } from './replay.js';
36
+ import { iterateJsonlLines } from './jsonl-read.js';
37
+ import { isDiscardableSession } from './session-select.js';
36
38
  import { blobPath, isSupportedWireFormat, dirSizeSync } from './layout.js';
37
39
  import { SingleFlight } from './singleflight.js';
38
40
 
@@ -59,16 +61,17 @@ export function isV2SessionDir(p) {
59
61
  function readResponsesRaw(sessionDir) {
60
62
  const bySeq = new Map();
61
63
  const p = join(sessionDir, 'responses.jsonl');
62
- if (!existsSync(p)) return bySeq;
63
- let raw = '';
64
- try { raw = readFileSync(p, 'utf-8'); } catch { return bySeq; }
65
- for (const line of raw.split('\n')) {
66
- const t = line.trim();
67
- if (!t) continue;
68
- const m = t.match(/"seq":\s*(\d+)/);
69
- if (!m) continue;
70
- const seq = Number(m[1]);
71
- if (!bySeq.has(seq)) bySeq.set(seq, t);
64
+ try {
65
+ // streamed line-by-line (issue #129): responses.jsonl is the most likely
66
+ // file to outgrow Node's string cap (full response bodies, one per request)
67
+ for (const t of iterateJsonlLines(p)) {
68
+ const m = t.match(/"seq":\s*(\d+)/);
69
+ if (!m) continue;
70
+ const seq = Number(m[1]);
71
+ if (!bySeq.has(seq)) bySeq.set(seq, t);
72
+ }
73
+ } catch (err) {
74
+ reportSwallowed('v2-read.responses-read-failed', err);
72
75
  }
73
76
  return bySeq;
74
77
  }
@@ -402,12 +405,30 @@ export class SessionSynthesizer {
402
405
  // ---- body (blob backfill is per-request by journal ref — never carried) --
403
406
  const tools = this._loadBlob(req.blobs && req.blobs.tools);
404
407
  const system = this._loadBlob(req.blobs && req.blobs.sys);
408
+ // req.params carries every residual body field (params-era journals);
409
+ // dedicated fields override it so precedence is deterministic. user_id is
410
+ // ALWAYS meta.userIdRaw — the client does equality-based session-boundary
411
+ // detection on it, and a `-c` adopted folder mixes real user_ids that
412
+ // would otherwise split the timeline mid-session.
413
+ const params = req.params && typeof req.params === 'object' ? req.params : null;
405
414
  const body = {
415
+ ...params,
406
416
  ...(req.model && { model: req.model }),
407
417
  ...(system !== undefined && { system }),
408
418
  ...(tools !== undefined && { tools }),
409
- ...(meta.userIdRaw && { metadata: { user_id: meta.userIdRaw } }),
410
419
  };
420
+ if (meta.userIdRaw) {
421
+ const extra = body.metadata && typeof body.metadata === 'object' ? body.metadata : null;
422
+ body.metadata = { ...extra, user_id: meta.userIdRaw };
423
+ } else if (body.metadata && typeof body.metadata === 'object' && body.metadata.user_id !== undefined) {
424
+ // noid session (no parseable user_id at write time): a raw params
425
+ // user_id may vary per request, and surfacing it would re-open the
426
+ // spurious client boundary-split this block exists to prevent. Drop
427
+ // ONLY user_id; other metadata keys keep full fidelity.
428
+ const { user_id, ...restMeta } = body.metadata;
429
+ if (Object.keys(restMeta).length > 0) body.metadata = restMeta;
430
+ else delete body.metadata;
431
+ }
411
432
 
412
433
  const isTeammateEntry = isTeammate;
413
434
  const isMainKind = isMain;
@@ -473,6 +494,14 @@ export class SessionSynthesizer {
473
494
  phase: 'placeholder',
474
495
  entry,
475
496
  isMain: isMainKind,
497
+ // Journal-truth request identity for the live row builder (live-feed
498
+ // _rowFrom): conv/evt are the V3.S5 assembler inputs the cold fold
499
+ // (meta-rows foldDir) already carries — without them on live rows the
500
+ // client rebuilds entries with EMPTY messages and the chat vanished at
501
+ // stream end until a cold reload (2026-07-16 live-render regression).
502
+ kind: req.kind,
503
+ ...(req.conv && { conv: req.conv }),
504
+ ...(req.evt && { evt: req.evt }),
476
505
  stateRef: conv ? conv.state : null,
477
506
  };
478
507
  this._await.set(seq, item);
@@ -523,6 +552,7 @@ export class SessionSynthesizer {
523
552
  }
524
553
  entry.response = {
525
554
  ...(typeof done.http === 'number' && { status: done.http }),
555
+ ...(resp && resp.statusText && { statusText: resp.statusText }),
526
556
  ...(resp && resp.headers && { headers: resp.headers }),
527
557
  body: resp ? resp.body : null,
528
558
  };
@@ -544,7 +574,15 @@ export class SessionSynthesizer {
544
574
  function* iterateSessionItems(sessionDir, opts = {}) {
545
575
  const projectDir = dirname(dirname(sessionDir));
546
576
  const sessionId = basename(sessionDir);
547
- const session = readSession(projectDir, sessionId);
577
+ let session;
578
+ try {
579
+ session = readSession(projectDir, sessionId);
580
+ } catch (err) {
581
+ // One unreadable session must degrade to "not rendered", never crash the
582
+ // whole scan (issue #129: an oversized file crash-looped every startup).
583
+ reportSwallowed('v2-read.session-read-failed', new Error(`${sessionId}: ${err.message}`));
584
+ return;
585
+ }
548
586
  if (session.unsupported) {
549
587
  reportSwallowed('v2-read.unsupported-wire-format', new Error(`${sessionId}: wireFormat=${session.wireFormat}`));
550
588
  return;
@@ -609,6 +647,12 @@ export function findTeammateSessionDirs(sessionDir, leaderUuid) {
609
647
  continue;
610
648
  }
611
649
  if (!l) {
650
+ // Discardable dirs (quota-probe orphans — no main/teammate req) must
651
+ // never act as leader candidates: a probe fires at process startup,
652
+ // so its startTs sits right next to the real leader's and can win the
653
+ // orphan tie-break below, stealing the teammate's traffic from the
654
+ // real leader's folded stream.
655
+ if (isDiscardableSession(dir, meta)) continue;
612
656
  leaderStarts.push({ sid: name, startTs: (meta && meta.startTs) || '' });
613
657
  continue;
614
658
  }
@@ -646,7 +690,7 @@ export function findTeammateSessionDirs(sessionDir, leaderUuid) {
646
690
  * Item-level iteration of one v2 session with teammate re-join — the shared
647
691
  * core of the raw-string generators and the window reader below.
648
692
  */
649
- function* iterateV2Items(sessionDir, opts = {}) {
693
+ export function* iterateV2Items(sessionDir, opts = {}) {
650
694
  const streams = [iterateSessionItems(sessionDir, opts)];
651
695
  const ownMeta = (() => {
652
696
  try { return JSON.parse(readFileSync(join(sessionDir, 'meta.json'), 'utf-8')); } catch { return null; }
@@ -735,7 +779,7 @@ export function _v2WindowStatsForTest(reset = false) {
735
779
  * The result is IMMUTABLE and shared across coalesced callers — window
736
780
  * selection must not mutate `recs`/`ring`.
737
781
  */
738
- async function scanV2Descriptors(sessionDir) {
782
+ export async function scanV2Descriptors(sessionDir) {
739
783
  _windowStats.scanRuns++;
740
784
  const dedup = new Map(); // key → descriptor (insertion order = output order)
741
785
  const ring = []; // newest ≤3 isMain descriptors, pre-dedup iteration order
@@ -796,7 +840,7 @@ function selectV2Window(scan, opts = {}) {
796
840
 
797
841
  /** Level-1 shared Pass A + per-caller selection. `cached` gates the TTL
798
842
  * micro-cache read (F5b: /events live-attach must never consume it). */
799
- async function computeV2Window(sessionDir, opts = {}) {
843
+ export async function computeV2Window(sessionDir, opts = {}) {
800
844
  const scan = await _scanFlight.run(`scan:${sessionDir}`, () => scanV2Descriptors(sessionDir), { cached: !!opts.cached });
801
845
  return selectV2Window(scan, opts);
802
846
  }
@@ -813,7 +857,7 @@ async function computeV2Window(sessionDir, opts = {}) {
813
857
  * equivalent (algorithm review F6). `collect` mode fills a slot array in
814
858
  * Pass A output order instead: byte-identical to the historical single pass.
815
859
  */
816
- async function materializeV2Window(sessionDir, win, { collect = false, onEntry = null } = {}) {
860
+ async function materializeV2Window(sessionDir, win, { collect = false, onEntry = null, promoteKeys = null } = {}) {
817
861
  const memberIdx = new Map();
818
862
  win.members.forEach((d, i) => memberIdx.set(itemKey(d.sessionId, d.seq), i));
819
863
  const ringKeys = new Set(win.ring.map((d) => itemKey(d.sessionId, d.seq)));
@@ -837,7 +881,12 @@ async function materializeV2Window(sessionDir, win, { collect = false, onEntry =
837
881
  // replayed state (item.stateRef aliases the live conversation state at
838
882
  // this seq — used transiently, never retained). Same construction as the
839
883
  // historical end-of-window rebuild, byte-for-byte.
840
- if (k === win.baselineKey) {
884
+ // `promoteKeys` (V3.S1): per-member force-checkpoint for single-entry
885
+ // reads — a mid-session main delta carries only its appended slice, but
886
+ // the detail view needs the full replayed state for BOTH the target and
887
+ // its prevMain (Body Diff / cacheLoss compare whole messages arrays).
888
+ // The window baseline promotes only win.baselineKey (the window start).
889
+ if (k === win.baselineKey || (promoteKeys && promoteKeys.has(k))) {
841
890
  const state = item.stateRef || [];
842
891
  const rebuilt = { ...item.entry, body: { ...item.entry.body, messages: state } };
843
892
  rebuilt._isCheckpoint = true;
@@ -889,6 +938,47 @@ async function materializeV2Window(sessionDir, win, { collect = false, onEntry =
889
938
  * @param {{limit?: number, before?: string|null, cached?: boolean}} [opts]
890
939
  * @returns {Promise<{entries: string[], hasMore: boolean, oldestTimestamp: string, totalCount: number, mainAgentRing: string[]}>}
891
940
  */
941
+ /**
942
+ * V3.S1: on-demand single-entry read — the detail view's data source once the
943
+ * request list is metadata rows. Locates the target (sessionId, seq) in the
944
+ * shared descriptor scan, pairs it with its preceding isMain descriptor
945
+ * (Body Diff / Context tab need the previous mainAgent, raw-seq adjacency),
946
+ * and materializes exactly those members with per-member checkpoint promotion
947
+ * (main deltas come back with the full replayed state, matching what the
948
+ * client holds after reconstruction on the legacy channel).
949
+ *
950
+ * @param {string} sessionDir - leader session dir (teammate rows resolve
951
+ * through the leader's k-way fold so tags/order match the list)
952
+ * @param {{seq: number, sessionId?: string|null, cached?: boolean}} opts -
953
+ * sessionId (UUID) disambiguates teammate rows; null = any session's seq
954
+ * @returns {Promise<{entry: string, prevMain: string|null}|null>} raw JSON
955
+ * strings (never re-parsed here), or null when the seq is unknown / gated
956
+ * out by synthesis (crash-orphan) — callers answer 404.
957
+ */
958
+ export async function readV2SingleEntry(sessionDir, opts = {}) {
959
+ const seq = opts.seq;
960
+ const sessionId = opts.sessionId || null;
961
+ const key = `entry:${sessionDir}|${sessionId || ''}|${seq}`;
962
+ return _windowFlight.run(key, async () => {
963
+ const scan = await _scanFlight.run(`scan:${sessionDir}`, () => scanV2Descriptors(sessionDir), { cached: true });
964
+ const recs = scan.recs;
965
+ const idx = recs.findIndex((d) => d.seq === seq && (!sessionId || d.sessionId === sessionId));
966
+ if (idx === -1) return null;
967
+ const target = recs[idx];
968
+ let prevMain = null;
969
+ for (let i = idx - 1; i >= 0; i--) {
970
+ if (recs[i].isMain) { prevMain = recs[i]; break; }
971
+ }
972
+ const members = prevMain ? [prevMain, target] : [target];
973
+ const win = { members, ring: [], baselineKey: null };
974
+ const promoteKeys = new Set(members.filter((d) => d.isMainDelta).map((d) => itemKey(d.sessionId, d.seq)));
975
+ const { slots } = await materializeV2Window(sessionDir, win, { collect: true, promoteKeys });
976
+ const entry = slots[members.length - 1];
977
+ if (entry === undefined) return null; // synthesis gate dropped it between passes
978
+ return { entry, prevMain: prevMain ? (slots[0] ?? null) : null };
979
+ }, { cached: opts.cached !== false });
980
+ }
981
+
892
982
  export async function readV2WindowedEntries(sessionDir, opts = {}) {
893
983
  const cached = !!opts.cached;
894
984
  const key = `win:${sessionDir}|${opts.limit || 0}|${opts.before || ''}`;
@@ -1016,8 +1106,12 @@ export function listV2Sessions(projectDir) {
1016
1106
  const reqKind = new Map();
1017
1107
  let turns = 0;
1018
1108
  let sentinelVersion = null;
1109
+ let hasMainOrTeammate = false;
1019
1110
  for (const line of readJsonlTolerant(join(dir, 'journal.jsonl'))) {
1020
- if (line.ph === 'req') reqKind.set(line.seq, line.kind);
1111
+ if (line.ph === 'req') {
1112
+ reqKind.set(line.seq, line.kind);
1113
+ if (line.kind === 'main' || line.kind === 'teammate') hasMainOrTeammate = true;
1114
+ }
1021
1115
  else if (line.ph === 'done' && reqKind.get(line.seq) === 'main') {
1022
1116
  turns++;
1023
1117
  reqKind.delete(line.seq); // fold duplicate done lines (§14)
@@ -1052,6 +1146,17 @@ export function listV2Sessions(projectDir) {
1052
1146
  turns,
1053
1147
  size: dirSizeSync(dir),
1054
1148
  preview,
1149
+ // Discardable-session verdict. KEEP IN SYNC: session-select.js
1150
+ // isDiscardableSession is the canonical rule; this fold pre-computes
1151
+ // it for free over the FULL journal (the canonical scan is 8MB-
1152
+ // budgeted — intentional asymmetry, a first main sits at the head).
1153
+ // When the fold says discard, the canonical predicate CONFIRMS it:
1154
+ // readJsonlTolerant swallows an I/O error (Windows EBUSY/EPERM lock)
1155
+ // into zero lines, which must KEEP the session, not hide it — the
1156
+ // canonical path carries that error→keep direction (ioErrorResult).
1157
+ // Main-bearing sessions never pay the extra read; probe journals are
1158
+ // ~3 lines.
1159
+ discard: !(meta && meta.leader) && !hasMainOrTeammate && isDiscardableSession(dir, meta),
1055
1160
  });
1056
1161
  } catch { /* one unreadable session must not break the list */ }
1057
1162
  }
@@ -8,7 +8,7 @@
8
8
  // The reader folds the two phases by seq; a req without a done is in-flight
9
9
  // (or the process died) — that IS the v1 placeholder, minus the duplicated body.
10
10
 
11
- import { existsSync, readFileSync } from 'node:fs';
11
+ import { iterateJsonlLines } from './jsonl-read.js';
12
12
 
13
13
  export class Journal {
14
14
  /**
@@ -29,11 +29,15 @@ export class Journal {
29
29
 
30
30
  static _maxSeqIn(journalPath) {
31
31
  try {
32
- if (!existsSync(journalPath)) return 0;
33
32
  let max = 0;
34
- // Regex scan instead of per-line JSON.parse: tolerant of a truncated
35
- // tail line and ~10x cheaper on large journals.
36
- for (const m of readFileSync(journalPath, 'utf-8').matchAll(/"seq":\s*(\d+)/g)) {
33
+ // Per-line regex instead of JSON.parse: tolerant of a truncated tail
34
+ // line and ~10x cheaper on large journals. Streamed line-by-line
35
+ // (issue #129): a whole-file read past Node's string cap would land in
36
+ // the catch below and silently RESET seq to 0 — colliding with every
37
+ // existing line, which the reader folds into corrupted sessions.
38
+ for (const line of iterateJsonlLines(journalPath)) {
39
+ const m = /"seq":\s*(\d+)/.exec(line);
40
+ if (!m) continue;
37
41
  const n = Number(m[1]);
38
42
  if (n > max) max = n;
39
43
  }
@@ -0,0 +1,127 @@
1
+ // Wire Format v2 — streaming JSONL line reader (issue #129).
2
+ //
3
+ // `readFileSync(path, 'utf-8')` materializes the WHOLE file as one JS string
4
+ // and throws ERR_STRING_TOO_LONG past Node's ~512MiB string cap — a single
5
+ // oversized session file (giant conv epoch / journal / responses.jsonl, seen
6
+ // in the wild after migrating very large v1 logs) crash-looped the server on
7
+ // every startup scan. This reader never builds a whole-file string: it reads
8
+ // fixed-size chunks and splits lines at the BYTE level (scanning for 0x0A
9
+ // before decoding), so multi-byte UTF-8 characters straddling a chunk
10
+ // boundary are never torn, and only one line at a time ever becomes a string.
11
+ //
12
+ // A single line at/above the string cap can never be decoded at all — it is
13
+ // skipped (reported once per file via reportSwallowed) instead of throwing,
14
+ // mirroring readJsonlTolerant's torn-tail tolerance (spec §14).
15
+
16
+ import { existsSync, openSync, readSync, closeSync, statSync } from 'node:fs';
17
+ import { reportSwallowed } from '../error-report.js';
18
+
19
+ const DEFAULT_CHUNK_BYTES = 8 * 1024 * 1024;
20
+ // Node's max string length is 0x1fffffe8 (~512MiB) — a line this long can
21
+ // never become a JS string, so it is unreadable by construction.
22
+ const DEFAULT_MAX_LINE_BYTES = 0x1fffffe8;
23
+
24
+ /**
25
+ * Iterate a JSONL file's lines as trimmed strings (blank lines skipped),
26
+ * without ever holding the whole file in one string.
27
+ *
28
+ * @param {string} path
29
+ * @param {{maxLineBytes?: number, chunkBytes?: number}} [opts] - test seams;
30
+ * production callers use the defaults.
31
+ * @yields {string} one trimmed non-empty line
32
+ */
33
+ export function* iterateJsonlLines(path, opts = {}) {
34
+ const maxLineBytes = opts.maxLineBytes || DEFAULT_MAX_LINE_BYTES;
35
+ const chunkBytes = opts.chunkBytes || DEFAULT_CHUNK_BYTES;
36
+ if (!existsSync(path)) return;
37
+ let fd;
38
+ try {
39
+ fd = openSync(path, 'r');
40
+ } catch (err) {
41
+ // ENOENT = vanished mid-scan (benign TOCTOU); anything else (EBUSY/EPERM
42
+ // Windows locks, EMFILE) silently rendering a session empty is
43
+ // diagnostic-worthy — CLAUDE.md swallow rule.
44
+ if (err && err.code !== 'ENOENT') reportSwallowed('v2-read.open-failed', err);
45
+ return;
46
+ }
47
+ try {
48
+ // Cap the chunk to the file's actual size (like the sibling readers):
49
+ // a fixed 8MB zero-filled alloc per call is a ~2600x regression over the
50
+ // old readFileSync for the KB-scale journals startup scans walk.
51
+ let capBytes = chunkBytes;
52
+ try { capBytes = Math.min(chunkBytes, Math.max(statSync(path).size, 1)); }
53
+ catch { /* stat raced a delete — keep the full chunk; the read loop decides */ }
54
+ const chunk = Buffer.alloc(capBytes);
55
+ let carry = []; // Buffer fragments of the current (incomplete) line
56
+ let carryBytes = 0;
57
+ let skipping = false; // inside an oversized line — drop bytes until \n
58
+ let reported = false; // one report per file, not per oversized line
59
+ let pos = 0;
60
+ const reportOnce = (err) => {
61
+ if (reported) return;
62
+ reported = true;
63
+ reportSwallowed('v2-read.jsonl-line-too-long', err);
64
+ };
65
+ while (true) {
66
+ const n = readSync(fd, chunk, 0, chunk.length, pos);
67
+ if (n === 0) break;
68
+ pos += n;
69
+ const view = chunk.subarray(0, n);
70
+ let from = 0;
71
+ while (from < n) {
72
+ const nl = view.indexOf(10, from);
73
+ if (nl === -1) {
74
+ // no newline in the rest of this chunk — carry (or keep skipping)
75
+ if (!skipping) {
76
+ const restLen = n - from;
77
+ if (carryBytes + restLen > maxLineBytes) {
78
+ skipping = true;
79
+ carry = [];
80
+ carryBytes = 0;
81
+ reportOnce(new Error(`${path}: line exceeds ${maxLineBytes} bytes — skipped`));
82
+ } else {
83
+ // chunk is reused by the next readSync — the carried slice must own its bytes
84
+ carry.push(Buffer.from(view.subarray(from)));
85
+ carryBytes += restLen;
86
+ }
87
+ }
88
+ break;
89
+ }
90
+ if (skipping) {
91
+ skipping = false; // the oversized line ends at this newline
92
+ } else {
93
+ const seg = view.subarray(from, nl);
94
+ let text = null;
95
+ try {
96
+ if (carry.length > 0) {
97
+ carry.push(seg);
98
+ text = Buffer.concat(carry).toString('utf-8');
99
+ } else {
100
+ text = seg.toString('utf-8');
101
+ }
102
+ } catch (err) {
103
+ reportOnce(err); // decode failed (line at the string cap) — skip it
104
+ }
105
+ carry = [];
106
+ carryBytes = 0;
107
+ if (text !== null) {
108
+ const t = text.trim();
109
+ if (t) yield t;
110
+ }
111
+ }
112
+ from = nl + 1;
113
+ }
114
+ }
115
+ // trailing line without a final newline (torn tail is the caller's concern)
116
+ if (!skipping && carry.length > 0) {
117
+ let text = null;
118
+ try { text = Buffer.concat(carry).toString('utf-8'); } catch (err) { reportOnce(err); }
119
+ if (text !== null) {
120
+ const t = text.trim();
121
+ if (t) yield t;
122
+ }
123
+ }
124
+ } finally {
125
+ closeSync(fd);
126
+ }
127
+ }