cc-viewer 1.9.17 → 1.9.19
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 +51 -1
- package/dist/assets/{App-BlNbziAQ.js → App-5GFhfhAM.js} +2 -2
- package/dist/assets/{MdxEditorPanel-C9cr-WBH.js → MdxEditorPanel-C71H-8ZP.js} +1 -1
- package/dist/assets/{Mobile-CDQPYYQo.js → Mobile-CAqle3TP.js} +1 -1
- package/dist/assets/{ProxyStatsModal-BEqrTR-F.js → ProxyStatsModal-B0ySgEsq.js} +1 -1
- package/dist/assets/{index-B4J-uwYG.js → index-C4_-ImaE.js} +2 -2
- package/dist/assets/{seqResourceLoaders-B-5xGs9q.css → seqResourceLoaders-C8O3LxzJ.css} +1 -1
- package/dist/assets/seqResourceLoaders-CXYXv_fq.js +2 -0
- package/dist/index.html +1 -1
- package/dist/voice-packs/default/agentLoopStart.wav +0 -0
- package/dist/voice-packs/default/pack.json +11 -0
- package/dist/voice-packs/sanguo/agentLoopStart.wav +0 -0
- package/dist/voice-packs/sanguo/pack.json +11 -0
- package/node_modules/@ccv/core/src/voice-pack-events.js +12 -3
- package/package.json +2 -2
- package/server/lib/attach-buffer.js +164 -0
- package/server/lib/plugin-loader.js +36 -2
- package/server/lib/plugin-runtime.js +12 -2
- package/server/lib/sdk-manager.js +727 -79
- package/server/lib/v2/live-feed.js +108 -7
- package/server/lib/v2/meta-rows.js +35 -4
- package/server/proxy.js +52 -8
- package/server/routes/events.js +64 -1
- package/server/routes/files-content.js +24 -1
- package/server/server.js +131 -11
- package/dist/assets/seqResourceLoaders-DaZ8fZXu.js +0 -2
|
@@ -74,6 +74,12 @@ export function readNewLines(cursor, chunkBytes = READ_CHUNK_BYTES, maxLineBytes
|
|
|
74
74
|
} catch {
|
|
75
75
|
return []; // not created yet
|
|
76
76
|
}
|
|
77
|
+
// Suppressed-seed cap: while seeding history, read only up to the byte size
|
|
78
|
+
// the feed last observed (cursor._limit). Bytes past it are read live on the
|
|
79
|
+
// follow-up pass. The cap compares BEFORE the shrink check so a file that
|
|
80
|
+
// grew past the cap reads exactly to it (offset === capped size ⇒ no-op);
|
|
81
|
+
// the partial-line carry already tolerates a cap landing mid-line.
|
|
82
|
+
if (cursor._limit != null) size = Math.min(size, cursor._limit);
|
|
77
83
|
if (size < cursor.offset) return null;
|
|
78
84
|
if (size === cursor.offset) return [];
|
|
79
85
|
if (cursor._pendBufs === undefined) {
|
|
@@ -194,6 +200,15 @@ export class V2LiveFeed {
|
|
|
194
200
|
this._clientFilter = typeof opts.clientFilter === 'function' ? opts.clientFilter : null;
|
|
195
201
|
this._sessions = new Map(); // dir → cursor bundle
|
|
196
202
|
this._seenDirs = new Map(); // dir → last observed journal mtimeMs (attached or not)
|
|
203
|
+
// dir → { journal, responses, conv: Map<path,size> } — per-file byte sizes
|
|
204
|
+
// captured at the feed's last observation of the dir (initial scan, attach,
|
|
205
|
+
// eviction, safety scan). A suppressed re-attach seeds only UP TO these
|
|
206
|
+
// offsets and reads everything past them live: bytes appended after the
|
|
207
|
+
// last observation (a resumed history session's new turn) are genuinely
|
|
208
|
+
// new, not history the cold-load channel owns. Without this the suppressed
|
|
209
|
+
// seed swallowed them (offset advanced past, never re-read → the new
|
|
210
|
+
// message only reappeared on a cold reload).
|
|
211
|
+
this._seenSizes = new Map();
|
|
197
212
|
this._discardGated = new Set(); // dirs refused by the discard gate — first real attach must NOT suppress history
|
|
198
213
|
this._sessionsRoot = null;
|
|
199
214
|
this._rootWatcher = null;
|
|
@@ -245,6 +260,7 @@ export class V2LiveFeed {
|
|
|
245
260
|
for (const cur of this._sessions.values()) this._closeCursor(cur);
|
|
246
261
|
this._sessions.clear();
|
|
247
262
|
this._seenDirs.clear();
|
|
263
|
+
this._seenSizes.clear();
|
|
248
264
|
this._discardGated.clear();
|
|
249
265
|
this._sessionsRoot = null;
|
|
250
266
|
}
|
|
@@ -263,7 +279,10 @@ export class V2LiveFeed {
|
|
|
263
279
|
// must be seeded suppressed, or the re-attach replays the whole session
|
|
264
280
|
// through the broadcast path (the exact OOM this module was fixed for).
|
|
265
281
|
// Only a genuinely never-seen dir emits from byte 0.
|
|
266
|
-
cur = this._attach(sessionDir, {
|
|
282
|
+
cur = this._attach(sessionDir, {
|
|
283
|
+
suppressExisting: this._seenDirs.has(sessionDir),
|
|
284
|
+
suppressUpTo: this._seenSizes.get(sessionDir) || null,
|
|
285
|
+
});
|
|
267
286
|
if (!cur) {
|
|
268
287
|
if (_retries < TICK_RETRY_MAX) {
|
|
269
288
|
setTimeout(() => this.tick(sessionDir, _retries + 1), TICK_RETRY_MS);
|
|
@@ -319,11 +338,15 @@ export class V2LiveFeed {
|
|
|
319
338
|
const dir = join(this._sessionsRoot, name);
|
|
320
339
|
const mtime = this._journalMtime(dir);
|
|
321
340
|
this._seenDirs.set(dir, mtime);
|
|
341
|
+
// Record the byte watermark NOW (before any attach) so a later suppressed
|
|
342
|
+
// re-attach of this pre-existing dir seeds only up to here and reads any
|
|
343
|
+
// post-observation append live.
|
|
344
|
+
this._seenSizes.set(dir, this._snapshotSizes(dir));
|
|
322
345
|
// Pre-existing dirs with recent activity are live producers that predate
|
|
323
346
|
// this feed (mid-run server start / workspace switch): follow them but
|
|
324
347
|
// suppress their history — the cold reload channel covers it.
|
|
325
348
|
if (mtime > 0 && this._now() - mtime <= ATTACH_RECENT_MS) {
|
|
326
|
-
this._attach(dir, { suppressExisting: true });
|
|
349
|
+
this._attach(dir, { suppressExisting: true, suppressUpTo: this._seenSizes.get(dir) });
|
|
327
350
|
}
|
|
328
351
|
}
|
|
329
352
|
}
|
|
@@ -346,6 +369,8 @@ export class V2LiveFeed {
|
|
|
346
369
|
if (mtime === 0) return; // no journal yet — the next event/tick retries
|
|
347
370
|
const seenBefore = this._seenDirs.has(dir);
|
|
348
371
|
this._seenDirs.set(dir, mtime);
|
|
372
|
+
const suppressUpTo = this._seenSizes.get(dir) || this._snapshotSizes(dir);
|
|
373
|
+
this._seenSizes.set(dir, suppressUpTo);
|
|
349
374
|
if (!seenBefore) {
|
|
350
375
|
// Genuinely new session dir: everything in it is new — emit from byte 0
|
|
351
376
|
// when it was born after the feed started, otherwise treat as history.
|
|
@@ -358,7 +383,7 @@ export class V2LiveFeed {
|
|
|
358
383
|
return !(bt > 0) || bt >= this._startedAt - 1000;
|
|
359
384
|
} catch { return true; }
|
|
360
385
|
})();
|
|
361
|
-
this._attach(dir, { suppressExisting: !bornAfterStart });
|
|
386
|
+
this._attach(dir, { suppressExisting: !bornAfterStart, suppressUpTo });
|
|
362
387
|
}
|
|
363
388
|
}
|
|
364
389
|
|
|
@@ -366,9 +391,33 @@ export class V2LiveFeed {
|
|
|
366
391
|
try { return statSync(join(dir, 'journal.jsonl')).mtimeMs; } catch { return 0; }
|
|
367
392
|
}
|
|
368
393
|
|
|
394
|
+
/** Snapshot the current byte size of every file a cursor reads (journal,
|
|
395
|
+
* responses, and every conv epoch file) — the watermark a later suppressed
|
|
396
|
+
* re-attach seeds up to. A missing file (ENOENT) sizes 0 (all of it is new);
|
|
397
|
+
* any OTHER stat error sizes MAX_SAFE_INTEGER so a transient failure
|
|
398
|
+
* (Windows AV/EBUSY/EPERM) never zeroes the watermark and re-arms a
|
|
399
|
+
* full-history replay on the next attach (same error direction as
|
|
400
|
+
* _seekCursorsToEof). */
|
|
401
|
+
_snapshotSizes(dir) {
|
|
402
|
+
const sizeOf = (p) => {
|
|
403
|
+
try { return statSync(p).size; } catch (err) {
|
|
404
|
+
if (err && err.code === 'ENOENT') return 0;
|
|
405
|
+
reportSwallowed('v2-live.snapshot-stat', err);
|
|
406
|
+
return Number.MAX_SAFE_INTEGER;
|
|
407
|
+
}
|
|
408
|
+
};
|
|
409
|
+
const conv = new Map();
|
|
410
|
+
for (const { path } of this._enumerateConvFiles(dir)) conv.set(path, sizeOf(path));
|
|
411
|
+
return {
|
|
412
|
+
journal: sizeOf(join(dir, 'journal.jsonl')),
|
|
413
|
+
responses: sizeOf(join(dir, 'responses.jsonl')),
|
|
414
|
+
conv,
|
|
415
|
+
};
|
|
416
|
+
}
|
|
417
|
+
|
|
369
418
|
// ── per-session cursors ────────────────────────────────────────────────────
|
|
370
419
|
|
|
371
|
-
_attach(dir, { suppressExisting }) {
|
|
420
|
+
_attach(dir, { suppressExisting, suppressUpTo = null }) {
|
|
372
421
|
if (this._sessions.has(dir)) return this._sessions.get(dir);
|
|
373
422
|
if (!existsSync(join(dir, 'journal.jsonl'))) return null;
|
|
374
423
|
// Multi-window isolation: a dir exclusively claimed by ANOTHER live ccv
|
|
@@ -470,7 +519,39 @@ export class V2LiveFeed {
|
|
|
470
519
|
} catch (err) {
|
|
471
520
|
reportSwallowed('v2-live.session-watch', err);
|
|
472
521
|
}
|
|
473
|
-
|
|
522
|
+
// Seed read. When suppressing with a known watermark, cap each cursor at
|
|
523
|
+
// the last-observed size so the seed swallows ONLY bytes that predate the
|
|
524
|
+
// feed's last look; anything past it was appended after and is genuinely
|
|
525
|
+
// new (a resumed history session's fresh turn), not cold-load history.
|
|
526
|
+
const cappedSeed = cur.suppress && suppressUpTo;
|
|
527
|
+
if (cappedSeed) {
|
|
528
|
+
cur.journal._limit = suppressUpTo.journal;
|
|
529
|
+
cur.responses._limit = suppressUpTo.responses;
|
|
530
|
+
for (const { path } of this._enumerateConvFiles(dir)) {
|
|
531
|
+
const fc = { key: null, path, offset: 0 };
|
|
532
|
+
// A conv file in the watermark caps at its observed size. A conv file
|
|
533
|
+
// NOT in the watermark did not exist at the last look (a new conv key
|
|
534
|
+
// or a post-/clear epoch minted by the resumed turn), so its content
|
|
535
|
+
// is entirely new — cap at 0 so the suppressed seed reads NONE of it
|
|
536
|
+
// and the follow-up (suppress=false) reads + forwards it live. Leaving
|
|
537
|
+
// it uncapped here would let the suppressed seed consume it silently:
|
|
538
|
+
// the follow-up would then find the offset at EOF and the wire-v3
|
|
539
|
+
// client would assemble the new turn with empty messages.
|
|
540
|
+
fc._limit = (suppressUpTo.conv && suppressUpTo.conv.has(path)) ? suppressUpTo.conv.get(path) : 0;
|
|
541
|
+
cur.convFiles.set(path, fc);
|
|
542
|
+
}
|
|
543
|
+
}
|
|
544
|
+
try {
|
|
545
|
+
this._readCursor(cur); // seed (suppressed history or brand-new content)
|
|
546
|
+
} finally {
|
|
547
|
+
// Always drop the caps: a leaked _limit clamps every later read to
|
|
548
|
+
// size===offset, permanently freezing the cursor.
|
|
549
|
+
if (cappedSeed) {
|
|
550
|
+
delete cur.journal._limit;
|
|
551
|
+
delete cur.responses._limit;
|
|
552
|
+
for (const fc of cur.convFiles.values()) delete fc._limit;
|
|
553
|
+
}
|
|
554
|
+
}
|
|
474
555
|
} else {
|
|
475
556
|
// No per-dir watcher either: a promote can bring hundreds of dead dirs
|
|
476
557
|
// at once and each fs.watch costs an fd. The safety poll's unconditional
|
|
@@ -479,6 +560,15 @@ export class V2LiveFeed {
|
|
|
479
560
|
this._seekCursorsToEof(cur);
|
|
480
561
|
}
|
|
481
562
|
cur.suppress = false;
|
|
563
|
+
// Follow-up live read: bytes appended after the watermark but before the
|
|
564
|
+
// per-dir watcher was armed (a resumed history session whose first new turn
|
|
565
|
+
// raced this attach) sit past the capped offsets with no watcher event to
|
|
566
|
+
// deliver them — read them now so the new turn broadcasts instead of
|
|
567
|
+
// waiting for the next append / safety poll (and never arriving if the
|
|
568
|
+
// conversation goes quiet). No-op when nothing new is on disk.
|
|
569
|
+
if (!convertOrigin && suppressUpTo) this._readCursor(cur);
|
|
570
|
+
// Refresh the watermark to the post-attach state for any later re-attach.
|
|
571
|
+
this._seenSizes.set(dir, this._snapshotSizes(dir));
|
|
482
572
|
return cur;
|
|
483
573
|
}
|
|
484
574
|
|
|
@@ -788,6 +878,7 @@ export class V2LiveFeed {
|
|
|
788
878
|
// ownership release (owner exits) plus new activity re-attaches through
|
|
789
879
|
// the normal mtime-bump path below.
|
|
790
880
|
if (this._isForeignLiveOwned(cur.dir)) {
|
|
881
|
+
this._seenSizes.set(cur.dir, this._snapshotSizes(cur.dir)); // watermark before detach
|
|
791
882
|
this._closeCursor(cur);
|
|
792
883
|
this._sessions.delete(cur.dir);
|
|
793
884
|
this._seenDirs.set(cur.dir, this._journalMtime(cur.dir));
|
|
@@ -799,6 +890,7 @@ export class V2LiveFeed {
|
|
|
799
890
|
if (this._idleEvictMs > 0 && cur.openReqs.size === 0) {
|
|
800
891
|
const mtime = this._journalMtime(cur.dir);
|
|
801
892
|
if (mtime > 0 && this._now() - mtime > this._idleEvictMs) {
|
|
893
|
+
this._seenSizes.set(cur.dir, this._snapshotSizes(cur.dir)); // watermark before eviction
|
|
802
894
|
this._closeCursor(cur);
|
|
803
895
|
this._sessions.delete(cur.dir);
|
|
804
896
|
this._seenDirs.set(cur.dir, mtime);
|
|
@@ -826,9 +918,18 @@ export class V2LiveFeed {
|
|
|
826
918
|
this._maybeAttachNew(dir);
|
|
827
919
|
} else if (mtime > seen) {
|
|
828
920
|
// A dir skipped at the initial scan (stale then) has resumed activity:
|
|
829
|
-
// follow it now
|
|
921
|
+
// follow it now. Seed suppressed ONLY up to the last-observed byte
|
|
922
|
+
// watermark (captured at scan/eviction — the mtime bump proves new
|
|
923
|
+
// bytes landed past it); the resumed turn itself is read live, not
|
|
924
|
+
// swallowed with the history it predates this feed alongside. A missing
|
|
925
|
+
// watermark means the feed never observed this dir's pre-activity state,
|
|
926
|
+
// so there is no safe boundary — fall back to suppress-to-EOF (null)
|
|
927
|
+
// rather than snapshotting NOW (which would cap ABOVE the new bytes and
|
|
928
|
+
// re-introduce the swallow).
|
|
929
|
+
const suppressUpTo = this._seenSizes.get(dir) || null;
|
|
830
930
|
this._seenDirs.set(dir, mtime);
|
|
831
|
-
this.
|
|
931
|
+
if (suppressUpTo) this._seenSizes.set(dir, suppressUpTo);
|
|
932
|
+
this._attach(dir, { suppressExisting: true, suppressUpTo });
|
|
832
933
|
}
|
|
833
934
|
}
|
|
834
935
|
}
|
|
@@ -193,7 +193,9 @@ const _coldBundleFlight = new SingleFlight({ ttlMs: 500 });
|
|
|
193
193
|
|
|
194
194
|
/**
|
|
195
195
|
* The complete v3 cold read for one /events connection: windowed rows +
|
|
196
|
-
* native conv/responses payloads, single-flighted per (dir, window).
|
|
196
|
+
* native conv/responses payloads, single-flighted per (dir, window). The
|
|
197
|
+
* returned `native.convSeqs` is the conv coverage the /events attach buffer
|
|
198
|
+
* uses to avoid double-replaying conv lines (see attach-buffer.js).
|
|
197
199
|
*/
|
|
198
200
|
export function readV2ColdBundle(sessionDir, { limit = 0, since = null } = {}) {
|
|
199
201
|
const key = `bundle:${sessionDir}|${limit}|${since || ''}`;
|
|
@@ -217,8 +219,12 @@ export function readV2ColdBundle(sessionDir, { limit = 0, since = null } = {}) {
|
|
|
217
219
|
*
|
|
218
220
|
* @param {string} sessionDir - leader dir (teammate dirs folded like the rows)
|
|
219
221
|
* @param {object[]} rows - the window rows (readV2RequestsMeta output)
|
|
220
|
-
* @returns {Promise<{convPayloads: string[], respPayloads: string[]
|
|
221
|
-
*
|
|
222
|
+
* @returns {Promise<{convPayloads: string[], respPayloads: string[],
|
|
223
|
+
* convSeqs: Map<string, {seqs: Set<number>, baseSeq: number}>}>}
|
|
224
|
+
* pre-serialized JSON payloads for `v3_conv` / `v3_resp` SSE frames, plus the
|
|
225
|
+
* conv-line coverage keyed `${sessionId}\x00${channel}` — used by the /events
|
|
226
|
+
* attach buffer to skip replaying a v3_conv frame the cold bundle already
|
|
227
|
+
* carried (client conv replay is NOT idempotent; see attach-buffer.js).
|
|
222
228
|
*/
|
|
223
229
|
export async function readV2NativeCold(sessionDir, rows) {
|
|
224
230
|
const yieldLoop = () => new Promise((resolve) => setImmediate(resolve));
|
|
@@ -238,6 +244,7 @@ export async function readV2NativeCold(sessionDir, rows) {
|
|
|
238
244
|
|
|
239
245
|
const convPayloads = [];
|
|
240
246
|
const respPayloads = [];
|
|
247
|
+
const convSeqs = new Map();
|
|
241
248
|
for (const dir of dirs) {
|
|
242
249
|
const dirMeta = readMeta(dir);
|
|
243
250
|
const sessionId = (dirMeta && dirMeta.sessionId) || basename(dir);
|
|
@@ -270,6 +277,30 @@ export async function readV2NativeCold(sessionDir, rows) {
|
|
|
270
277
|
if (i % 2000 === 1999) await yieldLoop();
|
|
271
278
|
}
|
|
272
279
|
const windowRaws = raws.slice(start);
|
|
280
|
+
// Record every conv line the cold bundle is about to send so the /events
|
|
281
|
+
// attach buffer can skip replaying it. baseSeq = the smallest seq in the
|
|
282
|
+
// window (the snapshot baseline is the first in seq order) — a replayed seq
|
|
283
|
+
// below it was already reflected in the cold assembly and must not be
|
|
284
|
+
// re-consumed either. Only lines that PARSE are recorded: a torn trailing
|
|
285
|
+
// line (writer mid-append, no newline yet) is NOT in coverage — its seq is
|
|
286
|
+
// re-sent complete by the live feed once the newline lands, and the replay
|
|
287
|
+
// must not skip it (a skipped snapshot would corrupt the client's assembler).
|
|
288
|
+
{
|
|
289
|
+
const covKey = `${sessionId}\x00${key}`;
|
|
290
|
+
let cov = convSeqs.get(covKey);
|
|
291
|
+
if (!cov) { cov = { seqs: new Set(), baseSeq: Infinity }; convSeqs.set(covKey, cov); }
|
|
292
|
+
for (const raw of windowRaws) {
|
|
293
|
+
let seq = null;
|
|
294
|
+
try {
|
|
295
|
+
const ev = JSON.parse(raw);
|
|
296
|
+
if (Number.isInteger(ev && ev.seq)) seq = ev.seq;
|
|
297
|
+
} catch { /* torn line: not covered — the live re-send must not be skipped */ }
|
|
298
|
+
if (seq === null) continue;
|
|
299
|
+
cov.seqs.add(seq);
|
|
300
|
+
if (seq < cov.baseSeq) cov.baseSeq = seq;
|
|
301
|
+
}
|
|
302
|
+
if (cov.baseSeq === Infinity) cov.baseSeq = 0;
|
|
303
|
+
}
|
|
273
304
|
// Cap each frame at ~512KB of lines: a multi-MB single SSE event forces
|
|
274
305
|
// one giant synchronous JSON.parse on the client's main thread (no
|
|
275
306
|
// paint, no rAF — the loading UI freezes). Small frames are macrotask
|
|
@@ -287,7 +318,7 @@ export async function readV2NativeCold(sessionDir, rows) {
|
|
|
287
318
|
await yieldLoop();
|
|
288
319
|
pushChunked(respPayloads, respRaws, (linesJson) => `{"sessionId":${JSON.stringify(sessionId)},"lines":[${linesJson}]}`);
|
|
289
320
|
}
|
|
290
|
-
return { convPayloads, respPayloads };
|
|
321
|
+
return { convPayloads, respPayloads, convSeqs };
|
|
291
322
|
}
|
|
292
323
|
|
|
293
324
|
const NATIVE_FRAME_BUDGET = 512 * 1024;
|
package/server/proxy.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
|
|
2
2
|
import { createServer } from 'node:http';
|
|
3
|
+
import { Readable, Transform, pipeline } from 'node:stream';
|
|
3
4
|
import { readFileSync, existsSync } from 'node:fs';
|
|
4
5
|
import { join, basename } from 'node:path';
|
|
5
6
|
import { homedir } from 'node:os';
|
|
@@ -11,7 +12,7 @@ import { getClaudeConfigDir } from '../findcc.js';
|
|
|
11
12
|
import { isAnthropicApiPath, classifyProxyRole } from './lib/interceptor-core.js';
|
|
12
13
|
import { parseAgentId, findHeader } from './lib/v2/agent-id.js';
|
|
13
14
|
import { liveSystemPromptEnabled } from './lib/system-prompt-live.js';
|
|
14
|
-
import { executeRequest, extractModel } from './lib/proxy/proxy-retry.js';
|
|
15
|
+
import { executeRequest, extractModel, isStreamResponse } from './lib/proxy/proxy-retry.js';
|
|
15
16
|
import { buildRecord, appendRecord, dailyFilePath, todayStr, emitProxyStatsUpdate } from './lib/proxy/proxy-stats.js';
|
|
16
17
|
import { reportSwallowed } from './lib/error-report.js';
|
|
17
18
|
import { LOG_DIR } from '../findcc.js';
|
|
@@ -231,7 +232,6 @@ export function startProxy() {
|
|
|
231
232
|
res.writeHead(response.status, responseHeaders);
|
|
232
233
|
|
|
233
234
|
if (response.body) {
|
|
234
|
-
const { Readable, pipeline } = await import('node:stream');
|
|
235
235
|
// @ts-ignore
|
|
236
236
|
const nodeStream = Readable.fromWeb(response.body);
|
|
237
237
|
// 持久 error handler 兜底:防止 pipeline 清理后延迟到达的 error 事件导致进程崩溃
|
|
@@ -388,15 +388,59 @@ async function handleLlmApiRequest(req, res, fullUrl, fetchOptions, body, proxyD
|
|
|
388
388
|
res.writeHead(finalStatus, responseHeaders);
|
|
389
389
|
|
|
390
390
|
if (response.body) {
|
|
391
|
-
const { Readable, pipeline } = await import('node:stream');
|
|
392
391
|
// @ts-ignore
|
|
393
392
|
const nodeStream = Readable.fromWeb(response.body);
|
|
394
393
|
nodeStream.on('error', () => {});
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
394
|
+
const streamIdleMs = retryConfig && retryConfig.mode === 'off'
|
|
395
|
+
? (retryConfig.streamIdleTimeoutMs ?? 0)
|
|
396
|
+
: 0;
|
|
397
|
+
// Only gate SSE (streaming) responses: a non-stream body legitimately arrives as one
|
|
398
|
+
// whole payload after a long upstream think, and must not be truncated mid-wait.
|
|
399
|
+
if (streamIdleMs > 0 && isStreamResponse(response)) {
|
|
400
|
+
// Off-mode-only idle watchdog: retry modes already attach the equivalent
|
|
401
|
+
// guard on the Web-stream side upstream (applyStreamIdleWatchdog). That
|
|
402
|
+
// variant cannot run for off mode — its pipeThrough races the
|
|
403
|
+
// interceptor's getReader() body reconstruction (spurious `fetch failed`).
|
|
404
|
+
// This Node-side Transform sits after Readable.fromWeb, so it is immune
|
|
405
|
+
// to that race. The timer arms at construction, BEFORE the first chunk,
|
|
406
|
+
// so a stream that never sends its first byte is still bounded.
|
|
407
|
+
let watchdog = null;
|
|
408
|
+
let idleTimer = null;
|
|
409
|
+
const disarmIdle = () => {
|
|
410
|
+
if (idleTimer) {
|
|
411
|
+
clearTimeout(idleTimer);
|
|
412
|
+
idleTimer = null;
|
|
413
|
+
}
|
|
414
|
+
};
|
|
415
|
+
const armIdle = () => {
|
|
416
|
+
disarmIdle();
|
|
417
|
+
idleTimer = setTimeout(() => {
|
|
418
|
+
idleTimer = null;
|
|
419
|
+
// Log unconditionally: a destroyed stream surfaces downstream as an unexplained
|
|
420
|
+
// `fetch failed`; without this line there is no way to attribute it.
|
|
421
|
+
console.warn(`[CC-Viewer Proxy] off-mode stream idle timeout (${streamIdleMs}ms) — destroying upstream stream`);
|
|
422
|
+
watchdog.destroy(new Error(`proxy stream idle timeout (${streamIdleMs}ms)`));
|
|
423
|
+
}, streamIdleMs);
|
|
424
|
+
if (typeof idleTimer.unref === 'function') idleTimer.unref();
|
|
425
|
+
};
|
|
426
|
+
watchdog = new Transform({
|
|
427
|
+
transform(chunk, _enc, cb) { armIdle(); cb(null, chunk); },
|
|
428
|
+
flush(cb) { disarmIdle(); cb(); },
|
|
429
|
+
destroy(err, cb) { disarmIdle(); cb(err); },
|
|
430
|
+
});
|
|
431
|
+
armIdle();
|
|
432
|
+
pipeline(nodeStream, watchdog, res, (err) => {
|
|
433
|
+
if (err && process.env.CCV_DEBUG) {
|
|
434
|
+
console.error('[CC-Viewer Proxy] Stream pipeline error:', err.message);
|
|
435
|
+
}
|
|
436
|
+
});
|
|
437
|
+
} else {
|
|
438
|
+
pipeline(nodeStream, res, (err) => {
|
|
439
|
+
if (err && process.env.CCV_DEBUG) {
|
|
440
|
+
console.error('[CC-Viewer Proxy] Stream pipeline error:', err.message);
|
|
441
|
+
}
|
|
442
|
+
});
|
|
443
|
+
}
|
|
400
444
|
} else if (response.text) {
|
|
401
445
|
// 竞速失败兜底 response:用 text() 取 body
|
|
402
446
|
const text = await response.text();
|
package/server/routes/events.js
CHANGED
|
@@ -10,6 +10,7 @@ import { sseHead, sseWrite, needsDrain, wireEnd, awaitWireDrain, isWireV3Enabled
|
|
|
10
10
|
import { scopeKeyForProducer } from '../lib/scope-router.js';
|
|
11
11
|
import { readV2ColdBundle } from '../lib/v2/meta-rows.js';
|
|
12
12
|
import { readV2SingleEntry } from '../lib/v2/adapter.js';
|
|
13
|
+
import { createAttachBufferClient, flushAttachBuffer } from '../lib/attach-buffer.js';
|
|
13
14
|
import { resolveSessionDirName, latestMainSessionDir } from '../lib/v2/session-select.js';
|
|
14
15
|
import { sanitizePathComponent, normalizeSessionCwd } from '../lib/v2/layout.js';
|
|
15
16
|
import { listLivePtys } from '../pty-manager.js';
|
|
@@ -213,12 +214,29 @@ export async function serveEventStream(sink, params, deps) {
|
|
|
213
214
|
const registrable = sink.registrable;
|
|
214
215
|
let closed = false;
|
|
215
216
|
let pingTimer = null;
|
|
217
|
+
// Attach buffer (2026-10): a stand-in client registered in deps.clients during
|
|
218
|
+
// the cold load so live v2 deltas landing in the cold-load→push gap are queued
|
|
219
|
+
// (not broadcast to the not-yet-registered real client, and lost). Replayed
|
|
220
|
+
// through the real sink after load_end, then swapped for the registrable.
|
|
221
|
+
// Declared BEFORE dispose: sink.onClose may fire dispose synchronously on
|
|
222
|
+
// registration, so the variable must be initialized before that hook exists.
|
|
223
|
+
let attachBufferClient = null;
|
|
224
|
+
// Conv coverage of the v3 cold bundle (`native.convSeqs`), used to skip
|
|
225
|
+
// replaying a v3_conv frame the bundle already carried. Stays null on the
|
|
226
|
+
// legacy wire or when the cold read fails → flush fails open (replays all).
|
|
227
|
+
let coldConvCoverage = null;
|
|
216
228
|
const dispose = () => {
|
|
217
229
|
if (closed) return;
|
|
218
230
|
closed = true;
|
|
219
231
|
if (pingTimer) { clearInterval(pingTimer); pingTimer = null; }
|
|
220
232
|
const idx = deps.clients.indexOf(registrable);
|
|
221
233
|
if (idx !== -1) deps.clients.splice(idx, 1);
|
|
234
|
+
if (attachBufferClient) {
|
|
235
|
+
const bi = deps.clients.indexOf(attachBufferClient);
|
|
236
|
+
if (bi !== -1) deps.clients.splice(bi, 1);
|
|
237
|
+
try { attachBufferClient.end(); } catch (err) { reportSwallowed('sse.attach-buffer.dispose', err); }
|
|
238
|
+
attachBufferClient = null;
|
|
239
|
+
}
|
|
222
240
|
};
|
|
223
241
|
// Register the close hook at the ENTRY (not after the history stream): a client that
|
|
224
242
|
// disconnects mid-cold-load must still clear the pingTimer and never be pushed — the
|
|
@@ -240,6 +258,13 @@ export async function serveEventStream(sink, params, deps) {
|
|
|
240
258
|
// 否则 streamRawEntriesAsync 的 setImmediate yield 间隙会让 watcher 的
|
|
241
259
|
// sendToClients 向该客户端推送 live entry,而 load_end 的 setState 会覆盖这些
|
|
242
260
|
// 已处理的 live entry,导致 对话条目"显示→消失→重现"闪烁。
|
|
261
|
+
//
|
|
262
|
+
// Attach buffer (2026-10): the client registered into deps.clients during the
|
|
263
|
+
// cold load is NOT the real res — it is a buffer proxy that never writes the
|
|
264
|
+
// socket. It carries the same view stamps as the registrable, queueing the live
|
|
265
|
+
// deltas owed to this connection while the cold load runs. The real res still
|
|
266
|
+
// receives its first data only after load_end (the anti-flicker invariant is
|
|
267
|
+
// unchanged); the queued frames are replayed at the flush below.
|
|
243
268
|
|
|
244
269
|
// SSE 心跳保活:每 30s 发送 ping 事件,防止连接被 OS/代理/浏览器静默断开
|
|
245
270
|
pingTimer = setInterval(() => {
|
|
@@ -319,6 +344,26 @@ export async function serveEventStream(sink, params, deps) {
|
|
|
319
344
|
// else the bound project. Stamped onto the sink registrable so per-project feeds can
|
|
320
345
|
// route live entries to exactly the clients viewing that project.
|
|
321
346
|
const viewedProject = projectParam || (_projectName || '');
|
|
347
|
+
// Attach buffer: register the stand-in client NOW, in the synchronous segment
|
|
348
|
+
// before the first await (readV2ColdBundle / streamRawEntriesAsync). From here
|
|
349
|
+
// until the flush-and-swap at registration, any live v2 delta routed to this
|
|
350
|
+
// view scope is queued on the proxy instead of being lost to the gap. The
|
|
351
|
+
// proxy carries the same project/instance stamps the registrable will get
|
|
352
|
+
// below, so filterClientsByViewProject/Instance deliver it exactly this
|
|
353
|
+
// connection's frames — no cross-project / cross-instance bleed.
|
|
354
|
+
if (!closed) {
|
|
355
|
+
attachBufferClient = createAttachBufferClient({
|
|
356
|
+
project: viewedProject || undefined,
|
|
357
|
+
instance: instanceParam || undefined,
|
|
358
|
+
maxBytes: deps.attachBufferMaxBytes,
|
|
359
|
+
// Overflow = the cold-load window's increments are dropped, i.e. the original
|
|
360
|
+
// lost-render bug resurfaces for THIS connection. reportSwallowed is gated behind
|
|
361
|
+
// CCV_DEBUG, so it is invisible in production — log unconditionally, or the most
|
|
362
|
+
// likely residual failure of this fix would be undiagnosable.
|
|
363
|
+
onOverflow: (bytes) => console.warn(`[events] attach buffer overflowed at ${bytes} bytes; this connection may miss live updates until reconnect`),
|
|
364
|
+
});
|
|
365
|
+
deps.clients.push(attachBufferClient);
|
|
366
|
+
}
|
|
322
367
|
const resolveColdSource = () => {
|
|
323
368
|
if (sidParam) {
|
|
324
369
|
const project = projectParam || _projectName;
|
|
@@ -525,6 +570,10 @@ export async function serveEventStream(sink, params, deps) {
|
|
|
525
570
|
limit: useLimit ? effectiveLimit : 0,
|
|
526
571
|
since: useIncremental ? sinceParam : null,
|
|
527
572
|
});
|
|
573
|
+
// Conv coverage for the attach buffer flush: replayed v3_conv frames whose
|
|
574
|
+
// seq the cold bundle already carried are skipped (client conv replay is
|
|
575
|
+
// not idempotent). On a read error this stays null → flush fails open.
|
|
576
|
+
coldConvCoverage = (native && native.convSeqs) || null;
|
|
528
577
|
// Build every payload BEFORE load_start so it can carry the exact byte
|
|
529
578
|
// total — the client renders a real received/total progress (the legacy
|
|
530
579
|
// wire's per-entry count-up has no per-frame granularity here).
|
|
@@ -637,7 +686,21 @@ export async function serveEventStream(sink, params, deps) {
|
|
|
637
686
|
if (instanceParam) registrable._ccvViewInstance = instanceParam;
|
|
638
687
|
// Push-guard: a client whose close fired mid-cold-load (dispose already ran via the
|
|
639
688
|
// entry-registered onClose hook) must NOT be re-added to the broadcast list.
|
|
640
|
-
|
|
689
|
+
//
|
|
690
|
+
// Attach buffer flush + swap: replay every live delta queued during the cold
|
|
691
|
+
// load (in emission order, conv-covered frames skipped), then swap the proxy
|
|
692
|
+
// for the real registrable. This whole block is synchronous — no broadcast can
|
|
693
|
+
// interleave between the replay and the swap, so nothing is double-sent or
|
|
694
|
+
// lost. After this point the connection receives live frames directly.
|
|
695
|
+
if (!closed) {
|
|
696
|
+
flushAttachBuffer(sink, attachBufferClient, coldConvCoverage);
|
|
697
|
+
if (attachBufferClient) {
|
|
698
|
+
const bi = deps.clients.indexOf(attachBufferClient);
|
|
699
|
+
if (bi !== -1) deps.clients.splice(bi, 1);
|
|
700
|
+
}
|
|
701
|
+
deps.clients.push(registrable);
|
|
702
|
+
}
|
|
703
|
+
attachBufferClient = null;
|
|
641
704
|
|
|
642
705
|
// 任务清单快照补推:内存态不在冷加载数据里,页面刷新/重连后横条要等下一次
|
|
643
706
|
// 任务事件才恢复 —— 连接建立即推一份当前快照(空列表也推,清掉客户端残留)。
|
|
@@ -548,6 +548,29 @@ function fileContentPost(req, res, parsedUrl, isLocal) {
|
|
|
548
548
|
// encoding. Normalize only this mutation's containment comparisons.
|
|
549
549
|
let writeRoot;
|
|
550
550
|
const isInsideWriteRoot = (path) => isPathContained(path, writeRoot);
|
|
551
|
+
// isPathContained realpaths its target, so it returns false for a not-yet-existing
|
|
552
|
+
// file — which wrongly 403s every new-file save in target mode. For a missing leaf,
|
|
553
|
+
// realpath the nearest existing ancestor, re-append the descent, then prefix-compare
|
|
554
|
+
// against the (already-real) write root; an existing path keeps the strict realpath.
|
|
555
|
+
const isInsideWriteRootAllowNew = (path) => {
|
|
556
|
+
if (isInsideWriteRoot(path)) return true;
|
|
557
|
+
if (existsSync(path)) return false; // exists but resolves outside → strict deny
|
|
558
|
+
const root = writeRoot.endsWith(sep) ? writeRoot : writeRoot + sep;
|
|
559
|
+
let cursor = resolve(path, '..');
|
|
560
|
+
const descent = [basename(path)];
|
|
561
|
+
for (let depth = 0; depth < 32; depth++) {
|
|
562
|
+
if (existsSync(cursor)) {
|
|
563
|
+
let rebuilt;
|
|
564
|
+
try { rebuilt = join(realpathSync(cursor), ...descent); } catch { return false; }
|
|
565
|
+
return rebuilt === writeRoot || rebuilt.startsWith(root);
|
|
566
|
+
}
|
|
567
|
+
const parent = resolve(cursor, '..');
|
|
568
|
+
if (parent === cursor) return false;
|
|
569
|
+
descent.unshift(basename(cursor));
|
|
570
|
+
cursor = parent;
|
|
571
|
+
}
|
|
572
|
+
return false;
|
|
573
|
+
};
|
|
551
574
|
const MAX_BODY = 5 * 1024 * 1024; // 5MB,与 GET 路由限制对齐
|
|
552
575
|
let body = '';
|
|
553
576
|
let overflow = false;
|
|
@@ -685,7 +708,7 @@ function fileContentPost(req, res, parsedUrl, isLocal) {
|
|
|
685
708
|
// exists OR is being created — otherwise a crafted relative path could write
|
|
686
709
|
// outside the navigation root (isWriteAllowed only bounds by the allowlist).
|
|
687
710
|
// Project mode keeps the legacy existing-file-only check.
|
|
688
|
-
if (!editorSession && targetNavRoot && !
|
|
711
|
+
if (!editorSession && targetNavRoot && !isInsideWriteRootAllowNew(targetReal)) {
|
|
689
712
|
res.writeHead(403, { 'Content-Type': 'application/json' });
|
|
690
713
|
res.end(JSON.stringify({ error: 'Forbidden', reason: 'outside-view-root' }));
|
|
691
714
|
return;
|