cc-viewer 1.7.1 → 1.7.3
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.
- package/cli.js +21 -3
- package/dist/assets/{App-gUSmU6Ny.js → App-DRsuJXEw.js} +1 -1
- package/dist/assets/{MdxEditorPanel-DAR39A3S.js → MdxEditorPanel-D_dewIIc.js} +1 -1
- package/dist/assets/{Mobile-qfRsRth0.js → Mobile-BIzb7vpA.js} +1 -1
- package/dist/assets/{index-B51Ci0GL.js → index-DidP9FCD.js} +2 -2
- package/dist/assets/{seqResourceLoaders-DZJxMMaR.js → seqResourceLoaders-BtGpfGRW.js} +2 -2
- package/dist/index.html +1 -1
- package/package.json +1 -1
- package/server/i18n.js +20 -0
- package/server/interceptor.js +45 -1
- package/server/lib/ensure-hooks.js +28 -2
- package/server/lib/log-management.js +1 -0
- package/server/lib/log-watcher.js +8 -0
- package/server/lib/pid-alive.js +15 -0
- package/server/lib/session-start-bridge.js +121 -0
- package/server/lib/settings-merge.js +170 -0
- package/server/lib/stats-worker.js +21 -2
- package/server/lib/v2/adapter.js +52 -12
- package/server/lib/v2/journal.js +9 -5
- package/server/lib/v2/jsonl-read.js +127 -0
- package/server/lib/v2/live-feed.js +302 -66
- package/server/lib/v2/meta-rows.js +22 -21
- package/server/lib/v2/replay.js +5 -5
- package/server/lib/v2/session-owner.js +135 -0
- package/server/lib/v2/session-select.js +84 -4
- package/server/lib/v2/v2-writer.js +181 -6
- package/server/pty-manager.js +28 -4
- package/server/routes/events.js +44 -0
- package/server/routes/im.js +1 -1
- package/server/server.js +4 -1
- package/server/workspace-registry.js +5 -0
package/server/lib/v2/adapter.js
CHANGED
|
@@ -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
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
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
|
}
|
|
@@ -491,6 +494,14 @@ export class SessionSynthesizer {
|
|
|
491
494
|
phase: 'placeholder',
|
|
492
495
|
entry,
|
|
493
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 }),
|
|
494
505
|
stateRef: conv ? conv.state : null,
|
|
495
506
|
};
|
|
496
507
|
this._await.set(seq, item);
|
|
@@ -563,7 +574,15 @@ export class SessionSynthesizer {
|
|
|
563
574
|
function* iterateSessionItems(sessionDir, opts = {}) {
|
|
564
575
|
const projectDir = dirname(dirname(sessionDir));
|
|
565
576
|
const sessionId = basename(sessionDir);
|
|
566
|
-
|
|
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
|
+
}
|
|
567
586
|
if (session.unsupported) {
|
|
568
587
|
reportSwallowed('v2-read.unsupported-wire-format', new Error(`${sessionId}: wireFormat=${session.wireFormat}`));
|
|
569
588
|
return;
|
|
@@ -628,6 +647,12 @@ export function findTeammateSessionDirs(sessionDir, leaderUuid) {
|
|
|
628
647
|
continue;
|
|
629
648
|
}
|
|
630
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;
|
|
631
656
|
leaderStarts.push({ sid: name, startTs: (meta && meta.startTs) || '' });
|
|
632
657
|
continue;
|
|
633
658
|
}
|
|
@@ -1081,8 +1106,12 @@ export function listV2Sessions(projectDir) {
|
|
|
1081
1106
|
const reqKind = new Map();
|
|
1082
1107
|
let turns = 0;
|
|
1083
1108
|
let sentinelVersion = null;
|
|
1109
|
+
let hasMainOrTeammate = false;
|
|
1084
1110
|
for (const line of readJsonlTolerant(join(dir, 'journal.jsonl'))) {
|
|
1085
|
-
if (line.ph === 'req')
|
|
1111
|
+
if (line.ph === 'req') {
|
|
1112
|
+
reqKind.set(line.seq, line.kind);
|
|
1113
|
+
if (line.kind === 'main' || line.kind === 'teammate') hasMainOrTeammate = true;
|
|
1114
|
+
}
|
|
1086
1115
|
else if (line.ph === 'done' && reqKind.get(line.seq) === 'main') {
|
|
1087
1116
|
turns++;
|
|
1088
1117
|
reqKind.delete(line.seq); // fold duplicate done lines (§14)
|
|
@@ -1117,6 +1146,17 @@ export function listV2Sessions(projectDir) {
|
|
|
1117
1146
|
turns,
|
|
1118
1147
|
size: dirSizeSync(dir),
|
|
1119
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),
|
|
1120
1160
|
});
|
|
1121
1161
|
} catch { /* one unreadable session must not break the list */ }
|
|
1122
1162
|
}
|
package/server/lib/v2/journal.js
CHANGED
|
@@ -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 {
|
|
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
|
-
//
|
|
35
|
-
//
|
|
36
|
-
|
|
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
|
+
}
|