cc-viewer 1.9.4 → 1.9.5

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.
@@ -1,4 +1,5 @@
1
1
  import { resolveNativePath, LOG_DIR } from '../findcc.js';
2
+ import { normalizeSessionCwd } from './lib/v2/layout.js';
2
3
  import { fileURLToPath } from 'node:url';
3
4
  import { join, dirname, sep } from 'node:path';
4
5
  import { chmodSync, statSync } from 'node:fs';
@@ -1215,6 +1216,58 @@ export function setPtySessionIdForInstance(instanceKey, sessionId) {
1215
1216
  return true;
1216
1217
  }
1217
1218
 
1219
+ /**
1220
+ * Resolve a claude session uuid to its owning PTY record's scope identity, for
1221
+ * producer-side event routing (2026-10 per-workspace SSE channels). Returns
1222
+ * null for a dead record: sidToKey survives killPtyFor (only respawn/overwrite
1223
+ * /reap clear it, see setPtySessionId), so without the liveness gate a just-
1224
+ * closed workspace's turn-end/task frames would resolve to a corpse scope.
1225
+ * @param {string} sessionId
1226
+ * @returns {{instanceKey: string, cwd: string|null, project: string}|null}
1227
+ */
1228
+ export function resolvePtyForSession(sessionId) {
1229
+ if (typeof sessionId !== 'string' || !sessionId) return null;
1230
+ const key = sidToKey.get(sessionId);
1231
+ if (!key) return null;
1232
+ const rec = ptys.get(key);
1233
+ if (!rec || !rec.ptyProcess) return null;
1234
+ const cwd = (typeof rec.cwd === 'string' && rec.cwd) || null;
1235
+ return { instanceKey: key, cwd, project: cwd ? projectKeyForCwd(cwd) : '' };
1236
+ }
1237
+
1238
+ /**
1239
+ * Resolve a cwd to its SINGLE live PTY instanceKey, for hook-scope resolution
1240
+ * (2026-10 per-workspace SSE channels). A hook payload that only carries cwd
1241
+ * (no sessionId hit) would otherwise degrade to project-level scope — and
1242
+ * `filterClientsByScope` treats instance=null as "every viewer of the project",
1243
+ * over-delivering the frame to a same-cwd twin. Upgrade to the exact instance
1244
+ * ONLY when exactly one live record owns this cwd; with two live twins there
1245
+ * is no honest pick, so the caller keeps project-level (today's behavior).
1246
+ * Matches on the record's persistent `cwd` (never realpath'd — both sides stay
1247
+ * in the same path space), after a lexical normalize so a trailing slash or
1248
+ * `/tmp` vs `/private/tmp` alias from the hook's stdin can't split the match.
1249
+ * @param {string} cwd
1250
+ * @returns {string|null} the sole live instanceKey, or null (none / ambiguous)
1251
+ */
1252
+ export function resolveInstanceForCwd(cwd) {
1253
+ if (typeof cwd !== 'string' || !cwd) return null;
1254
+ let norm;
1255
+ try { norm = normalizeSessionCwd(cwd); } catch { norm = cwd; }
1256
+ if (!norm) return null;
1257
+ let found = null;
1258
+ for (const [k, rec] of ptys) {
1259
+ if (!rec || !rec.ptyProcess) continue; // LIVE only — a dead record must not claim the scope
1260
+ const recCwd = rec.cwd || null;
1261
+ if (!recCwd) continue;
1262
+ let recNorm;
1263
+ try { recNorm = normalizeSessionCwd(recCwd); } catch { recNorm = recCwd; }
1264
+ if (recNorm !== norm) continue;
1265
+ if (found !== null) return null; // a second live twin — ambiguous, keep project-level
1266
+ found = k;
1267
+ }
1268
+ return found;
1269
+ }
1270
+
1218
1271
  /**
1219
1272
  * Resolve the PTY-map key for a chat send anchor. Order:
1220
1273
  * 1. project + sessionId TOGETHER: sessionId is trusted only when the record
@@ -2,7 +2,7 @@
2
2
  import { loadAskStore, consumeIfFinal as askStoreConsumeIfFinal, markCancelled as askStoreMarkCancelled } from '../lib/ask/ask-store.js';
3
3
  import { runWaterfallHook, runParallelHook } from '../lib/plugin-loader.js';
4
4
  import { sendEventToClients } from '../lib/log-watcher.js';
5
- import { filterClientsByViewProject } from '../lib/v2/view-router.js';
5
+ import { filterClientsByScope } from '../lib/v2/view-router.js';
6
6
  import { _projectName } from '../interceptor.js';
7
7
 
8
8
  function pendingAsks(req, res, parsedUrl, isLocal, deps) {
@@ -517,12 +517,16 @@ function streamChunk(req, res, parsedUrl, isLocal, deps) {
517
517
  // Multi-project (2026-10): stamp the SOURCE project so the broadcast can
518
518
  // be routed only to clients VIEWING that project — an unfiltered
519
519
  // broadcast let project A's typewriter render inside project B's view
520
- // (the SSE cross-project bleed). `entry._resumeProject` is the emitting
521
- // claude's own project (self-reported via x-ccv-project-dir); absent ⇒
522
- // the bound project (legacy no-header producer).
523
- const sourceProject = (typeof entry._resumeProject === 'string' && entry._resumeProject)
524
- ? entry._resumeProject
525
- : (_projectName || '');
520
+ // (the SSE cross-project bleed). The interceptor now stamps the chunk
521
+ // with the emitting claude's self-reported project/instance (x-ccv-
522
+ // project-dir / x-ccv-instance); `entry._resumeProject` is the fallback
523
+ // for older producers, and absent everything ⇒ the bound project.
524
+ const sourceProject = (typeof entry.project === 'string' && entry.project)
525
+ ? entry.project
526
+ : (typeof entry._resumeProject === 'string' && entry._resumeProject)
527
+ ? entry._resumeProject
528
+ : (_projectName || '');
529
+ const sourceInstance = (typeof entry.instance === 'string' && entry.instance) ? entry.instance : null;
526
530
  const _streamChunkPayload = {
527
531
  timestamp: entry.timestamp,
528
532
  url: entry.url,
@@ -533,9 +537,10 @@ function streamChunk(req, res, parsedUrl, isLocal, deps) {
533
537
  // ignore it.
534
538
  sessionId: typeof entry.sessionId === 'string' ? entry.sessionId : null,
535
539
  project: sourceProject,
540
+ instance: sourceInstance,
536
541
  };
537
542
  sendEventToClients(
538
- filterClientsByViewProject(deps.clients, sourceProject, _projectName || ''),
543
+ filterClientsByScope(deps.clients, sourceProject, sourceInstance, _projectName || ''),
539
544
  'stream-progress',
540
545
  _streamChunkPayload,
541
546
  );
@@ -7,6 +7,7 @@ import { streamRawEntriesAsync } from '../lib/log-stream.js';
7
7
  import { migrationStatus } from '../lib/v2/migrate-prompt.js';
8
8
  import { reportSwallowed } from '@ccv/core/error-report';
9
9
  import { sseHead, sseWrite, needsDrain, wireEnd, awaitWireDrain, isWireV3Enabled } from '../lib/wire-compress.js';
10
+ import { scopeKeyForProducer } from '../lib/scope-router.js';
10
11
  import { readV2ColdBundle } from '../lib/v2/meta-rows.js';
11
12
  import { readV2SingleEntry } from '../lib/v2/adapter.js';
12
13
  import { resolveSessionDirName, latestMainSessionDir } from '../lib/v2/session-select.js';
@@ -49,11 +50,17 @@ function turnEndNotify(req, res, parsedUrl, isLocal, deps) {
49
50
  res.end(JSON.stringify({ error: 'malformed JSON body' }));
50
51
  return;
51
52
  }
52
- deps.scheduleTurnEndBroadcast(payload.sessionId || null, payload.ts || Date.now(), payload.transcriptPath || null);
53
+ // Per-workspace: resolve the emitting claude's scope (sid→PTY first, cwd
54
+ // fallback, bound last) so the turn_end frame and the immediate turn-active
55
+ // clear land on exactly one workspace's viewers.
56
+ const scope = typeof deps.resolveScopeForHookPayload === 'function'
57
+ ? deps.resolveScopeForHookPayload(payload)
58
+ : { project: '', instance: null };
59
+ deps.scheduleTurnEndBroadcast(payload.sessionId || null, payload.ts || Date.now(), payload.transcriptPath || null, scope);
53
60
  // Turn-level loading clears on the RAW Stop POST arrival — the debounced
54
61
  // `turn_end` SSE frame (voice-pack semantics) would keep the spinner up for
55
62
  // the whole debounce window. Idempotent on the server side.
56
- try { deps.onTurnActiveClear?.(); }
63
+ try { deps.onTurnActiveClear?.(scope); }
57
64
  catch (err) { reportSwallowed('turn-end-notify.turn-active-clear', err); }
58
65
  res.writeHead(200, { 'Content-Type': 'application/json' });
59
66
  res.end(JSON.stringify({ ok: true }));
@@ -151,6 +158,28 @@ function taskEventNotify(req, res, parsedUrl, isLocal, deps) {
151
158
  });
152
159
  }
153
160
 
161
+ /**
162
+ * Parse the /events query into the canonical params object — the SINGLE parser shared by
163
+ * both transports (SSE: from the request URL; WS mux: from the `open` frame's raw query
164
+ * string fed through `new URL('?'+qs, …)`), so the two can never drift. Keeping `?limit`
165
+ * absent-vs-0 distinct (absent ⇒ DEFAULT_EVENTS_LIMIT, explicit 0 ⇒ "all") is load-bearing.
166
+ */
167
+ export function paramsFromParsedUrl(parsedUrl) {
168
+ const g = (k) => parsedUrl.searchParams.get(k);
169
+ const sidRaw = g('sid');
170
+ const instanceRaw = g('instance');
171
+ const limitRaw = g('limit');
172
+ return {
173
+ sid: (typeof sidRaw === 'string' && /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(sidRaw)) ? sidRaw : null,
174
+ project: g('project'),
175
+ instance: (typeof instanceRaw === 'string' && /^ccv-[0-9a-f]+$/.test(instanceRaw)) ? instanceRaw : null,
176
+ since: g('since'),
177
+ cc: parseInt(g('cc'), 10) || 0,
178
+ limitRaw, // keep the raw string so "absent" (null) ≠ "explicit 0"
179
+ limitGiven: limitRaw !== null,
180
+ };
181
+ }
182
+
154
183
  // SSE endpoint
