cc-viewer 1.9.3 → 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.
Files changed (37) hide show
  1. package/README.md +5 -5
  2. package/cli.js +29 -2
  3. package/dist/assets/App-Bj_-wfrB.js +2 -0
  4. package/dist/assets/App-DuDeDOuX.css +1 -0
  5. package/dist/assets/{MdxEditorPanel-Ck8B5a5v.js → MdxEditorPanel-DD2IrRnW.js} +1 -1
  6. package/dist/assets/{Mobile-B8AkKb0z.js → Mobile-BQBQkr2V.js} +1 -1
  7. package/dist/assets/{ProxyStatsModal-DsDVMf-c.js → ProxyStatsModal-8VYl6n39.js} +1 -1
  8. package/dist/assets/index-CloXv0sm.js +2 -0
  9. package/dist/assets/{seqResourceLoaders-BsMFNfvR.css → seqResourceLoaders-Bg9Lz8pH.css} +1 -1
  10. package/dist/assets/seqResourceLoaders-M4XGblXm.js +2 -0
  11. package/dist/index.html +1 -1
  12. package/package.json +1 -1
  13. package/server/interceptor.js +176 -14
  14. package/server/lib/git-diff.js +14 -8
  15. package/server/lib/scope-router.js +68 -0
  16. package/server/lib/sdk-manager.js +1524 -895
  17. package/server/lib/task-state.js +74 -34
  18. package/server/lib/turn-end-bridge.js +6 -1
  19. package/server/lib/v2/live-feed.js +7 -6
  20. package/server/lib/v2/view-router.js +48 -0
  21. package/server/lib/workflow-watcher.js +13 -6
  22. package/server/lib/ws-mux.js +473 -0
  23. package/server/pty-manager.js +53 -0
  24. package/server/routes/ask-perm.js +13 -8
  25. package/server/routes/events.js +190 -74
  26. package/server/routes/resume.js +79 -1
  27. package/server/routes/sdk-context.js +24 -0
  28. package/server/routes/sdk-mcp-status.js +24 -0
  29. package/server/routes/session-pin.js +12 -2
  30. package/server/routes/workflow-journal.js +5 -2
  31. package/server/routes/workspaces.js +31 -2
  32. package/server/scratch-pty-manager.js +127 -46
  33. package/server/server.js +616 -108
  34. package/dist/assets/App-BzXO6ATr.js +0 -2
  35. package/dist/assets/App-DJ4yNvwz.css +0 -1
  36. package/dist/assets/index-Dq5UPr6v.js +0 -2
  37. package/dist/assets/seqResourceLoaders-BsQ6KXpV.js +0 -2
