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.
@@ -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, { suppressExisting: this._seenDirs.has(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
- this._readCursor(cur); // seed (suppressed history or brand-new content)
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, history suppressed (it predates this feed).
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._attach(dir, { suppressExisting: true });
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
- * pre-serialized JSON payloads for `v3_conv` / `v3_resp` SSE frames
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
- pipeline(nodeStream, res, (err) => {
396
- if (err && process.env.CCV_DEBUG) {
397
- console.error('[CC-Viewer Proxy] Stream pipeline error:', err.message);
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();
@@ -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
- if (!closed) deps.clients.push(registrable);
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 && !isInsideWriteRoot(targetReal)) {
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;