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.
@@ -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;
@@ -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
  // 任务事件才恢复 —— 连接建立即推一份当前快照(空列表也推,清掉客户端残留)。
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
- _setTurnActive(false, null, qScope);
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
- function _setTurnActive(active, sessionId, scope = null) {
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; },