@@ -0,0 +1,473 @@
1
+ /**
2
+ * WebSocket stream mux for the dialogue channel (`/ws/mux`).
3
+ *
4
+ * Carries N logical /events streams over ONE shared WebSocket, so the browser's
5
+ * HTTP/1.1 ~6-connections-per-origin ceiling (which pins SSE to maxChannels=3)
6
+ * no longer bounds how many workspaces stream live concurrently. The SSE path
7
+ * is kept intact as the automatic fallback: when the mux cannot serve (no
8
+ * route, handshake refused, capability absent) the client reopens the SAME
9
+ * scope URL over a native EventSource with its since/cc cursor — no data loss.
10
+ *
11
+ * Frame protocol (text JSON, graphql-ws style envelope):
12
+ * c→s { type:'open', streamId, qs } qs = the RAW ?query string a native
13
+ * EventSource URL would carry (passthrough;
14
+ * we never re-derive scope field-by-field,
15
+ * so `?limit` absence≠0 and sid/instance
16
+ * regexes stay byte-identical to /events)
17
+ * c→s { type:'close', streamId }
18
+ * c→s { type:'pong', ts }
19
+ * s→c { type:'open-ack', streamId, epoch, seq } ack precedes every frame of the stream
20
+ * s→c { type:'frame', streamId, seq, name, data } name:null ⇔ bare `data:`; data is the
21
+ * verbatim JSON string (never re-stringified)
22
+ * s→c { type:'error', streamId, code, message } per-stream; terminates only that stream
23
+ * s→c { type:'ping', ts } 25s app-level keepalive (browsers cannot send
24
+ * WS control-frame pings, so liveness is app-level)
25
+ *
26
+ * Reliability: a stream's lifetime == its socket's lifetime (mirrors SSE: one
27
+ * EventSource ⇔ one connection, reconnect = new connection + since/cc). There is
28
+ * NO server-side frame replay across sockets; resume semantics live entirely in
29
+ * the existing since/cc cursor. seq is per-stream monotonic, epoch is per-process;
30
+ * the client dedups on the per-stream seq alone (streamId is re-minted on every reconnect, so a
31
+ * replayed frame from a prior incarnation can never collide); epoch is diagnostic only.
32
+ *
33
+ * The KEY design move is the sink shim: each logical stream masquerades as an SSE
34
+ * `res` inside the shared `clients` array, so the broadcast hot path
35
+ * (log-watcher's send*ToClients / _safeSseWrite / filterClientsByScope) needs ZERO
36
+ * changes. The shim is a real EventEmitter because `awaitWireDrain`/`_safeSseWrite`
37
+ * use on/once/off('drain'|'close'|'error') on it.
38
+ *
39
+ * Pure logic: no `ws` module dependency (the WebSocketServer class is injected) and no
40
+ * routes/ dependency (serveStream is injected by server.js), so this stays L1 and
41
+ * unit-testable with fakes — same house style as lib/ws-backpressure.js.
42
+ */
43
+ import { randomBytes } from 'node:crypto';
44
+ import { EventEmitter } from 'node:events';
45
+ import { reportSwallowed as defaultReport } from '@ccv/core/error-report';
46
+ import { createBackpressureGate } from './ws-backpressure.js';
47
+
48
+ const STREAM_ID_RE = /^[A-Za-z0-9_-]{1,64}$/;
49
+ const HEARTBEAT_SEND_MS = 25_000; // app-level ping cadence (chisel: proxies kill idle conns)
50
+ const HEARTBEAT_REAP_MS = 60_000; // no inbound frame for this long → terminate the socket
51
+ const MAX_STREAMS_PER_SOCKET = 8;
52
+ // Per-socket queue is a pure MEMORY SAFETY NET (the primary slow-consumer eviction is the
53
+ // per-stream 30s backpressure rule inherited from _safeSseWrite). Sized well above any
54
+ // legitimate burst so it only trips on a wedged socket, never on a fast-producer burst.
55
+ const QUEUE_MAX_BYTES = 8 * 1024 * 1024;
56
+ const HIGH_WATER = 1024 * 1024; // mirrors terminal's hysteresis band
57
+ const LOW_WATER = 256 * 1024;
58
+
59
+ /** Per-stream application error codes (WS close-code range, graphql-ws 44xx semantics). */
60
+ export const MUX_CODES = {
61
+ BAD_REQUEST: 4400, // open frame failed validation
62
+ DUPLICATE_STREAM: 4409, // streamId already live on this socket
63
+ TOO_MANY_STREAMS: 4429, // socket stream cap reached
64
+ SCOPE_UNAVAILABLE: 4503, // this scope cannot be served over mux → client falls back (this stream only)
65
+ };
66
+
67
+ export function encodeFrame(obj) {
68
+ return JSON.stringify(obj);
69
+ }
70
+
71
+ /** Parse one inbound text frame. Returns null on anything that is not a {type:string} object. */
72
+ export function decodeFrame(raw) {
73
+ try {
74
+ const o = JSON.parse(typeof raw === 'string' ? raw : String(raw));
75
+ return (o && typeof o === 'object' && typeof o.type === 'string') ? o : null;
76
+ } catch {
77
+ return null;
78
+ }
79
+ }
80
+
81
+ /**
82
+ * Parse ONE complete SSE frame body (no trailing blank line) into {name, data}.
83
+ * Implements the SSE field rules rather than assuming a single data line: strip one
84
+ * leading space after the colon, join multiple `data:` lines with '\n', drop unknown
85
+ * field lines. `name` is null for a bare `data:` frame. Returns null when there is no
86
+ * data field at all (a frame this codebase never legitimately emits).
87
+ *
88
+ * NOTE: only the two shapes this codebase emits are exercised in production —
89
+ * `data: <json>` and `event: <name>\ndata: <json>` (JSON.stringify escapes newlines,
90
+ * so data is always single-line). The multi-line tolerance is forward defense; an
91
+ * unexpected non-data/event field line is reported as a protocol-drift signal.
92
+ */
93
+ export function parseSseFrame(body, report = defaultReport) {
94
+ const lines = body.split('\n');
95
+ let name = null;
96
+ const dataLines = [];
97
+ for (const line of lines) {
98
+ if (line.startsWith('data:')) {
99
+ dataLines.push(line.startsWith('data: ') ? line.slice(6) : line.slice(5));
100
+ } else if (line.startsWith('event:')) {
101
+ name = line.startsWith('event: ') ? line.slice(7) : line.slice(6);
102
+ } else if (line === '' || line.startsWith(':')) {
103
+ // blank / comment line — ignore
104
+ } else {
105
+ report('ws.mux.parse', new Error('unexpected SSE field line'), { line: line.slice(0, 16) });
106
+ }
107
+ }
108
+ if (dataLines.length === 0) return null;
109
+ return { name, data: dataLines.join('\n') };
110
+ }
111
+
112
+ /**
113
+ * Validate an inbound `open` frame. Returns { ok:true, streamId, qs } or
114
+ * { ok:false, code, message }. qs is passed through verbatim (only shape-checked to be
115
+ * a string without a leading '?'); the scope/since/cc/limit semantics are re-parsed by
116
+ * the SAME paramsFromParsedUrl the SSE route uses, so the two transports never drift.
117
+ */
118
+ export function validateOpenParams(msg) {
119
+ const streamId = msg.streamId;
120
+ if (typeof streamId !== 'string' || !STREAM_ID_RE.test(streamId)) {
121
+ return { ok: false, code: MUX_CODES.BAD_REQUEST, message: 'invalid streamId' };
122
+ }
123
+ const qs = msg.qs;
124
+ if (qs !== null && qs !== undefined && (typeof qs !== 'string' || qs.startsWith('?') || qs.length > 4096)) {
125
+ return { ok: false, code: MUX_CODES.BAD_REQUEST, message: 'invalid qs' };
126
+ }
127
+ return { ok: true, streamId, qs: qs || '' };
128
+ }
129
+
130
+ /**
131
+ * The sink shim: one logical stream presented to the broadcast hot path as an SSE `res`.
132
+ * A REAL EventEmitter because _safeSseWrite / awaitWireDrain attach on/once/off('drain'|
133
+ * 'close'|'error') to it; a hand-rolled once() would TypeError inside awaitWireDrain and
134
+ * (with no unhandledRejection backstop in the dispatcher) kill the process.
135
+ */
136
+ class MuxSink extends EventEmitter {
137
+ constructor(st, streamId) {
138
+ super();
139
+ this.streamId = streamId;
140
+ this._st = st;
141
+ // The registrable IS the shim itself (it is the object pushed into deps.clients, so the
142
+ // per-stream _ccvView* stamps land on it — never on the shared socket).
143
+ this.registrable = this;
144
+ // SSE-res surface the hot path reads (see log-watcher._safeSseWrite / wire-compress):
145
+ this.destroyed = false; // flipped only when THIS stream ends (never by socket congestion)
146
+ this.writable = true;
147
+ this._wireEnc = undefined; // MUST stay falsy: sseWrite/awaitWireDrain branch on it
148
+ this._sseBackpressureSince = 0;
149
+ this._ccvViewProject = undefined; // stamped by the serve core at push time (per-stream)
150
+ this._ccvViewInstance = undefined;
151
+ this.writableNeedDrain = false;
152
+ this._buf = ''; // cross-call SSE byte reassembly buffer
153
+ this._seq = 0; // per-stream monotonic frame sequence
154
+ this._drainWaiters = [];
155
+ // EventEmitter throws on an unlistened 'error'; the shim only ever reports transport
156
+ // death via 'close', but keep a noop so a stray emit('error') can never crash the process.
157
+ this.on('error', () => {});
158
+ // The serve core registers its dispose hook here at entry.
159
+ this.once('close', () => { this._disposed?.(); });
160
+ }
161
+
162
+ /** serve core entry hook: register the dispose callback (idempotent by the core). */
163
+ onClose(fn) { this._disposed = fn; }
164
+
165
+ alive() { return !this.destroyed && !this._st.closed; }
166
+ needsDrain() { return this._st.behind(); }
167
+
168
+ /** Mirrors wire-compress.awaitWireDrain: resolve on drain / close / 30s timeout. The SAME
169
+ * `done` reference is registered and removed (the previous version off()'d a different
170
+ * identity, leaking one stale listener per backpressure wait → MaxListenersExceededWarning). */
171
+ awaitDrain() {
172
+ return new Promise((resolve) => {
173
+ let settled = false;
174
+ const done = () => {
175
+ if (settled) return;
176
+ settled = true;
177
+ clearTimeout(t);
178
+ this.off('drain', done);
179
+ this.off('close', done);
180
+ resolve();
181
+ };
182
+ const t = setTimeout(done, 30000);
183
+ this.once('drain', done);
184
+ this.once('close', done);
185
+ });
186
+ }
187
+
188
+ /**
189
+ * Opaque byte-append (the serve core splits one load_chunk across 3 write() calls).
190
+ * Reassembles complete SSE frames across calls and forwards each as a {type:'frame'}.
191
+ * Returns false to signal per-stream backpressure (frames are QUEUED, never dropped) —
192
+ * the same threshold semantics _safeSseWrite applies to an SSE res. Never throws.
193
+ */
194
+ write(text) {
195
+ if (this.destroyed) return false;
196
+ this._buf += text;
197
+ let start = 0;
198
+ let idx;
199
+ let ok = true;
200
+ while ((idx = this._buf.indexOf('\n\n', start)) !== -1) {
201
+ const body = this._buf.slice(start, idx);
202
+ start = idx + 2;
203
+ const parsed = parseSseFrame(body, this._st.report);
204
+ if (!parsed) continue;
205
+ this._seq += 1;
206
+ if (!this._st.send({ type: 'frame', streamId: this.streamId, seq: this._seq, name: parsed.name, data: parsed.data })) {
207
+ ok = false;
208
+ }
209
+ }
210
+ if (start > 0) this._buf = this._buf.slice(start);
211
+ this.writableNeedDrain = this._st.behind();
212
+ return ok;
213
+ }
214
+
215
+ /**
216
+ * End THIS logical stream only — never the socket (one socket carries N streams; a single
217
+ * slow stream's 30s eviction must not take its siblings down, unlike SSE where end() closed
218
+ * exactly one EventSource). Emits 'close' asynchronously to mirror Node res semantics (so a
219
+ * `for (const c of clients) c.end()` loop does not skip siblings mid-iteration).
220
+ */
221
+ end() {
222
+ if (this.destroyed) return;
223
+ this.destroyed = true;
224
+ this.writable = false;
225
+ this.writableNeedDrain = false;
226
+ queueMicrotask(() => {
227
+ this.emit('close');
228
+ this._st.closeStream(this.streamId, 'sink-end');
229
+ });
230
+ }
231
+ }
232
+
233
+ export function createStreamMuxServer({
234
+ WebSocketServer,
235
+ serveStream,
236
+ reportSwallowed = defaultReport,
237
+ epoch = randomBytes(6).toString('hex'),
238
+ setInterval: setI = setInterval,
239
+ clearInterval: clearI = clearInterval,
240
+ setTimeout: setT = setTimeout,
241
+ clearTimeout: clearT = clearTimeout,
242
+ now = Date.now,
243
+ } = {}) {
244
+ // perMessageDeflate: the dialogue channel carries the app's largest transfers (cold-load
245
+ // windows); without it the mux would send verbatim JSON where the SSE path gets per-connection
246
+ // brotli (measured 21-88x on real sessions). ws's built-in permessage-deflate (zlib) recovers
247
+ // most of that with no new dependency. (SSE keeps its stronger brotli; this closes the gap.)
248
+ const wss = new WebSocketServer({ noServer: true, maxPayload: 1024 * 1024, perMessageDeflate: true });
249
+ const sockets = new Set();
250
+ wss.on('error', (err) => reportSwallowed('ws.mux.server', err));
251
+ wss.on('connection', (ws) => handleConnection(ws));
252
+
253
+ function handleConnection(ws) {
254
+ const st = {
255
+ ws,
256
+ streams: new Map(), // streamId -> { sink, handle, disposed }
257
+ lastInboundAt: now(),
258
+ closed: false,
259
+ queue: [], // FIFO of encoded frames held while behind
260
+ queuedBytes: 0,
261
+ _behind: false,
262
+ gate: null,
263
+ pingTimer: null,
264
+ flushTimer: null,
265
+ report: reportSwallowed,
266
+ send: null, behind: null, closeStream: null,
267
+ };
268
+ sockets.add(st);
269
+
270
+ // Drain the queue as a BOUNDED trickle, not a single-tick flood: after each frame, re-check
271
+ // bufferedAmount and stop the moment it climbs back over highWater (a slow consumer's socket
272
+ // buffer would otherwise spike to many times the high-water mark in one resume tick). The
273
+ // trickle self-reschedules (the gate's poll stops once it fires onResume, so we can't rely on
274
+ // it to drive the next trickle); teardown clears the timer.
275
+ const flushQueue = () => {
276
+ if (st.flushTimer) { clearT(st.flushTimer); st.flushTimer = null; }
277
+ while (st.queue.length && !st.closed && st.ws.readyState === 1) {
278
+ if ((st.ws.bufferedAmount || 0) > HIGH_WATER) {
279
+ st._behind = true;
280
+ st.flushTimer = setT(flushQueue, 100);
281
+ st.flushTimer.unref?.();
282
+ return;
283
+ }
284
+ const text = st.queue[0];
285
+ try { st.ws.send(text); } catch (err) {
286
+ reportSwallowed('ws.mux.send', err);
287
+ closeSocket(st, 1011, 'send-failed');
288
+ return;
289
+ }
290
+ st.queue.shift();
291
+ st.queuedBytes -= text.length;
292
+ }
293
+ };
294
+ const fireAllDrain = () => {
295
+ for (const rec of st.streams.values()) rec.sink.emit('drain');
296
+ };
297
+ st.gate = createBackpressureGate({
298
+ getBufferedAmount: () => st.ws.bufferedAmount || 0,
299
+ onResume: () => { st._behind = false; flushQueue(); fireAllDrain(); },
300
+ onBehind: () => { st._behind = true; },
301
+ onTimeout: () => {
302
+ reportSwallowed('ws.mux.backpressure', new Error('timeout'));
303
+ closeSocket(st, 1011, 'backpressure-timeout');
304
+ },
305
+ highWater: HIGH_WATER,
306
+ lowWater: LOW_WATER,
307
+ timeoutMs: 30000,
308
+ });
309
+ st.behind = () => st._behind || st.queuedBytes > 0;
310
+
311
+ // Send-or-enqueue. NEVER drops: while behind, frames accumulate in the bounded queue
312
+ // (order preserved). A wedged client dies by the 30s gate timeout / queue cap, then the
313
+ // client reconnects and resumes via its since/cc cursor. The gate's offer() return is
314
+ // ONLY a signal (its native contract is "skip this send" — the terminal path compensates
315
+ // with data-resync; mux must NOT skip, so we ignore the return for the send decision).
316
+ st.send = (msg) => {
317
+ if (st.closed || st.ws.readyState !== 1) return false;
318
+ const text = encodeFrame(msg);
319
+ st.gate.offer(); // signal only; result deliberately unused
320
+ if (st._behind) {
321
+ if (st.queuedBytes + text.length > QUEUE_MAX_BYTES) {
322
+ closeSocket(st, 1011, 'queue-overflow');
323
+ return false;
324
+ }
325
+ st.queue.push(text);
326
+ st.queuedBytes += text.length;
327
+ return false;
328
+ }
329
+ try { st.ws.send(text); } catch (err) {
330
+ reportSwallowed('ws.mux.send', err);
331
+ closeSocket(st, 1011, 'send-failed');
332
+ return false;
333
+ }
334
+ return true;
335
+ };
336
+
337
+ st.closeStream = (streamId, reason) => {
338
+ const rec = st.streams.get(streamId);
339
+ if (!rec || rec.disposed) return;
340
+ rec.disposed = true;
341
+ st.streams.delete(streamId);
342
+ // Wake any in-flight sink.awaitDrain() waiters (SSE's awaitWireDrain wakes on res
343
+ // 'close'; a mux stream must too, or a serve coroutine hangs the full 30s timeout).
344
+ // end() is the idempotent public path that emits 'close' + notifies us back (guarded
345
+ // by rec.disposed above, so the re-entry is a no-op).
346
+ try { rec.sink.end(); } catch (err) { reportSwallowed('ws.mux.dispose', err); }
347
+ try { rec.handle?.dispose?.(); } catch (err) { reportSwallowed('ws.mux.dispose', err); }
348
+ // Make a server-initiated stream close VISIBLE to the client (a SSE res.end() makes the
349
+ // browser EventSource error+reconnect; a silent mux close would leave the facade OPEN and
350
+ // the channel frozen until its heartbeat). Only when the socket is up and the close did
351
+ // not originate from the client's own close frame.
352
+ if (reason !== 'client-close' && reason !== 'socket-close' && reason !== 'server-stopping' && !st.closed) {
353
+ st.send({ type: 'close', streamId });
354
+ }
355
+ };
356
+
357
+ st.pingTimer = setI(() => {
358
+ if (st.closed) return;
359
+ if (now() - st.lastInboundAt > HEARTBEAT_REAP_MS) {
360
+ closeSocket(st, 1001, 'heartbeat-reap');
361
+ return;
362
+ }
363
+ st.send({ type: 'ping', ts: now() });
364
+ }, HEARTBEAT_SEND_MS);
365
+ st.pingTimer.unref?.();
366
+
367
+ ws.on('message', (raw) => { st.lastInboundAt = now(); onMessage(st, raw); });
368
+ ws.on('close', () => teardown(st, 'socket-close'));
369
+ ws.on('error', (err) => { reportSwallowed('ws.mux.socket', err); teardown(st, 'socket-error'); });
370
+ }
371
+
372
+ function onMessage(st, raw) {
373
+ const msg = decodeFrame(raw);
374
+ if (!msg) {
375
+ st.send({ type: 'error', streamId: null, code: MUX_CODES.BAD_REQUEST, message: 'malformed frame' });
376
+ return;
377
+ }
378
+ if (msg.type === 'pong') return;
379
+ if (msg.type === 'ping') { st.send({ type: 'pong', ts: now() }); return; }
380
+ if (msg.type === 'close') {
381
+ st.closeStream(msg.streamId, 'client-close'); // unknown id: ignore (graphql-ws)
382
+ return;
383
+ }
384
+ if (msg.type === 'open') onOpen(st, msg);
385
+ }
386
+
387
+ function onOpen(st, msg) {
388
+ const v = validateOpenParams(msg);
389
+ if (!v.ok) {
390
+ st.send({ type: 'error', streamId: typeof msg.streamId === 'string' ? msg.streamId : null, code: v.code, message: v.message });
391
+ return;
392
+ }
393
+ if (st.streams.has(v.streamId)) {
394
+ st.send({ type: 'error', streamId: v.streamId, code: MUX_CODES.DUPLICATE_STREAM, message: 'duplicate streamId' });
395
+ return;
396
+ }
397
+ if (st.streams.size >= MAX_STREAMS_PER_SOCKET) {
398
+ st.send({ type: 'error', streamId: v.streamId, code: MUX_CODES.TOO_MANY_STREAMS, message: 'too many streams' });
399
+ return;
400
+ }
401
+ const sink = new MuxSink(st, v.streamId);
402
+ const rec = { sink, handle: null, disposed: false };
403
+ st.streams.set(v.streamId, rec);
404
+ sink.on('close', () => st.closeStream(v.streamId, 'sink-close'));
405
+ // ack precedes every frame of this stream (history frames included).
406
+ st.send({ type: 'open-ack', streamId: v.streamId, epoch, seq: 0 });
407
+ let handleP;
408
+ try {
409
+ handleP = serveStream(sink, v.qs);
410
+ } catch (err) {
411
+ reportSwallowed('ws.mux.serve', err);
412
+ st.send({ type: 'error', streamId: v.streamId, code: MUX_CODES.SCOPE_UNAVAILABLE, message: 'serve failed' });
413
+ st.closeStream(v.streamId, 'serve-throw');
414
+ return;
415
+ }
416
+ // serveStream may return a handle synchronously or a Promise of one (the SSE serve core
417
+ // resolves after the history push). Capture a sync (non-thenable) handle IMMEDIATELY so a
418
+ // close arriving in the same tick still disposes it; a Promise handle lands in the microtask.
419
+ if (handleP && typeof handleP.then !== 'function') {
420
+ rec.handle = handleP;
421
+ if (rec.disposed) { try { handleP?.dispose?.(); } catch (err) { reportSwallowed('ws.mux.dispose', err); } }
422
+ } else {
423
+ Promise.resolve(handleP).then((resolved) => {
424
+ rec.handle = resolved;
425
+ if (rec.disposed) { // socket/stream died while history was streaming
426
+ try { resolved?.dispose?.(); } catch (err) { reportSwallowed('ws.mux.dispose', err); }
427
+ }
428
+ }).catch((err) => {
429
+ reportSwallowed('ws.mux.serve', err);
430
+ st.send({ type: 'error', streamId: v.streamId, code: MUX_CODES.SCOPE_UNAVAILABLE, message: 'serve failed' });
431
+ st.closeStream(v.streamId, 'serve-failed');
432
+ });
433
+ }
434
+ }
435
+
436
+ function closeSocket(st, code, reason) {
437
+ if (st.closed) return;
438
+ for (const streamId of [...st.streams.keys()]) st.closeStream(streamId, 'socket-close');
439
+ try { st.ws.close(code, reason); } catch { /* already gone */ }
440
+ teardown(st, reason);
441
+ }
442
+
443
+ function teardown(st, _reason) {
444
+ if (st.closed) return;
445
+ st.closed = true;
446
+ for (const streamId of [...st.streams.keys()]) st.closeStream(streamId, _reason);
447
+ try { st.gate.dispose(); } catch { /* best-effort */ }
448
+ if (st.pingTimer) { clearI(st.pingTimer); st.pingTimer = null; }
449
+ if (st.flushTimer) { clearT(st.flushTimer); st.flushTimer = null; }
450
+ sockets.delete(st);
451
+ }
452
+
453
+ return {
454
+ handleUpgrade(req, socket, head) {
455
+ try {
456
+ wss.handleUpgrade(req, socket, head, (ws) => wss.emit('connection', ws, req));
457
+ } catch (err) {
458
+ reportSwallowed('ws.mux.upgrade', err);
459
+ try { socket.destroy(); } catch { /* raced */ }
460
+ }
461
+ },
462
+ /** Terminate every socket (server.close() does NOT reach upgraded sockets) + close wss. */
463
+ close() {
464
+ for (const st of [...sockets]) {
465
+ try { closeSocket(st, 1001, 'server-stopping'); } catch { /* best-effort */ }
466
+ try { st.ws.terminate?.(); } catch { /* already gone */ }
467
+ }
468
+ try { wss.close(); } catch { /* stop/start cycles */ }
469
+ },
470
+ _sockets: sockets,
471
+ _server: wss,
472
+ };
473
+ }
@@ -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
  );