cc-viewer 1.9.18 → 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 +34 -0
- package/dist/assets/{App-COyZwmVt.js → App-5GFhfhAM.js} +2 -2
- package/dist/assets/{MdxEditorPanel-BsHDgZCY.js → MdxEditorPanel-C71H-8ZP.js} +1 -1
- package/dist/assets/{Mobile-B1H_PrNl.js → Mobile-CAqle3TP.js} +1 -1
- package/dist/assets/{ProxyStatsModal-CSQNNwpU.js → ProxyStatsModal-B0ySgEsq.js} +1 -1
- package/dist/assets/{index-CPn0gvr6.js → index-C4_-ImaE.js} +2 -2
- package/dist/assets/{seqResourceLoaders-DZA-JHK0.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 +2 -0
- package/server/lib/plugin-runtime.js +12 -2
- package/server/lib/sdk-manager.js +301 -58
- package/server/lib/v2/live-feed.js +108 -7
- package/server/lib/v2/meta-rows.js +35 -4
- package/server/routes/events.js +64 -1
- package/server/server.js +80 -3
- package/dist/assets/seqResourceLoaders-Cm1JnNsu.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/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
|
// 任务事件才恢复 —— 连接建立即推一份当前快照(空列表也推,清掉客户端残留)。
|
package/server/server.js
CHANGED
|
@@ -3005,7 +3005,9 @@ async function setupTerminalWebSocket(httpServer) {
|
|
|
3005
3005
|
{ project: (typeof msg.project === 'string' && msg.project) || null, instance: (typeof msg.instanceKey === 'string' && msg.instanceKey) || null },
|
|
3006
3006
|
_projectName || '',
|
|
3007
3007
|
);
|
|
3008
|
-
|
|
3008
|
+
// Web Stop = user interrupt: the loop ends NOW (no Stop hook fires on
|
|
3009
|
+
// interrupt), so emit the immediate agent_loop_end alongside the clear.
|
|
3010
|
+
_setTurnActive(false, null, qScope, 'interrupted');
|
|
3009
3011
|
}
|
|
3010
3012
|
} else if (msg.type === 'image-remove-notify' || msg.type === 'image-upload-notify') {
|
|
3011
3013
|
// Security: only allow paths within upload directories, reject traversal
|
|
@@ -3163,6 +3165,7 @@ let _lastSdkActive = false;
|
|
|
3163
3165
|
// Per-scope CLI streaming edge state ('' = bound — the legacy single flag).
|
|
3164
3166
|
const _lastCliActiveByScope = new Map();
|
|
3165
3167
|
let _onTurnEndBroadcastForTests = null;
|
|
3168
|
+
let _onAgentLoopBroadcastForTests = null;
|
|
3166
3169
|
|
|
3167
3170
|
function _normalizeKey(sessionId) {
|
|
3168
3171
|
return (typeof sessionId === 'string' && sessionId) ? sessionId : null;
|
|
@@ -3230,6 +3233,14 @@ function _emitTurnEnd(sessionId, ts, transcriptPath = null, scope = null) {
|
|
|
3230
3233
|
const sid = _normalizeKey(sessionId);
|
|
3231
3234
|
const t = ts || Date.now();
|
|
3232
3235
|
const sc = scope || { project: '', instance: null };
|
|
3236
|
+
// PTY loop end rides the debounced boundary (the Stop hook is query()-level, so an
|
|
3237
|
+
// immediate frame would ring mid-loop on user-role injections). SDK mode already
|
|
3238
|
+
// emitted its authoritative per-result end — emitting here too would double-fire.
|
|
3239
|
+
// Emitted BEFORE turn_end so the client can suppress the legacy voice when the new
|
|
3240
|
+
// agentLoopEnd cue actually played (same boundary ts on both frames).
|
|
3241
|
+
if (!isSdkMode) {
|
|
3242
|
+
_emitAgentLoopBoundary('end', { sessionId: sid, ts: t, scope: sc, mode: 'pty', reason: 'stop-hook', transcriptPath });
|
|
3243
|
+
}
|
|
3233
3244
|
try {
|
|
3234
3245
|
if (clients.length > 0 && sendEventToClients) {
|
|
3235
3246
|
const target = sc.project
|
|
@@ -3253,6 +3264,61 @@ function _emitTurnEnd(sessionId, ts, transcriptPath = null, scope = null) {
|
|
|
3253
3264
|
}
|
|
3254
3265
|
}
|
|
3255
3266
|
|
|
3267
|
+
// ── Agent-loop boundary frames (agent_loop_start / agent_loop_end) ─────────────
|
|
3268
|
+
// Authoritative per-turn loop lifecycle, uniform across modes:
|
|
3269
|
+
// SDK: emitted by sdk-manager at push-accept (start) and the turn's real result /
|
|
3270
|
+
// settlement (end) — immediate, per-turn correlated (turnId + turnStartedAt),
|
|
3271
|
+
// bypassing the turn_end debounce entirely (task-notification pseudo results
|
|
3272
|
+
// are filtered upstream and never reach here).
|
|
3273
|
+
// PTY: start on the UserPromptSubmit rising edge in _setTurnActive; end from
|
|
3274
|
+
// _emitTurnEnd (post-debounce, 'stop-hook') or the web Stop clear
|
|
3275
|
+
// ('interrupted'). PTY's end stays debounced on purpose — see the
|
|
3276
|
+
// SUNSET-MARKER block above.
|
|
3277
|
+
// Every emission also fires the matching plugin parallel hook (onAgentLoopStart /
|
|
3278
|
+
// onAgentLoopEnd) so plugins observe the same lifecycle as SSE clients.
|
|
3279
|
+
function _emitAgentLoopBoundary(phase, { sessionId = null, ts = Date.now(), scope = null, mode, turnId = null, turnStartedAt = null, reason = null, transcriptPath = null } = {}) {
|
|
3280
|
+
if (_isStopping) return;
|
|
3281
|
+
const sc = scope || { project: '', instance: null };
|
|
3282
|
+
const payload = {
|
|
3283
|
+
sessionId: _normalizeKey(sessionId), ts,
|
|
3284
|
+
project: sc.project || (_projectName || ''), instance: sc.instance || null,
|
|
3285
|
+
mode,
|
|
3286
|
+
turnId: turnId || null, turnStartedAt: turnStartedAt || null,
|
|
3287
|
+
...(reason ? { reason } : {}),
|
|
3288
|
+
...(transcriptPath ? { transcriptPath } : {}),
|
|
3289
|
+
};
|
|
3290
|
+
if (clients.length > 0 && sendEventToClients) {
|
|
3291
|
+
try {
|
|
3292
|
+
const target = sc.project
|
|
3293
|
+
? filterClientsByScope(clients, sc.project, sc.instance, _projectName || '')
|
|
3294
|
+
: _filterClientsByViewProject(clients, _projectName || '');
|
|
3295
|
+
sendEventToClients(target, phase === 'start' ? 'agent_loop_start' : 'agent_loop_end', payload);
|
|
3296
|
+
} catch (err) { reportSwallowed('agent-loop.broadcast', err); }
|
|
3297
|
+
}
|
|
3298
|
+
// Fire-and-forget: a plugin failure must never affect the SSE/turn pipeline.
|
|
3299
|
+
runParallelHook(phase === 'start' ? 'onAgentLoopStart' : 'onAgentLoopEnd', payload).catch(() => {});
|
|
3300
|
+
if (typeof _onAgentLoopBroadcastForTests === 'function') {
|
|
3301
|
+
try { _onAgentLoopBroadcastForTests({ phase, payload }); }
|
|
3302
|
+
catch (e) { if (process.env.NODE_ENV === 'test') throw e; /* never let a test stub pollute prod */ }
|
|
3303
|
+
}
|
|
3304
|
+
}
|
|
3305
|
+
|
|
3306
|
+
// SDK-mode entries (cli.js wires sdk-manager's per-turn callbacks here). The cwd
|
|
3307
|
+
// routes the frame to that workspace's scope, mirroring broadcastTurnEnd; instanceKey
|
|
3308
|
+
// narrows same-basename twins.
|
|
3309
|
+
export function broadcastAgentLoopStart(p = {}) {
|
|
3310
|
+
const scope = p.cwd
|
|
3311
|
+
? normalizeProducerScope({ project: projectKeyForCwd(p.cwd) || '', instance: p.instanceKey || null }, _projectName || '')
|
|
3312
|
+
: null;
|
|
3313
|
+
_emitAgentLoopBoundary('start', { ...p, scope, mode: 'sdk' });
|
|
3314
|
+
}
|
|
3315
|
+
export function broadcastAgentLoopEnd(p = {}) {
|
|
3316
|
+
const scope = p.cwd
|
|
3317
|
+
? normalizeProducerScope({ project: projectKeyForCwd(p.cwd) || '', instance: p.instanceKey || null }, _projectName || '')
|
|
3318
|
+
: null;
|
|
3319
|
+
_emitAgentLoopBoundary('end', { ...p, scope, mode: 'sdk' });
|
|
3320
|
+
}
|
|
3321
|
+
|
|
3256
3322
|
function _scheduleTurnEndBroadcast(sessionId, ts, transcriptPath = null, scope = null) {
|
|
3257
3323
|
if (_isStopping) return;
|
|
3258
3324
|
const sid = _normalizeKey(sessionId);
|
|
@@ -3435,7 +3501,10 @@ function _broadcastTurnActive(active, sessionId, scope = null) {
|
|
|
3435
3501
|
// turn-end entry points and would otherwise emit duplicate frames). The heartbeat
|
|
3436
3502
|
// is armed/disarmed here so it tracks the flag exactly. Scope defaults to bound
|
|
3437
3503
|
// ('') — the legacy single-workspace behavior is byte-identical.
|
|
3438
|
-
|
|
3504
|
+
// `source` distinguishes clear paths: only 'interrupted' (web Stop / queue-suppress)
|
|
3505
|
+
// emits an immediate agent_loop_end — the ordinary Stop-hook clear defers to the
|
|
3506
|
+
// debounced end in _emitTurnEnd so user-role injection loops don't ring mid-loop.
|
|
3507
|
+
function _setTurnActive(active, sessionId, scope = null, source = 'hook') {
|
|
3439
3508
|
const sc = scope || { project: '', instance: null };
|
|
3440
3509
|
const key = scopeKeyOf(sc.project, sc.instance);
|
|
3441
3510
|
const bucket = turnStateFor(key);
|
|
@@ -3446,6 +3515,11 @@ function _setTurnActive(active, sessionId, scope = null) {
|
|
|
3446
3515
|
if (next) _turnSessionIdByScope.set(key, sessionId || null);
|
|
3447
3516
|
if (activeTurnScopes().length > 0) _armTurnActiveHeartbeat(); else _clearTurnActiveHeartbeat();
|
|
3448
3517
|
_broadcastTurnActive(next, sessionId, sc);
|
|
3518
|
+
if (next) {
|
|
3519
|
+
_emitAgentLoopBoundary('start', { sessionId, ts: Date.now(), scope: sc, mode: 'pty' });
|
|
3520
|
+
} else if (source === 'interrupted') {
|
|
3521
|
+
_emitAgentLoopBoundary('end', { sessionId, ts: Date.now(), scope: sc, mode: 'pty', reason: 'interrupted' });
|
|
3522
|
+
}
|
|
3449
3523
|
}
|
|
3450
3524
|
|
|
3451
3525
|
// 统一的 streaming-state 观察入口。production polling 和 SDK push 都调它,测试也走它,
|
|
@@ -3480,19 +3554,21 @@ export const __testing = (process.env.NODE_ENV === 'test') ? {
|
|
|
3480
3554
|
_lastCliActiveByScope.clear();
|
|
3481
3555
|
_stoppingPromise = null;
|
|
3482
3556
|
_onTurnEndBroadcastForTests = null;
|
|
3557
|
+
_onAgentLoopBroadcastForTests = null;
|
|
3483
3558
|
setTurnActive(false);
|
|
3484
3559
|
_clearTurnActiveHeartbeat();
|
|
3485
3560
|
_turnSessionIdByScope.clear();
|
|
3486
3561
|
clearScopeState();
|
|
3487
3562
|
},
|
|
3488
3563
|
onBroadcast(fn) { _onTurnEndBroadcastForTests = fn; },
|
|
3564
|
+
onAgentLoopBroadcast(fn) { _onAgentLoopBroadcastForTests = fn; },
|
|
3489
3565
|
getPendingKeys() { return [..._pendingTurnEndTimers.keys()]; },
|
|
3490
3566
|
setIsStopping(v) { _isStopping = !!v; },
|
|
3491
3567
|
observeStreamingTick(activeNow, mode = 'cli', scopeKey = BOUND_SCOPE) { return _observeStreamingTick(activeNow, mode, scopeKey); },
|
|
3492
3568
|
scheduleTurnEnd(sessionId, ts, scope = null) { _scheduleTurnEndBroadcast(sessionId, ts, null, scope); },
|
|
3493
3569
|
getDebounceMs() { return TURN_END_DEBOUNCE_MS; },
|
|
3494
3570
|
getPtyTurnActive(scopeKey = BOUND_SCOPE) { return turnStateFor(scopeKey).active; },
|
|
3495
|
-
setPtyTurnActive(v, sessionId = null, scope = null) { _setTurnActive(v, sessionId, scope); },
|
|
3571
|
+
setPtyTurnActive(v, sessionId = null, scope = null, source = 'hook') { _setTurnActive(v, sessionId, scope, source); },
|
|
3496
3572
|
// Routes the payload through the SAME predicate the deps.onTaskEvent closure
|
|
3497
3573
|
// uses, so tests exercise the production gate rather than a mirror of it.
|
|
3498
3574
|
notifyTaskEvent(payload) {
|
|
@@ -3512,6 +3588,7 @@ export const __testing = (process.env.NODE_ENV === 'test') ? {
|
|
|
3512
3588
|
} : Object.freeze({
|
|
3513
3589
|
reset() {},
|
|
3514
3590
|
onBroadcast() {},
|
|
3591
|
+
onAgentLoopBroadcast() {},
|
|
3515
3592
|
getPendingKeys() { return []; },
|
|
3516
3593
|
setIsStopping() {},
|
|
3517
3594
|
observeStreamingTick() { return false; },
|