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
@@ -0,0 +1,361 @@
1
+ /**
2
+ * Wire v3 (V3.S2) — request-list metadata rows.
3
+ *
4
+ * A row is a journal fold (req + done by seq) plus two body-derived fields
5
+ * (typeTag, cacheLoss) computed in a bounded streaming Pass B over exactly the
6
+ * windowed members: bodies are materialized one at a time, classified, and
7
+ * dropped — the wire carries rows only (~1.3KB each vs full entries).
8
+ *
9
+ * Two DELIBERATE classification divergences from the legacy full-entry list
10
+ * (both more correct; pinned in test/v2-meta-rows.test.js, declared in
11
+ * history.md):
12
+ * - membership is the journal fold — a SUPERSET of the synthesizer's output
13
+ * (conv-gapped crash-orphans get a row; their detail fetch answers 404 and
14
+ * typeTag stays journal-derived);
15
+ * - mainAgent is `kind==='main' && !meta.leader` — the isMain semantics the
16
+ * mainAgentRing already pinned, dropping "main-looking" sub/misc bodies.
17
+ */
18
+ import { readFileSync, readdirSync } from 'node:fs';
19
+ import { join, basename } from 'node:path';
20
+ import { readJsonlTolerant } from './replay.js';
21
+ import { iterateJsonlLines } from './jsonl-read.js';
22
+ import { iterateV2Items, findTeammateSessionDirs } from './adapter.js';
23
+ import { SingleFlight } from './singleflight.js';
24
+ import { reportSwallowed } from '../error-report.js';
25
+ import { classifyRequest } from '../../../src/utils/requestType.js';
26
+
27
+ // KEEP IN SYNC: server/lib/v2/adapter.js itemKey — (sessionId, seq) identity.
28
+ const itemKey = (sessionId, seq) => `${sessionId}\x00${seq}`;
29
+
30
+ const CACHE_TTL_MS = 5 * 60 * 1000;
31
+
32
+ /** KEEP IN SYNC: src/utils/helpers.js stripPrivateKeys. */
33
+ function stripPrivateKeys(obj) {
34
+ if (Array.isArray(obj)) return obj.map(stripPrivateKeys);
35
+ if (obj && typeof obj === 'object') {
36
+ const result = {};
37
+ for (const key of Object.keys(obj)) {
38
+ if (key.startsWith('_')) continue;
39
+ result[key] = stripPrivateKeys(obj[key]);
40
+ }
41
+ return result;
42
+ }
43
+ return obj;
44
+ }
45
+
46
+ /**
47
+ * KEEP IN SYNC: src/utils/helpers.js _computeCacheLoss (that module cannot be
48
+ * imported server-side — SVG/i18n dependency chain). One deliberate fix: the
49
+ * client computes the ttl gap as `stringTs - stringTs` (NaN, so its ttl branch
50
+ * never fires); here the timestamps are parsed — pinned in the unit test.
51
+ */
52
+ export function computeCacheLoss(prevEntry, currEntry) {
53
+ const gap = Date.parse(currEntry.timestamp) - Date.parse(prevEntry.timestamp);
54
+ if (gap > CACHE_TTL_MS) return { reason: 'ttl', reasons: ['ttl'] };
55
+ const prev = stripPrivateKeys(prevEntry.body);
56
+ const curr = stripPrivateKeys(currEntry.body);
57
+ if (!prev || !curr) return { reason: 'key_change', reasons: ['key_change'] };
58
+ const reasons = [];
59
+ if (prev.model !== curr.model) reasons.push('model_change');
60
+ if (JSON.stringify(prev.system) !== JSON.stringify(curr.system)) reasons.push('system_change');
61
+ if (JSON.stringify(prev.tools) !== JSON.stringify(curr.tools)) reasons.push('tools_change');
62
+ const prevMsgs = prev.messages || [];
63
+ const currMsgs = curr.messages || [];
64
+ if (currMsgs.length < prevMsgs.length) {
65
+ reasons.push('msg_truncated');
66
+ } else {
67
+ const prefixLen = Math.min(prevMsgs.length, currMsgs.length);
68
+ let prefixMatch = true;
69
+ for (let j = 0; j < prefixLen; j++) {
70
+ if (JSON.stringify(prevMsgs[j]) !== JSON.stringify(currMsgs[j])) { prefixMatch = false; break; }
71
+ }
72
+ if (!prefixMatch) reasons.push('msg_modified');
73
+ }
74
+ if (reasons.length === 0) reasons.push('key_change');
75
+ return { reason: reasons[0], reasons };
76
+ }
77
+
78
+ function readMeta(dir) {
79
+ try { return JSON.parse(readFileSync(join(dir, 'meta.json'), 'utf-8')); } catch { return null; }
80
+ }
81
+
82
+ /** Fold one session dir's journal into row skeletons (no bodies touched). */
83
+ function foldDir(dir) {
84
+ const meta = readMeta(dir);
85
+ const isTeammateDir = !!(meta && meta.leader);
86
+ const sessionId = (meta && meta.sessionId) || basename(dir);
87
+ const reqs = new Map();
88
+ const dones = new Map();
89
+ for (const line of readJsonlTolerant(join(dir, 'journal.jsonl'))) {
90
+ if (line.ph === 'req' && !reqs.has(line.seq)) reqs.set(line.seq, line);
91
+ else if (line.ph === 'done' && !dones.has(line.seq)) dones.set(line.seq, line);
92
+ }
93
+ const rows = [];
94
+ for (const [seq, r] of reqs) {
95
+ const d = dones.get(seq);
96
+ const usage = d && d.usage ? {
97
+ input_tokens: d.usage.in || 0,
98
+ output_tokens: d.usage.out || 0,
99
+ cache_read_input_tokens: d.usage.cr || 0,
100
+ cache_creation_input_tokens: d.usage.cw || 0,
101
+ } : null;
102
+ rows.push({
103
+ seq,
104
+ sessionId,
105
+ timestamp: r.ts || '',
106
+ url: r.url || '',
107
+ method: r.method || 'POST',
108
+ // V3.S5 assembler inputs: which conv channel this request's messages
109
+ // live in, and whether its event was a snapshot (checkpoint marker).
110
+ conv: r.conv,
111
+ evt: r.evt,
112
+ kind: r.kind || 'misc',
113
+ mainAgent: r.kind === 'main' && !isTeammateDir,
114
+ // v1 entry contract (adapter.js:427): teammate = agentName string | true
115
+ teammate: isTeammateDir ? ((meta.leader && meta.leader.agentName) || true) : undefined,
116
+ model: r.model,
117
+ proxyUrl: r.proxy && r.proxy.url ? r.proxy.url : undefined,
118
+ status: d && typeof d.http === 'number' ? d.http : undefined,
119
+ duration: d && typeof d.dur === 'number' ? d.dur : undefined,
120
+ usage,
121
+ inProgress: !d,
122
+ typeTag: null, // Pass B
123
+ cacheLoss: null, // Pass B
124
+ });
125
+ }
126
+ return rows;
127
+ }
128
+
129
+ /**
130
+ * Read the request-list metadata rows for a session (leader + folded
131
+ * teammates), windowed like the entry list: dedup timestamp|url last-wins →
132
+ * `before` filter → tail-`limit`. Pass B streams the window members' bodies
133
+ * once to attach typeTag/cacheLoss.
134
+ *
135
+ * @param {string} sessionDir
136
+ * @param {{limit?: number, before?: string|null, since?: string|null, passB?: boolean}} [opts] -
137
+ * passB:false skips body classification (live increments send rows first
138
+ * and re-send corrections once Pass B / the next request lands);
139
+ * `since` (review P1-2) scopes an incremental reconnect to the delta window
140
+ * — without it every mobile reconnect re-transmits the full session
141
+ * @returns {Promise<{rows: object[], totalCount: number, hasMore: boolean, oldestTimestamp: string}>}
142
+ */
143
+ export async function readV2RequestsMeta(sessionDir, opts = {}) {
144
+ const limit = opts.limit || 0;
145
+ const before = opts.before || null;
146
+ const since = opts.since || null;
147
+ const meta = readMeta(sessionDir);
148
+ const leaderUuid = (meta && meta.sessionId) || basename(sessionDir);
149
+ const dirs = [sessionDir];
150
+ try { dirs.push(...findTeammateSessionDirs(sessionDir, leaderUuid).map((t) => t.dir)); } catch { /* leaderless */ } // items are {dir, leader}
151
+
152
+ // k-way ordering parity: same (ts, sessionId, seq) comparator as iterateV2Items
153
+ const all = dirs.flatMap(foldDir);
154
+ all.sort((a, b) => (a.timestamp < b.timestamp ? -1 : a.timestamp > b.timestamp ? 1 : a.sessionId < b.sessionId ? -1 : a.sessionId > b.sessionId ? 1 : a.seq - b.seq));
155
+
156
+ // dedup timestamp|url last-wins (nokey rows key on seq — stable, unlike array index)
157
+ const dedup = new Map();
158
+ for (const row of all) {
159
+ const key = row.timestamp && row.url ? `${row.timestamp}|${row.url}` : `__nokey_${row.sessionId}_${row.seq}`;
160
+ dedup.set(key, row);
161
+ }
162
+ let rows = [...dedup.values()];
163
+ if (before) rows = rows.filter((r) => r.timestamp && r.timestamp < before);
164
+ const totalCount = rows.length;
165
+ let hasMore = false;
166
+ if (limit > 0 && rows.length > limit) {
167
+ hasMore = true;
168
+ rows = rows.slice(rows.length - limit);
169
+ }
170
+ const oldestTimestamp = rows.length > 0 ? rows[0].timestamp : '';
171
+ // Emission-time since filter (mirrors selectV2Window: applied after the
172
+ // window selection; nokey rows always resend — the legacy contract).
173
+ if (since) rows = rows.filter((r) => !r.timestamp || r.timestamp >= since);
174
+
175
+ if (opts.passB !== false && rows.length > 0) {
176
+ await attachBodyFields(sessionDir, rows);
177
+ }
178
+ return { rows, totalCount, hasMore, oldestTimestamp };
179
+ }
180
+
181
+ // In-flight coalescing for the v3 cold reads (review P2-b): a reconnect storm
182
+ // is N tabs hitting the same sessionDir at once — without this each ran its
183
+ // own full journal fold + Pass B + conv-file reads. cached:false — /events is
184
+ // a live-attach caller (S10b F5b: a stale TTL window would widen the
185
+ // cold-load→attach broadcast-loss gap), so callers only JOIN in-flight runs.
186
+ const _coldBundleFlight = new SingleFlight({ ttlMs: 500 });
187
+
188
+ /**
189
+ * The complete v3 cold read for one /events connection: windowed rows +
190
+ * native conv/responses payloads, single-flighted per (dir, window).
191
+ */
192
+ export function readV2ColdBundle(sessionDir, { limit = 0, since = null } = {}) {
193
+ const key = `bundle:${sessionDir}|${limit}|${since || ''}`;
194
+ return _coldBundleFlight.run(key, async () => {
195
+ const meta = await readV2RequestsMeta(sessionDir, { limit, since });
196
+ const native = await readV2NativeCold(sessionDir, meta.rows);
197
+ return { meta, native };
198
+ }, { cached: false });
199
+ }
200
+
201
+ /**
202
+ * Wire v3 (V3.S4) cold native lines for the flagged client's assembler:
203
+ * per conv channel, raw lines from the LAST snapshot at-or-before the
204
+ * window's oldest seq (per session) through the end; responses lines for
205
+ * exactly the window member seqs. Lines are forwarded verbatim (string
206
+ * concat, no parse/stringify round trip beyond seq/t sniffing).
207
+ *
208
+ * ASYNC with event-loop yields (review P1-4): a large session's conv files
209
+ * total tens of MB — reading and snapshot-scanning them in one synchronous
210
+ * stretch blocks every connected client for the duration of the cold load.
211
+ *
212
+ * @param {string} sessionDir - leader dir (teammate dirs folded like the rows)
213
+ * @param {object[]} rows - the window rows (readV2RequestsMeta output)
214
+ * @returns {Promise<{convPayloads: string[], respPayloads: string[]}>}
215
+ * pre-serialized JSON payloads for `v3_conv` / `v3_resp` SSE frames
216
+ */
217
+ export async function readV2NativeCold(sessionDir, rows) {
218
+ const yieldLoop = () => new Promise((resolve) => setImmediate(resolve));
219
+ const minSeqBySession = new Map();
220
+ const memberSeqsBySession = new Map();
221
+ for (const r of rows) {
222
+ const cur = minSeqBySession.get(r.sessionId);
223
+ if (cur === undefined || r.seq < cur) minSeqBySession.set(r.sessionId, r.seq);
224
+ let set = memberSeqsBySession.get(r.sessionId);
225
+ if (!set) { set = new Set(); memberSeqsBySession.set(r.sessionId, set); }
226
+ set.add(r.seq);
227
+ }
228
+ const meta = readMeta(sessionDir);
229
+ const leaderUuid = (meta && meta.sessionId) || basename(sessionDir);
230
+ const dirs = [sessionDir];
231
+ try { dirs.push(...findTeammateSessionDirs(sessionDir, leaderUuid).map((t) => t.dir)); } catch { /* leaderless */ } // items are {dir, leader}
232
+
233
+ const convPayloads = [];
234
+ const respPayloads = [];
235
+ for (const dir of dirs) {
236
+ const dirMeta = readMeta(dir);
237
+ const sessionId = (dirMeta && dirMeta.sessionId) || basename(dir);
238
+ const minSeq = minSeqBySession.get(sessionId);
239
+ const memberSeqs = memberSeqsBySession.get(sessionId);
240
+ if (minSeq === undefined) continue; // no window rows from this session
241
+ // conv channels: raw lines from the channel's last snapshot ≤ minSeq
242
+ const convRoot = join(dir, 'conversations');
243
+ let keys = [];
244
+ try { keys = readdirSync(convRoot, { withFileTypes: true }).filter((e) => e.isDirectory()).map((e) => e.name); } catch { /* no conv dir */ }
245
+ for (const key of keys) {
246
+ let files = [];
247
+ try { files = readdirSync(join(convRoot, key)).filter((f) => /^e\d+\.jsonl$/.test(f)).sort((a, b) => Number(a.match(/\d+/)[0]) - Number(b.match(/\d+/)[0])); } catch { continue; }
248
+ const raws = [];
249
+ for (const f of files) {
250
+ // streamed line-by-line (issue #129): a conv epoch past Node's string
251
+ // cap must degrade to skipped lines, not lose the whole file silently
252
+ try { for (const trimmed of iterateJsonlLines(join(convRoot, key, f))) raws.push(trimmed); }
253
+ catch (err) { reportSwallowed('v2-native.conv-read-failed', err); }
254
+ await yieldLoop(); // one yield per conv file read (error path included)
255
+ }
256
+ // last snapshot at-or-before the window start for this session
257
+ let start = 0;
258
+ for (let i = 0; i < raws.length; i++) {
259
+ try {
260
+ const ev = JSON.parse(raws[i]);
261
+ if (ev.t === 'snapshot' && ev.seq <= minSeq) start = i;
262
+ if (ev.seq > minSeq) break;
263
+ } catch { /* tolerate torn line */ }
264
+ if (i % 2000 === 1999) await yieldLoop();
265
+ }
266
+ const windowRaws = raws.slice(start);
267
+ // Cap each frame at ~512KB of lines: a multi-MB single SSE event forces
268
+ // one giant synchronous JSON.parse on the client's main thread (no
269
+ // paint, no rAF — the loading UI freezes). Small frames are macrotask
270
+ // boundaries: the browser paints and the byte meter ticks between them.
271
+ pushChunked(convPayloads, windowRaws, (linesJson) => `{"sessionId":${JSON.stringify(sessionId)},"channel":${JSON.stringify(key)},"lines":[${linesJson}]}`);
272
+ }
273
+ // responses: exactly the window member seqs — streamed (issue #129)
274
+ const respRaws = [];
275
+ try {
276
+ for (const trimmed of iterateJsonlLines(join(dir, 'responses.jsonl'))) {
277
+ const m = /"seq":\s*(\d+)/.exec(trimmed);
278
+ if (m && memberSeqs && memberSeqs.has(Number(m[1]))) respRaws.push(trimmed);
279
+ }
280
+ } catch (err) { reportSwallowed('v2-native.responses-read-failed', err); }
281
+ await yieldLoop();
282
+ pushChunked(respPayloads, respRaws, (linesJson) => `{"sessionId":${JSON.stringify(sessionId)},"lines":[${linesJson}]}`);
283
+ }
284
+ return { convPayloads, respPayloads };
285
+ }
286
+
287
+ const NATIVE_FRAME_BUDGET = 512 * 1024;
288
+
289
+ /** Split raw lines into ≤~512KB payloads (see comment at the conv call site). */
290
+ function pushChunked(out, raws, wrap) {
291
+ let buf = [];
292
+ let bytes = 0;
293
+ const flush = () => {
294
+ if (buf.length > 0) out.push(wrap(buf.join(',')));
295
+ buf = [];
296
+ bytes = 0;
297
+ };
298
+ for (const raw of raws) {
299
+ buf.push(raw);
300
+ bytes += raw.length + 1;
301
+ if (bytes >= NATIVE_FRAME_BUDGET) flush();
302
+ }
303
+ flush();
304
+ }
305
+
306
+ /**
307
+ * Bounded streaming Pass B: materialize exactly the rows' bodies (one at a
308
+ * time), classify typeTag with one-entry lookahead (classifyRequest's
309
+ * Preflight/Plan detection reads nextReq), compute cacheLoss against the
310
+ * retained previous mainAgent body, then drop the body.
311
+ */
312
+ async function attachBodyFields(sessionDir, rows) {
313
+ const rowByKey = new Map(rows.map((r) => [itemKey(r.sessionId, r.seq), r]));
314
+ const materialize = (sessionId, seq) => rowByKey.has(itemKey(sessionId, seq));
315
+ let pending = null; // { row, entry, isMain } awaiting its nextReq for classify
316
+ let prevMainFull = null; // previous mainAgent full-body entry (cacheLoss)
317
+ let n = 0;
318
+ const finish = (slot, nextEntry) => {
319
+ const { row, entry } = slot;
320
+ try {
321
+ const tag = classifyRequest(entry, nextEntry);
322
+ row.typeTag = tag ? { type: tag.type, subType: tag.subType ?? null } : null;
323
+ } catch (err) {
324
+ // Diagnostic-worthy (a throw silently mis-tags the row; the live path
325
+ // reports the same failure) — CLAUDE.md swallow rule.
326
+ reportSwallowed('v2-meta.row-classify', err);
327
+ row.typeTag = null; // journal-derived kind still renders
328
+ }
329
+ // Authoritative synthesis-level markers. mainAgent stays KIND-derived
330
+ // (item.isMain = journal kind==='main' && !leader) like foldDir and the
331
+ // live rows — entry.mainAgent re-derives from the blob-backfilled body
332
+ // and mis-tags a main-shaped countTokens probe as true, which would merge
333
+ // its turn into the chat after a cold reload (2026-07-16 review P1).
334
+ row.mainAgent = slot.isMain === true;
335
+ if (entry.teammate) row.teammate = entry.teammate;
336
+ if (entry.body && entry.body.model) row.model = entry.body.model;
337
+ if (row.mainAgent) {
338
+ const usage = entry.response?.body?.usage;
339
+ const cacheCreate = usage?.cache_creation_input_tokens || 0;
340
+ const cacheRead = usage?.cache_read_input_tokens || 0;
341
+ if (prevMainFull && cacheCreate > 0 && cacheCreate > cacheRead) {
342
+ try { row.cacheLoss = computeCacheLoss(prevMainFull, entry); } catch (err) { reportSwallowed('v2-meta.row-cacheloss', err); }
343
+ }
344
+ prevMainFull = entry;
345
+ }
346
+ };
347
+ for (const item of iterateV2Items(sessionDir, { materialize })) {
348
+ const row = rowByKey.get(itemKey(item.sessionId, item.seq));
349
+ if (!row) continue;
350
+ // main deltas carry only their appended slice — classification and
351
+ // cacheLoss need the full replayed state (stateRef aliases it transiently)
352
+ let entry = item.entry;
353
+ if (row.mainAgent && item.stateRef && entry.body) {
354
+ entry = { ...entry, body: { ...entry.body, messages: item.stateRef } };
355
+ }
356
+ if (pending) finish(pending, entry);
357
+ pending = { row, entry, isMain: item.isMain };
358
+ if (++n % 20 === 0) await new Promise((resolve) => setImmediate(resolve));
359
+ }
360
+ if (pending) finish(pending, null);
361
+ }
@@ -14,14 +14,14 @@ import { join } from 'node:path';
14
14
  import { createHash } from 'node:crypto';