155
184
  // 构造「有新版」徽标的 SSE 帧(pending = {version, source})。pending 为空返回 null。
156
185
  // 抽成纯函数便于单测帧格式;events() 在新连接上调用它补推,使版本徽标跨刷新持续显示。
@@ -160,15 +189,50 @@ export function sseUpdateBadgeFrame(pending) {
160
189
  : null;
161
190
  }
162
191
 
163
- async function events(req, res, parsedUrl, isLocal, deps) {
164
- // Negotiated Content-Encoding (br|identity). From here on, every byte of
165
- // this response MUST go through sseWrite — a bare res.write would corrupt
166
- // the compressed stream (see server/lib/wire-compress.js).
167
- sseHead(req, res, 200, {
168
- 'Content-Type': 'text/event-stream',
169
- 'Cache-Control': 'no-cache',
170
- 'Connection': 'keep-alive',
171
- });
192
+ /**
193
+ * The sink contract — the transport-agnostic target the serve core writes to. An SSE
194
+ * `res` is adapted with `registrable === res`; a WS-mux logical stream provides its own
195
+ * shim (which IS the registrable). Both produce the identical (name, data) frame sequence
196
+ * by construction, because both run through this one function.
197
+ * sink.write(text) -> bool opaque SSE byte-append (a load_chunk is split across 3 writes)
198
+ * sink.alive() -> bool !destroyed && writable
199
+ * sink.needsDrain() -> bool socket-level backpressure signal
200
+ * sink.awaitDrain() -> Promise resolves on drain / close / error / 30s timeout
201
+ * sink.registrable the object pushed into deps.clients (gets _ccvView* stamps)
202
+ */
203
+
204
+ /**
205
+ * serveEventStream — THE single implementation of one /events stream, behind both the SSE
206
+ * route and the WS-mux stream. Resolves after history + kv/context + the broadcast-list
207
+ * push (so callers awaiting it observe the client registered). Returns { dispose } —
208
+ * dispose is idempotent: clears the per-stream pingTimer and splices the registrable out
209
+ * of deps.clients.
210
+ */
211
+ export async function serveEventStream(sink, params, deps) {
212
+ const registrable = sink.registrable;
213
+ let closed = false;
214
+ let pingTimer = null;
215
+ const dispose = () => {
216
+ if (closed) return;
217
+ closed = true;
218
+ if (pingTimer) { clearInterval(pingTimer); pingTimer = null; }
219
+ const idx = deps.clients.indexOf(registrable);
220
+ if (idx !== -1) deps.clients.splice(idx, 1);
221
+ };
222
+ // Register the close hook at the ENTRY (not after the history stream): a client that
223
+ // disconnects mid-cold-load must still clear the pingTimer and never be pushed — the
224
+ // previous tail-registration leaked the timer and pushed a dead res in that window.
225
+ sink.onClose?.(dispose);
226
+
227
+ const { sidParam, projectParam, instanceParam, sinceParam, ccParam, limitRaw, limitGiven } = {
228
+ sidParam: params.sid,
229
+ projectParam: params.project,
230
+ instanceParam: params.instance,
231
+ sinceParam: params.since,
232
+ ccParam: params.cc,
233
+ limitRaw: params.limitRaw,
234
+ limitGiven: params.limitGiven,
235
+ };
172
236
 
173
237
  // 注意:不要在此处 clients.push(res)!
174
238
  // 必须等 load_end + kv_cache + context_window 全部发送完毕后再加入广播列表,
@@ -177,8 +241,8 @@ async function events(req, res, parsedUrl, isLocal, deps) {
177
241
  // 已处理的 live entry,导致 对话条目"显示→消失→重现"闪烁。
178
242
 
179
243
  // SSE 心跳保活:每 30s 发送 ping 事件,防止连接被 OS/代理/浏览器静默断开
180
- const pingTimer = setInterval(() => {
181
- try { sseWrite(res, 'event: ping\ndata: {}\n\n'); } catch {}
244
+ pingTimer = setInterval(() => {
245
+ try { sink.write('event: ping\ndata: {}\n\n'); } catch {}
182
246
  }, 30000);
183
247
 
184
248
  // server_config: 给前端推一次性的关键运行时常量,让前端 cooldown / debounce 等
@@ -186,15 +250,15 @@ async function events(req, res, parsedUrl, isLocal, deps) {
186
250
  // 见 server.js 顶部 turnEnd debounce 的 SUNSET-MARKER 注释。
187
251
  // write 失败要 warn:之前静默吞会让 env override 漂移到前端硬常量 10s 而无人发觉。
188
252
  try {
189
- sseWrite(res, `event: server_config\ndata: ${JSON.stringify({ turnEndDebounceMs: deps.turnEndDebounceMs, wireV3: !!deps.wireV3, build: deps.serverBuild || '' })}\n\n`);
253
+ sink.write(`event: server_config\ndata: ${JSON.stringify({ turnEndDebounceMs: deps.turnEndDebounceMs, wireV3: !!deps.wireV3, build: deps.serverBuild || '' })}\n\n`);
190
254
  } catch (err) {
191
255
  console.warn(`[server_config] SSE write failed (turnEndDebounceMs=${deps.turnEndDebounceMs}):`, err && err.message);
192
256
  }
193
257
 
194
258
  // 补推「有新版」徽标:复用启动检查缓存的结果,让刷新/新标签页连上即恢复徽标。
195
- // 置于 deps.clients.push(res) 之前 → 新客户端仅得一份,不与一次性广播竞态。
259
+ // 置于 deps.clients.push 之前 → 新客户端仅得一份,不与一次性广播竞态。
196
260
  const updFrame = sseUpdateBadgeFrame(deps.pendingMajorUpdate);
197
- if (updFrame) sseWrite(res, updFrame);
261
+ if (updFrame) sink.write(updFrame);
198
262
 
199
263
  // 1.7.0 迁移引导(P2):「正在被查看的项目」仍有未转换的 v1 日志 → 连接即推 migrate_prompt。
200
264
  // 是否弹窗由客户端决定(「不再提醒」偏好在客户端;continued=true 时无视 dismissed
@@ -203,17 +267,15 @@ async function events(req, res, parsedUrl, isLocal, deps) {
203
267
  // (declared below from ?project=) scopes the check to the project the client is
204
268
  // actually viewing, not always the bound one.
205
269
  try {
206
- const migProject = parsedUrl.searchParams.get('project') || _projectName || '';
270
+ const migProject = projectParam || _projectName || '';
207
271
  const mig = migrationStatus(LOG_DIR, migProject);
208
272
  if (mig.pending) {
209
- sseWrite(res, `event: migrate_prompt\ndata: ${JSON.stringify({ ...mig, continued: isContinuedLaunch() })}\n\n`);
273
+ sink.write(`event: migrate_prompt\ndata: ${JSON.stringify({ ...mig, continued: isContinuedLaunch() })}\n\n`);
210
274
  }
211
275
  } catch (e) { reportSwallowed('sse.migrate_prompt', e); }
212
276
 
213
- // 增量加载参数:移动端带 since/cc/project 请求增量数据
214
- const sinceParam = parsedUrl.searchParams.get('since');
215
- const ccParam = parseInt(parsedUrl.searchParams.get('cc'), 10) || 0;
216
- const projectParam = parsedUrl.searchParams.get('project');
277
+ // 增量加载参数:移动端带 since/cc/project 请求增量数据(sinceParam/ccParam/projectParam
278
+ // 已在头部从 params 解构)。
217
279
  // Multi-project (2026-10): a foreign `?project=` (a parallel-project VIEW)
218
280
  // may ALSO resume incrementally — the view-state cache keeps the departing
219
281
  // project's lastTs and the client returns with `?project=<p>&since=<lastTs>`.
@@ -224,12 +286,12 @@ async function events(req, res, parsedUrl, isLocal, deps) {
224
286
  // so the bound-match condition is dropped (cc>0 + a valid date still guard).
225
287
  const useIncremental = !!(sinceParam && ccParam > 0 && !isNaN(new Date(sinceParam).getTime()));
226
288
 
227
- // 分页参数:
289
+ // 分页参数(limitRaw/limitGiven 已在头部从 params 解构,保留 absent≠0 语义):
228
290
  // - mobile 首次加载传 ?limit=200
229
291
  // - bare desktop 请求(无任何 query 参数)默认套 DEFAULT_EVENTS_LIMIT
230
292
  // - 显式 ?limit=0 表示"我要全量"(保留旧行为入口)
231
- const limitParamRaw = parsedUrl.searchParams.get('limit');
232
- const limitParamGiven = limitParamRaw !== null;
293
+ const limitParamRaw = limitRaw;
294
+ const limitParamGiven = limitGiven;
233
295
  const limitParamNum = parseInt(limitParamRaw, 10);
234
296
  let effectiveLimit = 0;
235
297
  if (!useIncremental) {
@@ -250,18 +312,10 @@ async function events(req, res, parsedUrl, isLocal, deps) {
250
312
  // - `?project=<p>` (no sid): project <p>'s CURRENT session — the newest
251
313
  // readable, non-teammate session with a main turn — so a parallel-project
252
314
  // chip can be VIEWED without respawning or killing its process.
253
- const sidParamRaw = parsedUrl.searchParams.get('sid');
254
- const sidParam = (typeof sidParamRaw === 'string' && /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(sidParamRaw)) ? sidParamRaw : null;
255
- // Multi-instance (2026-10-06): `?instance=<instanceKey>` narrows the view to ONE concurrent
256
- // process of a same-cwd project (which otherwise share a basename session dir pool). It
257
- // scopes the no-sid cold-load pick (each instance resolves to ITS OWN latest session) and is
258
- // stamped on the SSE response so the per-project live feed can route to exactly the clients
259
- // viewing that instance. Validated to the minted `ccv-<hex>` shape.
260
- const instanceParamRaw = parsedUrl.searchParams.get('instance');
261
- const instanceParam = (typeof instanceParamRaw === 'string' && /^ccv-[0-9a-f]+$/.test(instanceParamRaw)) ? instanceParamRaw : null;
315
+ // sidParam / instanceParam 已在头部从 params 解构并校验(sid=uuid 形状、instance=ccv-<hex>)。
262
316
  // `projectParam` (declared above for the incremental guard) doubles as the view
263
317
  // scope. The project this client is VIEWING: the explicit ?project= override,
264
- // else the bound project. Stamped onto the SSE response so per-project feeds can
318
+ // else the bound project. Stamped onto the sink registrable so per-project feeds can
265
319
  // route live entries to exactly the clients viewing that project.
266
320
  const viewedProject = projectParam || (_projectName || '');
267
321
  const resolveColdSource = () => {
@@ -316,7 +370,7 @@ async function events(req, res, parsedUrl, isLocal, deps) {
316
370
  // it attached — tell the client explicitly so it can toast + auto-detach.
317
371
  const sidNotFound = !!(sidParam && !viewColdDir);
318
372
  if (sidNotFound) {
319
- try { sseWrite(res, `event: sid-not-found\ndata: ${JSON.stringify({ sid: sidParam, ts: Date.now() })}\n\n`); } catch { }
373
+ try { sink.write(`event: sid-not-found\ndata: ${JSON.stringify({ sid: sidParam, ts: Date.now() })}\n\n`); } catch { }
320
374
  }
321
375
  // Multi-instance (2026-10-06): an explicit `?instance=` that resolves to NO session dir must
322
376
  // likewise NOT silently fall through to the bound project's current session (the instance
@@ -346,7 +400,7 @@ async function events(req, res, parsedUrl, isLocal, deps) {
346
400
  }
347
401
  }
348
402
  if (instanceNoSession && !instanceAlive) {
349
- try { sseWrite(res, `event: sid-not-found\ndata: ${JSON.stringify({ sid: null, instance: instanceParam, reason: 'instance-no-session', ts: Date.now() })}\n\n`); } catch { }
403
+ try { sink.write(`event: sid-not-found\ndata: ${JSON.stringify({ sid: null, instance: instanceParam, reason: 'instance-no-session', ts: Date.now() })}\n\n`); } catch { }
350
404
  }
351
405
  // When a view-scoped dir was requested AND resolved, it replaces the live-source
352
406
  // cold load; otherwise fall back to the standard single-current-session source.
@@ -398,30 +452,28 @@ async function events(req, res, parsedUrl, isLocal, deps) {
398
452
  const coldLoadResult = v3Cold ? null : await streamRawEntriesAsync(emptyLiveView ? '' : (coldSource || getLiveLogSource({ serveInFlight: !!deps.wireV3 })), async (raw) => {
399
453
  // 直接发送原始 JSON 字符串,不做 parse/reconstruct/stringify
400
454
  // ExitPlanMode V2 空 input 的条目按需补全 plan / planFilePath,其它原样透传
401
- if (res.destroyed || !res.writable) return;
455
+ if (!sink.alive()) return;
402
456
  const out = enrichRawIfNeeded(raw);
403
457
  // SSE data 字段不允许裸换行,去除 pretty-printed JSON 的换行
404
- // 写入路径整体 try-catch 兜底:连接在 res.write 之间被对端 RST/destroy 时不至于
405
- // 把 EPIPE 抛穿 async callback;res.on('close'|'error') 已会做 clients 数组清理。
458
+ // 写入路径整体 try-catch 兜底:连接在 write 之间被对端 RST/destroy 时不至于
459
+ // 把 EPIPE 抛穿 async callback;sink 的 close/error 已会做 clients 数组清理。
406
460
  let drained = true;
407
461
  try {
408
- sseWrite(res, 'event: load_chunk\ndata: [');
409
- const ok = sseWrite(res, out.includes('\n') ? out.replace(/\n/g, '') : out);
410
- sseWrite(res, ']\n\n');
411
- // Two pressure signals: `ok` is the write target's input buffer (the
412
- // ENCODER on compressed paths — its backlog would otherwise be invisible
413
- // because compressed output is 20-80x smaller and rarely fills the
414
- // socket), needsDrain(res) is the socket itself. awaitWireDrain below
415
- // waits on whichever stream actually applies the pressure.
416
- drained = ok && !needsDrain(res);
462
+ sink.write('event: load_chunk\ndata: [');
463
+ const ok = sink.write(out.includes('\n') ? out.replace(/\n/g, '') : out);
464
+ sink.write(']\n\n');
465
+ // Two pressure signals: `ok` is the write target's input buffer (the ENCODER on
466
+ // compressed SSE paths), sink.needsDrain() is the socket itself. sink.awaitDrain()
467
+ // below waits on whichever stream actually applies the pressure.
468
+ drained = ok && !sink.needsDrain();
417
469
  } catch {
418
470
  return;
419
471
  }
420
472
  // 写缓冲满则等 drain(或 close/error/超时任一 fulfill),防止浏览器侧 renderer OOM。
421
- // helper 内部会在 fulfill 时把另外两个监听器从 res 上摘掉,避免 N 次 backpressure
473
+ // helper 内部会在 fulfill 时把另外两个监听器从 sink 上摘掉,避免 N 次 backpressure
422
474
  // 累积出 N 个 stale close/error listener 触发 MaxListenersExceededWarning。
423
475
  if (!drained) {
424
- await awaitWireDrain(res, deps.SSE_BACKPRESSURE_TIMEOUT_MS);
476
+ await sink.awaitDrain();
425
477
  }
426
478
  }, {
427
479
  since: useIncremental ? sinceParam : undefined,
@@ -451,7 +503,7 @@ async function events(req, res, parsedUrl, isLocal, deps) {
451
503
  loadStartData.hasMore = !!hasMore;
452
504
  loadStartData.oldestTs = oldestTs || '';
453
505
  }
454
- sseWrite(res, `event: load_start\ndata: ${JSON.stringify(loadStartData)}\n\n`);
506
+ sink.write(`event: load_start\ndata: ${JSON.stringify(loadStartData)}\n\n`);
455
507
  },
456
508
  });
457
509
 
@@ -485,10 +537,10 @@ async function events(req, res, parsedUrl, isLocal, deps) {
485
537
  loadStartData.hasMore = !!meta.hasMore;
486
538
  loadStartData.oldestTs = meta.oldestTimestamp || '';
487
539
  }
488
- sseWrite(res, `event: load_start\ndata: ${JSON.stringify(loadStartData)}\n\n`);
489
- sseWrite(res, `event: v2_requests\ndata: ${rowsPayload}\n\n`);
490
- for (const payload of native.convPayloads) sseWrite(res, `event: v3_conv\ndata: ${payload}\n\n`);
491
- for (const payload of native.respPayloads) sseWrite(res, `event: v3_resp\ndata: ${payload}\n\n`);
540
+ sink.write(`event: load_start\ndata: ${JSON.stringify(loadStartData)}\n\n`);
541
+ sink.write(`event: v2_requests\ndata: ${rowsPayload}\n\n`);
542
+ for (const payload of native.convPayloads) sink.write(`event: v3_conv\ndata: ${payload}\n\n`);
543
+ for (const payload of native.respPayloads) sink.write(`event: v3_resp\ndata: ${payload}\n\n`);
492
544
  // kv-cache / context_window sources: rebuild the newest completed
493
545
  // mainAgent rows. Depth 3 mirrors the legacy scan-ring fallback (review
494
546
  // P2-e): the newest main may lack usage/cached-content or be a
@@ -501,7 +553,7 @@ async function events(req, res, parsedUrl, isLocal, deps) {
501
553
  } catch (err) { reportSwallowed('sse.v2_requests', err); }
502
554
  }
503
555
 
504
- sseWrite(res, `event: load_end\ndata: {}\n\n`);
556
+ sink.write(`event: load_end\ndata: {}\n\n`);
505
557
 
506
558
  // S10a: v2 sources no longer invoke onScan (the two-pass window never
507
559
  // stringifies out-of-window entries) — the adapter returns the newest-main
@@ -534,17 +586,21 @@ async function events(req, res, parsedUrl, isLocal, deps) {
534
586
 
535
587
  // 发送最新 MainAgent 的 KV-Cache 和 context_window
536
588
  if (latestKvCache) {
537
- sseWrite(res, `event: kv_cache_content\ndata: ${JSON.stringify(latestKvCache)}\n\n`);
589
+ sink.write(`event: kv_cache_content\ndata: ${JSON.stringify(latestKvCache)}\n\n`);
538
590
  }
539
591
  if (latestContextWindow) {
540
- sseWrite(res, `event: context_window\ndata: ${JSON.stringify(latestContextWindow)}\n\n`);
592
+ sink.write(`event: context_window\ndata: ${JSON.stringify(latestContextWindow)}\n\n`);
541
593
  pushedContextWindow = true;
542
594
  }
543
595
  // Fallback: no MainAgent in log (e.g. fresh session after -c), read context-window.json.
544
596
  // Skipped for an empty live view: CONTEXT_WINDOW_FILE is a single GLOBAL file (not
545
597
  // project-scoped), so pushing it here would paint another project's usage onto a
546
598
  // brand-new workspace. The first real main turn pushes a genuine measurement instead.
547
- if (!pushedContextWindow && !emptyLiveView) {
599
+ // Per-workspace: the same global-file constraint applies to ANY non-bound view —
600
+ // a parallel project's viewers must not see the bound project's global fallback
601
+ // either (their live feed pushes their own measurement on the next main turn).
602
+ const viewedIsBound = !viewedProject || viewedProject === (_projectName || '');
603
+ if (!pushedContextWindow && !emptyLiveView && viewedIsBound) {
548
604
  try {
549
605
  const cwRaw = readFileSync(CONTEXT_WINDOW_FILE, 'utf-8');
550
606
  const cwFile = JSON.parse(cwRaw);
@@ -560,7 +616,7 @@ async function events(req, res, parsedUrl, isLocal, deps) {
560
616
  const effectiveSize = adaptContextWindow(contextSize, inputTokens);
561
617
  const usedPct = effectiveSize > 0 ? Math.round((totalTokens / effectiveSize) * 100) : 0;
562
618
  const data = { ...cw, context_window_size: effectiveSize, used_percentage: usedPct, remaining_percentage: 100 - usedPct };
563
- sseWrite(res, `event: context_window\ndata: ${JSON.stringify(data)}\n\n`);
619
+ sink.write(`event: context_window\ndata: ${JSON.stringify(data)}\n\n`);
564
620
  }
565
621
  } catch { }
566
622
  }
@@ -569,45 +625,85 @@ async function events(req, res, parsedUrl, isLocal, deps) {
569
625
  // 这样 watcher 的 sendToClients 不会在 load 阶段向该客户端推送 live entry。
570
626
  // Stamp the viewed project so per-project feeds route live entries to exactly
571
627
  // the clients viewing that project (unset ⇒ the bound project, the default).
572
- if (viewedProject) res._ccvViewProject = viewedProject;
628
+ // Stamped on the REGISTRABLE (the object in deps.clients): for SSE that is the res
629
+ // itself; for a WS-mux stream it is the per-stream shim (never the shared socket).
630
+ if (viewedProject) registrable._ccvViewProject = viewedProject;
573
631
  // Multi-instance: stamp the viewed instance too, so a same-cwd project's live feed can
574
632
  // route each instance's entries to exactly the clients viewing THAT instance (unset ⇒
575
633
  // instance-agnostic, the pre-multi-instance default).
576
- if (instanceParam) res._ccvViewInstance = instanceParam;
577
- deps.clients.push(res);
634
+ if (instanceParam) registrable._ccvViewInstance = instanceParam;
635
+ // Push-guard: a client whose close fired mid-cold-load (dispose already ran via the
636
+ // entry-registered onClose hook) must NOT be re-added to the broadcast list.
637
+ if (!closed) deps.clients.push(registrable);
578
638
 
579
639
  // 任务清单快照补推:内存态不在冷加载数据里,页面刷新/重连后横条要等下一次
580
640
  // 任务事件才恢复 —— 连接建立即推一份当前快照(空列表也推,清掉客户端残留)。
641
+ // Per-workspace: the snapshot comes from the VIEWED scope's bucket, so a
642
+ // parallel view never receives the bound project's checklist (and vice versa).
643
+ // The scope key is built by the SAME normalizer the producers use (bound
644
+ // collapses to ''), so a bound view and its producer always meet in one bucket.
645
+ const _viewScopeKey = scopeKeyForProducer(
646
+ { project: viewedProject || null, instance: instanceParam || null },
647
+ _projectName || '',
648
+ );
581
649
  try {
582
- const snap = deps.getTaskSnapshot?.();
583
- if (snap) sseWrite(res, `event: task_update\ndata: ${JSON.stringify({ sessionId: snap.sessionId, tasks: snap.tasks, ts: Date.now() })}\n\n`);
650
+ const snap = typeof deps.getTaskSnapshot === 'function' ? deps.getTaskSnapshot(_viewScopeKey) : null;
651
+ if (snap) sink.write(`event: task_update\ndata: ${JSON.stringify({ sessionId: snap.sessionId, tasks: snap.tasks, ts: Date.now() })}\n\n`);
584
652
  } catch (err) { reportSwallowed('sse.task_update.snapshot', err); }
585
653
 
586
654
  // Turn-level loading replay: a page refresh mid-turn restores the spinner in the
587
655
  // same connect block (turn-level state is in-memory only, not in the cold load).
588
- // The flag is a bound-project global (same constraint as streamingState), so the
589
- // replay only goes to viewers of the bound project — a chip-switched parallel view
590
- // must not latch the bound project's spinner. NOTE: this makes turn_active MORE
591
- // filtered than streaming_status/turn_end (which broadcast to all); the parallel
592
- // view simply shows no spinner for its own project, matching the pre-multi-PTY
593
- // single-project posture documented at the streaming_status timer.
656
+ // Per-workspace: the replay reads the VIEWED scope's bucket, so a parallel
657
+ // view restores ITS OWN spinner (previously bound-only — the parallel view
658
+ // simply never showed one).
594
659
  try {
595
- const viewedIsBound = !viewedProject || viewedProject === (_projectName || '');
596
- if (viewedIsBound && deps.getPtyTurnActive?.()) {
597
- sseWrite(res, `event: turn_active\ndata: ${JSON.stringify({ active: true, sessionId: null, ts: Date.now() })}\n\n`);
660
+ // 按返回值区分(不是按存在性):getTurnActiveFor 存在但查不到该 scope 的
661
+ // 活跃回合(false/undefined)时,仍回落 legacy 的 bound-only getPtyTurnActive —
662
+ // 否则一个「存在但恒 false」的退化实现会静默消灭整个连接回放。
663
+ const _forScope = typeof deps.getTurnActiveFor === 'function' ? deps.getTurnActiveFor(_viewScopeKey) : undefined;
664
+ const _turnActive = (_forScope !== undefined && _forScope !== null)
665
+ ? _forScope
666
+ : (viewedIsBound && deps.getPtyTurnActive?.());
667
+ if (_turnActive) {
668
+ sink.write(`event: turn_active\ndata: ${JSON.stringify({ active: true, sessionId: null, ts: Date.now() })}\n\n`);
598
669
  }
599
670
  } catch (err) { reportSwallowed('sse.turn_active.replay', err); }
600
671
 
601
- // req.on('close') 在某些异常断连时不一定立即触发;res 端 close/error 兜底保证
602
- // 不会在 clients 数组里留下幽灵 res,防止 sendToClients 后续写入触发慢泄漏。
603
- const removeFromClients = () => {
604
- clearInterval(pingTimer);
605
- const idx = deps.clients.indexOf(res);
606
- if (idx !== -1) deps.clients.splice(idx, 1);
672
+ // Close cleanup lives in the entry-registered dispose (sink.onClose at the top): it clears
673
+ // the pingTimer and splices the registrable out of deps.clients, covering a disconnect at
674
+ // ANY point (mid-cold-load included — the previous tail-only listener leaked that window).
675
+ return { dispose };
676
+ }
677
+
678
+ // /events route handler — a thin SSE-only shell over serveEventStream. It negotiates the
679
+ // wire encoding, adapts the res into the sink contract, and lets the sink's onClose hook
680
+ // register dispose on the transport's req/res close/error. The WS-mux path calls
681
+ // serveEventStream directly with its own shim (whose onClose fires on stream teardown).
682
+ async function events(req, res, parsedUrl, isLocal, deps) {
683
+ // Negotiated Content-Encoding (br|identity). From here on, every byte of this response
684
+ // MUST go through sseWrite — a bare res.write would corrupt the compressed stream.
685
+ sseHead(req, res, 200, {
686
+ 'Content-Type': 'text/event-stream',
687
+ 'Cache-Control': 'no-cache',
688
+ 'Connection': 'keep-alive',
689
+ });
690
+ // onClose is set BEFORE serveEventStream runs so the entry-registered hook captures it:
691
+ // serveEventStream calls sink.onClose(dispose) at entry, and we fan dispose out to the
692
+ // req/res lifecycle. (req close may not fire on some abrupt disconnects; res close/error
693
+ // backstop guarantees no ghost res is left in clients — preventing a slow write leak.)
694
+ const sink = {
695
+ registrable: res,
696
+ write: (s) => sseWrite(res, s),
697
+ alive: () => !res.destroyed && res.writable !== false,
698
+ needsDrain: () => needsDrain(res),
699
+ awaitDrain: () => awaitWireDrain(res, deps.SSE_BACKPRESSURE_TIMEOUT_MS),
700
+ onClose: (dispose) => {
701
+ req.on('close', dispose);
702
+ res.on('close', dispose);
703
+ res.on('error', dispose);
704
+ },
607
705
  };
608
- req.on('close', removeFromClients);
609
- res.on('close', removeFromClients);
610
- res.on('error', removeFromClients);
706
+ await serveEventStream(sink, paramsFromParsedUrl(parsedUrl), deps);
611
707
  }
612
708
 
613
709
  // API endpoint
@@ -262,6 +262,23 @@ function postLiveProcessCloseHandler(req, res, parsedUrl, isLocal, deps) {
262
262
  try {
263
263
  rebound = _maybeRebindAfterClose(r, deps);
264
264
  } catch (err) { reportSwallowed('resume-route.close-rebind', err); }
265
+ // A PTY killed here fires no Stop POST, so its scope's streaming/turn
266
+ // bucket would stay `active:true` forever — the heartbeat/streaming timer
267
+ // keeps re-broadcasting it and the client's 30s staleness escape is
268
+ // perpetually reset by those same frames (the permanent-spinner leak).
269
+ // Reap exactly that scope. The producer buckets it under
270
+ // projectKeyForCwd(cwd) (interceptor scopeKeyForEntry / server
271
+ // _resolveScopeForHookPayload), NOT the instanceKey — so resolve the same
272
+ // project-scoped key here, collapsed to '' when it was the bound project.
273
+ try {
274
+ if (r.cwd && deps.clearScopeRuntimeFor) {
275
+ const { projectKeyForCwd } = await import('../lib/system-prompt-snapshots.js');
276
+ const { normalizeProducerScope, scopeKeyOf } = await import('../lib/scope-router.js');
277
+ const proj = projectKeyForCwd(r.cwd);
278
+ const n = normalizeProducerScope({ project: proj, instance: null }, deps.projectName || '');
279
+ deps.clearScopeRuntimeFor(scopeKeyOf(n.project, n.instance));
280
+ }
281
+ } catch (err) { reportSwallowed('resume-route.close-clear-scope', err); }
265
282
  // Closing a project/instance must also reap its scratch (mini-terminal) shells, or
266
283
  // the viewed-then-switched-away workspace's shells stay alive as orphans until a
267
284
  // full workspacesStop/process exit. The killed record's original spawn cwd keys
@@ -4,9 +4,10 @@
4
4
  // - GET /api/session-pin → { pinnedSessionId }
5
5
  // - POST /api/session-pin {pinnedSessionId: string|null} → persist + SSE-broadcast
6
6
  // `_logDir` is the live per-project dir ('' = no active project → GET null / POST no-op).
7
- import { _logDir } from '../interceptor.js';
7
+ import { _logDir, _projectName } from '../interceptor.js';
8
8
  import { readPin, writePin } from '../lib/session-pin-store.js';
9
9
  import { sendEventToClients } from '../lib/log-watcher.js';
10
+ import { filterClientsByViewProject } from '../lib/v2/view-router.js';
10
11
 
11
12
  function sendJson(res, code, obj) {
12
13
  res.writeHead(code, { 'Content-Type': 'application/json' });
@@ -28,7 +29,16 @@ function postHandler(req, res, parsedUrl, isLocal, deps) {
28
29
  const pinnedSessionId = (typeof raw === 'string' && raw) ? raw : null;
29
30
  writePin(_logDir, pinnedSessionId);
30
31
  // Broadcast to THIS process's SSE clients so every device stays in sync.
31
- sendEventToClients(deps.clients, 'session_pin', { pinnedSessionId });
32
+ // Per-workspace: the pin file is the BOUND project's (`_logDir`), so the
33
+ // frame must reach only bound-project viewers — a parallel view's pin must
34
+ // not be clobbered by it (and vice versa). Payload carries the project so
35
+ // the client can guard in depth.
36
+ const bound = _projectName || '';
37
+ sendEventToClients(
38
+ filterClientsByViewProject(deps.clients, bound, bound),
39
+ 'session_pin',
40
+ { pinnedSessionId, project: bound },
41
+ );
32
42
  sendJson(res, 200, { ok: true, pinnedSessionId });
33
43
  });
34
44
  req.on('error', () => { try { sendJson(res, 400, { error: 'read-error' }); } catch {} });
@@ -33,10 +33,13 @@ function workflowJournal(req, res, parsedUrl, isLocal, deps) {
33
33
  }
34
34
 
35
35
  // 惰性 arm:只监视真有人在看的 session workflows 目录,后续 journal 覆写经 SSE 实时推送。
36
+ // Per-workspace: the server-supplied filter routes the frames to only this
37
+ // project's viewers (absent ⇒ legacy broadcast-to-all).
38
+ const _wfFilter = typeof deps.filterClientsByViewProject === 'function' ? deps.filterClientsByViewProject : null;
36
39
  const wfDir = resolveWorkflowsDir(session, project);
37
40
  if (wfDir && deps && Array.isArray(deps.clients)) {
38
41
  try {
39
- armWorkflowWatch({ workflowsDir: wfDir, sessionId: session, project, clients: deps.clients });
42
+ armWorkflowWatch({ workflowsDir: wfDir, sessionId: session, project, clients: deps.clients, clientFilter: _wfFilter });
40
43
  } catch {}
41
44
  }
42
45
 
@@ -57,7 +60,7 @@ function workflowJournal(req, res, parsedUrl, isLocal, deps) {
57
60
  const live = runDir ? deriveLiveJournal(runDir, runId) : null;
58
61
  if (live) {
59
62
  if (deps && Array.isArray(deps.clients)) {
60
- try { armWorkflowLiveWatch({ runDir, runId, sessionId: session, project, clients: deps.clients }); } catch {}
63
+ try { armWorkflowLiveWatch({ runDir, runId, sessionId: session, project, clients: deps.clients, clientFilter: _wfFilter }); } catch {}
61
64
  }
62
65
  res.writeHead(200, { 'Content-Type': 'application/json' });
63
66
  res.end(JSON.stringify({ ok: true, data: live }));