15
15
  import { messageFingerprint, normalizeMsgForEquality } from '../session-boundary.js';
16
16
  import { isSupportedWireFormat } from './layout.js';
17
+ import { iterateJsonlLines } from './jsonl-read.js';
17
18
 
18
- /** Parse a JSONL file tolerating a truncated tail line (spec §14). */
19
+ /** Parse a JSONL file tolerating a truncated tail line (spec §14). Streams
20
+ * line-by-line (issue #129): a file past Node's ~512MiB string cap must not
21
+ * throw ERR_STRING_TOO_LONG out of the whole read path. */
19
22
  export function readJsonlTolerant(path) {
20
- if (!existsSync(path)) return [];
21
23
  const out = [];
22
- for (const line of readFileSync(path, 'utf-8').split('\n')) {
23
- const t = line.trim();
24
- if (!t) continue;
24
+ for (const t of iterateJsonlLines(path)) {
25
25
  try { out.push(JSON.parse(t)); } catch { /* truncated tail — drop */ }
26
26
  }
27
27
  return out;
@@ -75,9 +75,13 @@ const SCAN_CHUNK = 64 * 1024;
75
75
  * @param {string} dir - absolute session dir
76
76
  * @param {number} budget - max bytes to read before giving up
77
77
  * @param {(line:string) => boolean} onLine
78
+ * @param {boolean} [ioErrorResult=false] - returned when the journal EXISTS
79
+ * but reading it throws (transient lock / fd exhaustion). The cold-load
80
+ * scanners keep the false default ("not activated"); the discard predicate
81
+ * passes true so an I/O hiccup can never hide a REAL session.
78
82
  * @returns {boolean}
79
83
  */
80
- function scanJournal(dir, budget, onLine) {
84
+ function scanJournal(dir, budget, onLine, ioErrorResult = false) {
81
85
  const journal = join(dir, 'journal.jsonl');
82
86
  if (!existsSync(journal)) return false;
83
87
  let fd;
@@ -105,7 +109,7 @@ function scanJournal(dir, budget, onLine) {
105
109
  const last = (carry + decoder.end()).trim();
106
110
  return !!(last && onLine(last));
107
111
  } catch {
108
- return false;
112
+ return ioErrorResult;
109
113
  } finally {
110
114
  if (fd !== undefined) { try { closeSync(fd); } catch {} }
111
115
  }
@@ -167,6 +171,69 @@ export function sessionHasCompletedMainTurn(dir) {
167
171
  });
168
172
  }
169
173
 
174
+ /**
175
+ * Does this session dir have at least one req of kind 'main' OR 'teammate'?
176
+ * The positive half of the discardable-session predicate. Budgeted with the
177
+ * WIDE budget (not MAIN_REQ_SCAN_BUDGET): journal req lines run ~1.1-1.3KB
178
+ * (headers + params), and time-driven heartbeat/countTokens lines can pile up
179
+ * before a real session's first main — a 256KB budget could give up early and
180
+ * misjudge a REAL session as discardable. Sessions that have a main still
181
+ * early-exit at its line; only genuinely main-less journals pay the budget.
182
+ * @param {string} dir - absolute session dir
183
+ * @returns {boolean}
184
+ */
185
+ export function sessionHasMainOrTeammateReq(dir) {
186
+ // ioErrorResult=true: a transient read error must KEEP the session (treat
187
+ // as main-bearing) — hiding a real session is the worse failure direction;
188
+ // a missing journal still returns false (discard is correct there).
189
+ return scanJournal(dir, COMPLETED_TURN_SCAN_BUDGET, (line) => {
190
+ if (!line.includes('"ph":"req"')) return false;
191
+ if (!line.includes('"kind":"main"') && !line.includes('"kind":"teammate"')) return false;
192
+ try {
193
+ const o = JSON.parse(line);
194
+ return !!(o && o.ph === 'req' && (o.kind === 'main' || o.kind === 'teammate'));
195
+ } catch { return false; } // torn line — keep scanning
196
+ }, true);
197
+ }
198
+
199
+ /**
200
+ * Discardable-session predicate (2026-07-16): a session dir that must be
201
+ * DISCARDED by every read surface — never listed, counted, followed, or used
202
+ * as a candidate in any logic. These are orphan dirs minted by Claude Code's
203
+ * quota probes (max_tokens:1, one `'quota'` user message, a throwaway
204
+ * session_id per probe — fired at launches and agent-team spawns), plus any
205
+ * torn/empty dir with no renderable identity.
206
+ *
207
+ * discard ⟺ meta.leader ABSENT (not a teammate session)
208
+ * AND no journal req line of kind 'main' or 'teammate'
209
+ *
210
+ * The 'teammate' kind clause is the safety net for a torn meta.json. Every
211
+ * real session carries a main req at/near the journal head (verified across
212
+ * the full real dataset: 18/18 main-less dirs were single quota probes), so a
213
+ * kept session early-exits its scan cheaply. Read-side only and self-healing:
214
+ * the moment a dir gains its first main req, the predicate flips and every
215
+ * surface picks it up on its next scan/poll.
216
+ *
217
+ * KEEP IN SYNC: adapter.js listV2Sessions pre-computes the same verdict
218
+ * inline (hasMainOrTeammate inside its existing full journal fold — unbounded,
219
+ * vs this predicate's 8MB budget; intentional asymmetry) and then CONFIRMS a
220
+ * discard through this predicate so the error→keep direction is shared.
221
+ * Change the rule here and there together.
222
+ *
223
+ * @param {string} dir - absolute session dir
224
+ * @param {object|null} [meta] - pre-parsed meta.json (avoids a re-read);
225
+ * omitted → read here; unreadable → treated as leaderless (journal decides)
226
+ * @returns {boolean} true = discard everywhere
227
+ */
228
+ export function isDiscardableSession(dir, meta) {
229
+ let m = meta;
230
+ if (m === undefined) {
231
+ try { m = JSON.parse(readFileSync(join(dir, 'meta.json'), 'utf-8')); } catch { m = null; }
232
+ }
233
+ if (m && m.leader) return false;
234
+ return !sessionHasMainOrTeammateReq(dir);
235
+ }
236
+
170
237
  /**
171
238
  * The newest readable, non-teammate session that HAS a main turn, as
172
239
  * `{ dir, sessionId }`, or null. "Readable" mirrors listV2Sessions' gates (spec
@@ -332,6 +332,19 @@ export class V2Writer {
332
332
  convResult = s.convs.ingest(convKey, msgs, { seq, rid });
333
333
  }
334
334
 
335
+ // Residual body params — every top-level body field except the three that
336
+ // already have dedicated stores (messages → conv, system/tools → blobs).
337
+ // Inlined whole on the req line like headers, so the adapter can rebuild
338
+ // the full v1 body (max_tokens, temperature, thinking, metadata, …).
339
+ let params = null;
340
+ if (entry.body && typeof entry.body === 'object' && !Array.isArray(entry.body)) {
341
+ const rest = { ...entry.body };
342
+ delete rest.messages;
343
+ delete rest.system;
344
+ delete rest.tools;
345
+ if (Object.keys(rest).length > 0) params = rest;
346
+ }
347
+
335
348
  // 3. Journal req line — LAST, so it never references missing content.
336
349
  s.journal.writeReq({
337
350
  seq,
@@ -345,6 +358,7 @@ export class V2Writer {
345
358
  ...(entry.body && entry.body.model && { model: entry.body.model }),
346
359
  ...(entry.isStream && { isStream: true }),
347
360
  ...(entry.headers && { headers: entry.headers }),
361
+ ...(params && { params }),
348
362
  ...((toolsRef || sysRef) && { blobs: { ...(toolsRef && { tools: toolsRef }), ...(sysRef && { sys: sysRef }) } }),
349
363
  ...(convResult && { msgFrom: convResult.msgFrom, msgTo: convResult.msgTo }),
350
364
  ...(convResult && convResult.evt && { evt: convResult.evt }),
@@ -431,6 +445,7 @@ export class V2Writer {
431
445
  rid,
432
446
  body: respBody,
433
447
  ...(resp && resp.headers && { headers: resp.headers }),
448
+ ...(resp && resp.statusText && { statusText: resp.statusText }),
434
449
  }) + '\n'
435
450
  );
436
451
 
@@ -0,0 +1,167 @@
1
+ /**
2
+ * Wire compression for client-facing SSE / streaming JSON responses.
3
+ *
4
+ * Negotiation is br|identity only: every real browser sends `br` in
5
+ * Accept-Encoding over plain http, while non-browser clients (curl, tests)
6
+ * that omit it keep today's plaintext bytes untouched. gzip is deliberately
7
+ * not offered — its 32KB window cannot back-reference the repeated multi-KB
8
+ * tools/system blocks or the growing conversation history, so it measured
9
+ * 1.8-3x against brotli's 20-80x on real sessions.
10
+ *
11
+ * CONVENTION: on a response that went through sseHead(), every subsequent
12
+ * byte MUST be written via sseWrite()/wireEnd() — a bare res.write() would
13
+ * interleave plaintext into the brotli stream and corrupt it for the client.
14
+ * All SSE broadcast paths (log-watcher send*ToClients, workspaces reload,
15
+ * server.js update badge) route through sseWrite for this reason.
16
+ *
17
+ * Escape hatches: CCV_WIRE_COMPRESSION=off disables negotiation entirely;
18
+ * CCV_BROTLI_QUALITY overrides the encoder quality (default 9 — measured
19
+ * knee on large sessions; q5 collapses to ~2.5x there).
20
+ */
21
+ import zlib from 'node:zlib';
22
+ import { reportSwallowed } from './error-report.js';
23
+ import { awaitDrainOrClose } from './sse-backpressure.js';
24
+
25
+ const C = zlib.constants;
26
+ const DEFAULT_QUALITY = 9;
27
+ const LGWIN = 24; // 16MB window: must cover the repeat distance of multi-MB live frames
28
+
29
+ /** Wire v3 flag parsing (default ON; '0' or 'off' is the escape hatch).
30
+ * Pure so the env contract is unit-testable — the const in server.js is
31
+ * evaluated once at import and can't be toggled in-process. */
32
+ export function isWireV3Enabled(v) {
33
+ return v !== '0' && v !== 'off';
34
+ }
35
+
36
+ function negotiatedEncoding(req) {
37
+ if (process.env.CCV_WIRE_COMPRESSION === 'off') return null;
38
+ const accept = req.headers && req.headers['accept-encoding'];
39
+ if (typeof accept !== 'string') return null;
40
+ // RFC 9110: a qvalue of 0 means "not acceptable" — `br;q=0` is a refusal.
41
+ for (const token of accept.split(',')) {
42
+ const [name, ...params] = token.trim().split(';');
43
+ if (name.trim() !== 'br') continue;
44
+ const q = params.map((p) => p.trim()).find((p) => p.startsWith('q='));
45
+ if (q && !(parseFloat(q.slice(2)) > 0)) return null;
46
+ return 'br';
47
+ }
48
+ return null;
49
+ }
50
+
51
+ function makeEncoder() {
52
+ let quality = Number(process.env.CCV_BROTLI_QUALITY);
53
+ if (!Number.isInteger(quality) || quality < 1 || quality > 11) quality = DEFAULT_QUALITY;
54
+ return zlib.createBrotliCompress({
55
+ params: {
56
+ [C.BROTLI_PARAM_QUALITY]: quality,
57
+ [C.BROTLI_PARAM_LGWIN]: LGWIN,
58
+ },
59
+ });
60
+ }
61
+
62
+ /**
63
+ * Negotiate Content-Encoding and send the response head. On a `br` hit the
64
+ * encoder is piped into `res` and attached as `res._wireEnc`; otherwise this
65
+ * is exactly `res.writeHead(status, headers)` (plaintext path unchanged).
66
+ * The encoder is destroyed on response close, freeing its 16MB window.
67
+ *
68
+ * @param {import('node:http').IncomingMessage} req
69
+ * @param {import('node:http').ServerResponse} res
70
+ * @param {number} status
71
+ * @param {Record<string, string>} headers
72
+ * @param {{flush?: boolean}} [opts] - flush:false = whole-stream response (no
73
+ * per-macrotask flush; wireEnd's FINISH trailer is the only boundary —
74
+ * better ratio for bulk JSON like /api/requests that has no liveness need)
75
+ */
76
+ export function sseHead(req, res, status, headers, opts) {
77
+ const enc = negotiatedEncoding(req);
78
+ if (!enc) {
79
+ // Vary on BOTH branches: the resource is negotiated either way, and a
80
+ // caching intermediary must key on Accept-Encoding for it.
81
+ res.writeHead(status, { ...headers, 'Vary': 'Accept-Encoding' });
82
+ return;
83
+ }
84
+ const encoder = makeEncoder();
85
+ if (opts && opts.flush === false) encoder._wireNoFlush = true;
86
+ encoder.on('error', (err) => {
87
+ // A compressor error mid-stream is unrecoverable for this client —
88
+ // surface it (silent loss here means a wedged UI) and drop the socket.
89
+ reportSwallowed('sse.compress', err);
90
+ try { res.destroy(); } catch { /* already gone */ }
91
+ });
92
+ encoder.pipe(res);
93
+ res._wireEnc = encoder;
94
+ res.on('close', () => {
95
+ res._wireEnc = null;
96
+ try { encoder.destroy(); } catch { /* already destroyed */ }
97
+ });
98
+ res.writeHead(status, { ...headers, 'Content-Encoding': 'br', 'Vary': 'Accept-Encoding' });
99
+ }
100
+
101
+ function scheduleFlush(encoder) {
102
+ if (encoder._wireNoFlush || encoder._wireFlushScheduled) return;
103
+ encoder._wireFlushScheduled = true;
104
+ setImmediate(() => {
105
+ encoder._wireFlushScheduled = false;
106
+ if (encoder.destroyed || encoder.writableEnded) return;
107
+ // SYNC flush per macrotask: bytes of every event written this tick leave
108
+ // immediately (SSE liveness) while the window is retained (compression
109
+ // context takeover across events — the source of the measured 20-80x).
110
+ encoder.flush(C.BROTLI_OPERATION_FLUSH);
111
+ });
112
+ }
113
+
114
+ /**
115
+ * Write one SSE chunk through the response's negotiated encoding.
116
+ * Returns the write() boolean of the stream actually written to; combine
117
+ * with needsDrain(res) for socket-level backpressure on compressed paths.
118
+ */
119
+ export function sseWrite(res, str) {
120
+ const encoder = res._wireEnc;
121
+ if (!encoder) return res.write(str);
122
+ if (encoder.destroyed || encoder.writableEnded) return false;
123
+ const ok = encoder.write(str);
124
+ scheduleFlush(encoder);
125
+ return ok;
126
+ }
127
+
128
+ /** Socket-level backpressure signal (true on both plain and compressed paths). */
129
+ export function needsDrain(res) {
130
+ return res.writableNeedDrain === true;
131
+ }
132
+
133
+ /**
134
+ * Wait until the response's write target can accept more data (or the
135
+ * connection dies, or timeoutMs elapses). On the plain path this is exactly
136
+ * awaitDrainOrClose(res). On the compressed path the pressured stream is the
137
+ * ENCODER (its 'drain' fires when compression consumes the input backlog) —
138
+ * waiting on res 'drain' there can stall the full timeout, because compressed
139
+ * output is 20-80x smaller and may never backpressure the socket at all.
140
+ */
141
+ export function awaitWireDrain(res, timeoutMs) {
142
+ const encoder = res._wireEnc;
143
+ if (!encoder) return awaitDrainOrClose(res, timeoutMs);
144
+ return new Promise((resolve) => {
145
+ let t;
146
+ const done = () => {
147
+ clearTimeout(t);
148
+ encoder.off('drain', done);
149
+ encoder.off('close', done);
150
+ res.off('close', done);
151
+ res.off('error', done);
152
+ resolve();
153
+ };
154
+ t = setTimeout(done, timeoutMs);
155
+ encoder.once('drain', done);
156
+ encoder.once('close', done); // encoder is destroyed on res close
157
+ res.once('close', done);
158
+ res.once('error', done);
159
+ });
160
+ }
161
+
162
+ /** End the response, flushing the encoder trailer first on compressed paths. */
163
+ export function wireEnd(res) {
164
+ const encoder = res._wireEnc;
165
+ if (!encoder) { res.end(); return; }
166
+ if (!encoder.destroyed && !encoder.writableEnded) encoder.end(); // pipe ends res
167
+ }