cc-viewer 1.9.4 → 1.9.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/cli.js +5 -2
- package/dist/assets/App-GhvyKB0E.js +2 -0
- package/dist/assets/{MdxEditorPanel-C8Cij93y.js → MdxEditorPanel-DwwKGPIQ.js} +1 -1
- package/dist/assets/{Mobile-OKCVi8un.js → Mobile-CGiDFGRO.js} +1 -1
- package/dist/assets/{ProxyStatsModal-DGlngtM9.js → ProxyStatsModal-CI5PkF_B.js} +1 -1
- package/dist/assets/{index-Clt4dw8Y.js → index-D0_0_HIv.js} +2 -2
- package/dist/assets/seqResourceLoaders-DHr1E1Ye.js +2 -0
- package/dist/index.html +1 -1
- package/package.json +1 -1
- package/server/interceptor.js +165 -14
- package/server/lib/git-diff.js +14 -8
- package/server/lib/scope-router.js +68 -0
- package/server/lib/sdk-manager.js +155 -32
- package/server/lib/sdk-session-index.js +25 -0
- package/server/lib/task-state.js +74 -34
- package/server/lib/turn-end-bridge.js +6 -1
- package/server/lib/v2/live-feed.js +7 -6
- package/server/lib/v2/view-router.js +48 -0
- package/server/lib/view-root.js +30 -10
- package/server/lib/workflow-watcher.js +13 -6
- package/server/lib/ws-mux.js +492 -0
- package/server/pty-manager.js +53 -0
- package/server/routes/ask-perm.js +13 -8
- package/server/routes/events.js +185 -86
- package/server/routes/project-meta.js +11 -4
- package/server/routes/proxy-stats.js +8 -4
- package/server/routes/resume.js +64 -10
- package/server/routes/session-pin.js +12 -2
- package/server/routes/workflow-journal.js +5 -2
- package/server/routes/workspaces.js +7 -1
- package/server/server.js +353 -120
- package/dist/assets/App-DtBVNYE7.js +0 -2
- package/dist/assets/seqResourceLoaders-zlIVjapi.js +0 -2
package/server/routes/events.js
CHANGED
|
@@ -7,11 +7,13 @@ 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';
|
|
13
14
|
import { sanitizePathComponent, normalizeSessionCwd } from '../lib/v2/layout.js';
|
|
14
15
|
import { listLivePtys } from '../pty-manager.js';
|
|
16
|
+
import { findSdkSessionByInstance } from '../lib/sdk-session-index.js';
|
|
15
17
|
import { enrichRawIfNeeded } from '../lib/enrich-plan-input.js';
|
|
16
18
|
import { validateLogPath } from '../lib/log-management.js';
|
|
17
19
|
import { isMainAgentEntry, extractCachedContent } from '../lib/kv-cache-analyzer.js';
|
|
@@ -49,11 +51,17 @@ function turnEndNotify(req, res, parsedUrl, isLocal, deps) {
|
|
|
49
51
|
res.end(JSON.stringify({ error: 'malformed JSON body' }));
|
|
50
52
|
return;
|
|
51
53
|
}
|
|
52
|
-
|
|
54
|
+
// Per-workspace: resolve the emitting claude's scope (sid→PTY first, cwd
|
|
55
|
+
// fallback, bound last) so the turn_end frame and the immediate turn-active
|
|
56
|
+
// clear land on exactly one workspace's viewers.
|
|
57
|
+
const scope = typeof deps.resolveScopeForHookPayload === 'function'
|
|
58
|
+
? deps.resolveScopeForHookPayload(payload)
|
|
59
|
+
: { project: '', instance: null };
|
|
60
|
+
deps.scheduleTurnEndBroadcast(payload.sessionId || null, payload.ts || Date.now(), payload.transcriptPath || null, scope);
|
|
53
61
|
// Turn-level loading clears on the RAW Stop POST arrival — the debounced
|
|
54
62
|
// `turn_end` SSE frame (voice-pack semantics) would keep the spinner up for
|
|
55
63
|
// the whole debounce window. Idempotent on the server side.
|
|
56
|
-
try { deps.onTurnActiveClear?.(); }
|
|
64
|
+
try { deps.onTurnActiveClear?.(scope); }
|
|
57
65
|
catch (err) { reportSwallowed('turn-end-notify.turn-active-clear', err); }
|
|
58
66
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
59
67
|
res.end(JSON.stringify({ ok: true }));
|
|
@@ -151,6 +159,28 @@ function taskEventNotify(req, res, parsedUrl, isLocal, deps) {
|
|
|
151
159
|
});
|
|
152
160
|
}
|
|
153
161
|
|
|
162
|
+
/**
|
|
163
|
+
* Parse the /events query into the canonical params object — the SINGLE parser shared by
|
|
164
|
+
* both transports (SSE: from the request URL; WS mux: from the `open` frame's raw query
|
|
165
|
+
* string fed through `new URL('?'+qs, …)`), so the two can never drift. Keeping `?limit`
|
|
166
|
+
* absent-vs-0 distinct (absent ⇒ DEFAULT_EVENTS_LIMIT, explicit 0 ⇒ "all") is load-bearing.
|
|
167
|
+
*/
|
|
168
|
+
export function paramsFromParsedUrl(parsedUrl) {
|
|
169
|
+
const g = (k) => parsedUrl.searchParams.get(k);
|
|
170
|
+
const sidRaw = g('sid');
|
|
171
|
+
const instanceRaw = g('instance');
|
|
172
|
+
const limitRaw = g('limit');
|
|
173
|
+
return {
|
|
174
|
+
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,
|
|
175
|
+
project: g('project'),
|
|
176
|
+
instance: (typeof instanceRaw === 'string' && /^ccv-[0-9a-f]+$/.test(instanceRaw)) ? instanceRaw : null,
|
|
177
|
+
since: g('since'),
|
|
178
|
+
cc: parseInt(g('cc'), 10) || 0,
|
|
179
|
+
limitRaw, // keep the raw string so "absent" (null) ≠ "explicit 0"
|
|
180
|
+
limitGiven: limitRaw !== null,
|
|
181
|
+
};
|
|
182
|
+
}
|
|
183
|
+
|
|
154
184
|
// SSE endpoint
|
|
155
185
|
// 构造「有新版」徽标的 SSE 帧(pending = {version, source})。pending 为空返回 null。
|
|
156
186
|
// 抽成纯函数便于单测帧格式;events() 在新连接上调用它补推,使版本徽标跨刷新持续显示。
|
|
@@ -160,15 +190,50 @@ export function sseUpdateBadgeFrame(pending) {
|
|
|
160
190
|
: null;
|
|
161
191
|
}
|
|
162
192
|
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
193
|
+
/**
|
|
194
|
+
* The sink contract — the transport-agnostic target the serve core writes to. An SSE
|
|
195
|
+
* `res` is adapted with `registrable === res`; a WS-mux logical stream provides its own
|
|
196
|
+
* shim (which IS the registrable). Both produce the identical (name, data) frame sequence
|
|
197
|
+
* by construction, because both run through this one function.
|
|
198
|
+
* sink.write(text) -> bool opaque SSE byte-append (a load_chunk is split across 3 writes)
|
|
199
|
+
* sink.alive() -> bool !destroyed && writable
|
|
200
|
+
* sink.needsDrain() -> bool socket-level backpressure signal
|
|
201
|
+
* sink.awaitDrain() -> Promise resolves on drain / close / error / 30s timeout
|
|
202
|
+
* sink.registrable the object pushed into deps.clients (gets _ccvView* stamps)
|
|
203
|
+
*/
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* serveEventStream — THE single implementation of one /events stream, behind both the SSE
|
|
207
|
+
* route and the WS-mux stream. Resolves after history + kv/context + the broadcast-list
|
|
208
|
+
* push (so callers awaiting it observe the client registered). Returns { dispose } —
|
|
209
|
+
* dispose is idempotent: clears the per-stream pingTimer and splices the registrable out
|
|
210
|
+
* of deps.clients.
|
|
211
|
+
*/
|
|
212
|
+
export async function serveEventStream(sink, params, deps) {
|
|
213
|
+
const registrable = sink.registrable;
|
|
214
|
+
let closed = false;
|
|
215
|
+
let pingTimer = null;
|
|
216
|
+
const dispose = () => {
|
|
217
|
+
if (closed) return;
|
|
218
|
+
closed = true;
|
|
219
|
+
if (pingTimer) { clearInterval(pingTimer); pingTimer = null; }
|
|
220
|
+
const idx = deps.clients.indexOf(registrable);
|
|
221
|
+
if (idx !== -1) deps.clients.splice(idx, 1);
|
|
222
|
+
};
|
|
223
|
+
// Register the close hook at the ENTRY (not after the history stream): a client that
|
|
224
|
+
// disconnects mid-cold-load must still clear the pingTimer and never be pushed — the
|
|
225
|
+
// previous tail-registration leaked the timer and pushed a dead res in that window.
|
|
226
|
+
sink.onClose?.(dispose);
|
|
227
|
+
|
|
228
|
+
const { sidParam, projectParam, instanceParam, sinceParam, ccParam, limitRaw, limitGiven } = {
|
|
229
|
+
sidParam: params.sid,
|
|
230
|
+
projectParam: params.project,
|
|
231
|
+
instanceParam: params.instance,
|
|
232
|
+
sinceParam: params.since,
|
|
233
|
+
ccParam: params.cc,
|
|
234
|
+
limitRaw: params.limitRaw,
|
|
235
|
+
limitGiven: params.limitGiven,
|
|
236
|
+
};
|
|
172
237
|
|
|
173
238
|
// 注意:不要在此处 clients.push(res)!
|
|
174
239
|
// 必须等 load_end + kv_cache + context_window 全部发送完毕后再加入广播列表,
|
|
@@ -177,8 +242,8 @@ async function events(req, res, parsedUrl, isLocal, deps) {
|
|
|
177
242
|
// 已处理的 live entry,导致 对话条目"显示→消失→重现"闪烁。
|
|
178
243
|
|
|
179
244
|
// SSE 心跳保活:每 30s 发送 ping 事件,防止连接被 OS/代理/浏览器静默断开
|
|
180
|
-
|
|
181
|
-
try {
|
|
245
|
+
pingTimer = setInterval(() => {
|
|
246
|
+
try { sink.write('event: ping\ndata: {}\n\n'); } catch {}
|
|
182
247
|
}, 30000);
|
|
183
248
|
|
|
184
249
|
// server_config: 给前端推一次性的关键运行时常量,让前端 cooldown / debounce 等
|
|
@@ -186,15 +251,15 @@ async function events(req, res, parsedUrl, isLocal, deps) {
|
|
|
186
251
|
// 见 server.js 顶部 turnEnd debounce 的 SUNSET-MARKER 注释。
|
|
187
252
|
// write 失败要 warn:之前静默吞会让 env override 漂移到前端硬常量 10s 而无人发觉。
|
|
188
253
|
try {
|
|
189
|
-
|
|
254
|
+
sink.write(`event: server_config\ndata: ${JSON.stringify({ turnEndDebounceMs: deps.turnEndDebounceMs, wireV3: !!deps.wireV3, build: deps.serverBuild || '' })}\n\n`);
|
|
190
255
|
} catch (err) {
|
|
191
256
|
console.warn(`[server_config] SSE write failed (turnEndDebounceMs=${deps.turnEndDebounceMs}):`, err && err.message);
|
|
192
257
|
}
|
|
193
258
|
|
|
194
259
|
// 补推「有新版」徽标:复用启动检查缓存的结果,让刷新/新标签页连上即恢复徽标。
|
|
195
|
-
// 置于 deps.clients.push
|
|
260
|
+
// 置于 deps.clients.push 之前 → 新客户端仅得一份,不与一次性广播竞态。
|
|
196
261
|
const updFrame = sseUpdateBadgeFrame(deps.pendingMajorUpdate);
|
|
197
|
-
if (updFrame)
|
|
262
|
+
if (updFrame) sink.write(updFrame);
|
|
198
263
|
|
|
199
264
|
// 1.7.0 迁移引导(P2):「正在被查看的项目」仍有未转换的 v1 日志 → 连接即推 migrate_prompt。
|
|
200
265
|
// 是否弹窗由客户端决定(「不再提醒」偏好在客户端;continued=true 时无视 dismissed
|
|
@@ -203,17 +268,15 @@ async function events(req, res, parsedUrl, isLocal, deps) {
|
|
|
203
268
|
// (declared below from ?project=) scopes the check to the project the client is
|
|
204
269
|
// actually viewing, not always the bound one.
|
|
205
270
|
try {
|
|
206
|
-
const migProject =
|
|
271
|
+
const migProject = projectParam || _projectName || '';
|
|
207
272
|
const mig = migrationStatus(LOG_DIR, migProject);
|
|
208
273
|
if (mig.pending) {
|
|
209
|
-
|
|
274
|
+
sink.write(`event: migrate_prompt\ndata: ${JSON.stringify({ ...mig, continued: isContinuedLaunch() })}\n\n`);
|
|
210
275
|
}
|
|
211
276
|
} catch (e) { reportSwallowed('sse.migrate_prompt', e); }
|
|
212
277
|
|
|
213
|
-
// 增量加载参数:移动端带 since/cc/project
|
|
214
|
-
|
|
215
|
-
const ccParam = parseInt(parsedUrl.searchParams.get('cc'), 10) || 0;
|
|
216
|
-
const projectParam = parsedUrl.searchParams.get('project');
|
|
278
|
+
// 增量加载参数:移动端带 since/cc/project 请求增量数据(sinceParam/ccParam/projectParam
|
|
279
|
+
// 已在头部从 params 解构)。
|
|
217
280
|
// Multi-project (2026-10): a foreign `?project=` (a parallel-project VIEW)
|
|
218
281
|
// may ALSO resume incrementally — the view-state cache keeps the departing
|
|
219
282
|
// project's lastTs and the client returns with `?project=<p>&since=<lastTs>`.
|
|
@@ -224,12 +287,12 @@ async function events(req, res, parsedUrl, isLocal, deps) {
|
|
|
224
287
|
// so the bound-match condition is dropped (cc>0 + a valid date still guard).
|
|
225
288
|
const useIncremental = !!(sinceParam && ccParam > 0 && !isNaN(new Date(sinceParam).getTime()));
|
|
226
289
|
|
|
227
|
-
//
|
|
290
|
+
// 分页参数(limitRaw/limitGiven 已在头部从 params 解构,保留 absent≠0 语义):
|
|
228
291
|
// - mobile 首次加载传 ?limit=200
|
|
229
292
|
// - bare desktop 请求(无任何 query 参数)默认套 DEFAULT_EVENTS_LIMIT
|
|
230
293
|
// - 显式 ?limit=0 表示"我要全量"(保留旧行为入口)
|
|
231
|
-
const limitParamRaw =
|
|
232
|
-
const limitParamGiven =
|
|
294
|
+
const limitParamRaw = limitRaw;
|
|
295
|
+
const limitParamGiven = limitGiven;
|
|
233
296
|
const limitParamNum = parseInt(limitParamRaw, 10);
|
|
234
297
|
let effectiveLimit = 0;
|
|
235
298
|
if (!useIncremental) {
|
|
@@ -250,18 +313,10 @@ async function events(req, res, parsedUrl, isLocal, deps) {
|
|
|
250
313
|
// - `?project=<p>` (no sid): project <p>'s CURRENT session — the newest
|
|
251
314
|
// readable, non-teammate session with a main turn — so a parallel-project
|
|
252
315
|
// chip can be VIEWED without respawning or killing its process.
|
|
253
|
-
|
|
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;
|
|
316
|
+
// sidParam / instanceParam 已在头部从 params 解构并校验(sid=uuid 形状、instance=ccv-<hex>)。
|
|
262
317
|
// `projectParam` (declared above for the incremental guard) doubles as the view
|
|
263
318
|
// scope. The project this client is VIEWING: the explicit ?project= override,
|
|
264
|
-
// else the bound project. Stamped onto the
|
|
319
|
+
// else the bound project. Stamped onto the sink registrable so per-project feeds can
|
|
265
320
|
// route live entries to exactly the clients viewing that project.
|
|
266
321
|
const viewedProject = projectParam || (_projectName || '');
|
|
267
322
|
const resolveColdSource = () => {
|
|
@@ -297,7 +352,8 @@ async function events(req, res, parsedUrl, isLocal, deps) {
|
|
|
297
352
|
let targetCwd = '';
|
|
298
353
|
if (instanceParam) {
|
|
299
354
|
try {
|
|
300
|
-
const rec = listLivePtys().find((p) => p && p.instanceKey === instanceParam)
|
|
355
|
+
const rec = listLivePtys().find((p) => p && p.instanceKey === instanceParam)
|
|
356
|
+
|| findSdkSessionByInstance(instanceParam);
|
|
301
357
|
targetCwd = rec && rec.cwd ? normalizeSessionCwd(rec.cwd) : '';
|
|
302
358
|
} catch (err) { targetCwd = ''; reportSwallowed('events.target-cwd', err); }
|
|
303
359
|
}
|
|
@@ -316,7 +372,7 @@ async function events(req, res, parsedUrl, isLocal, deps) {
|
|
|
316
372
|
// it attached — tell the client explicitly so it can toast + auto-detach.
|
|
317
373
|
const sidNotFound = !!(sidParam && !viewColdDir);
|
|
318
374
|
if (sidNotFound) {
|
|
319
|
-
try {
|
|
375
|
+
try { sink.write(`event: sid-not-found\ndata: ${JSON.stringify({ sid: sidParam, ts: Date.now() })}\n\n`); } catch { }
|
|
320
376
|
}
|
|
321
377
|
// Multi-instance (2026-10-06): an explicit `?instance=` that resolves to NO session dir must
|
|
322
378
|
// likewise NOT silently fall through to the bound project's current session (the instance
|
|
@@ -337,7 +393,8 @@ async function events(req, res, parsedUrl, isLocal, deps) {
|
|
|
337
393
|
let instanceAlive = false;
|
|
338
394
|
if (instanceNoSession) {
|
|
339
395
|
try {
|
|
340
|
-
instanceAlive = listLivePtys().some((p) => p && p.instanceKey === instanceParam)
|
|
396
|
+
instanceAlive = listLivePtys().some((p) => p && p.instanceKey === instanceParam)
|
|
397
|
+
|| !!findSdkSessionByInstance(instanceParam);
|
|
341
398
|
} catch (err) {
|
|
342
399
|
// Lookup failure degrades to the explicit dead-instance posture (never
|
|
343
400
|
// silently blank the client on a lookup error).
|
|
@@ -346,7 +403,7 @@ async function events(req, res, parsedUrl, isLocal, deps) {
|
|
|
346
403
|
}
|
|
347
404
|
}
|
|
348
405
|
if (instanceNoSession && !instanceAlive) {
|
|
349
|
-
try {
|
|
406
|
+
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
407
|
}
|
|
351
408
|
// When a view-scoped dir was requested AND resolved, it replaces the live-source
|
|
352
409
|
// cold load; otherwise fall back to the standard single-current-session source.
|
|
@@ -398,30 +455,28 @@ async function events(req, res, parsedUrl, isLocal, deps) {
|
|
|
398
455
|
const coldLoadResult = v3Cold ? null : await streamRawEntriesAsync(emptyLiveView ? '' : (coldSource || getLiveLogSource({ serveInFlight: !!deps.wireV3 })), async (raw) => {
|
|
399
456
|
// 直接发送原始 JSON 字符串,不做 parse/reconstruct/stringify
|
|
400
457
|
// ExitPlanMode V2 空 input 的条目按需补全 plan / planFilePath,其它原样透传
|
|
401
|
-
if (
|
|
458
|
+
if (!sink.alive()) return;
|
|
402
459
|
const out = enrichRawIfNeeded(raw);
|
|
403
460
|
// SSE data 字段不允许裸换行,去除 pretty-printed JSON 的换行
|
|
404
|
-
// 写入路径整体 try-catch 兜底:连接在
|
|
405
|
-
// 把 EPIPE 抛穿 async callback;
|
|
461
|
+
// 写入路径整体 try-catch 兜底:连接在 write 之间被对端 RST/destroy 时不至于
|
|
462
|
+
// 把 EPIPE 抛穿 async callback;sink 的 close/error 已会做 clients 数组清理。
|
|
406
463
|
let drained = true;
|
|
407
464
|
try {
|
|
408
|
-
|
|
409
|
-
const ok =
|
|
410
|
-
|
|
411
|
-
// Two pressure signals: `ok` is the write target's input buffer (the
|
|
412
|
-
//
|
|
413
|
-
//
|
|
414
|
-
|
|
415
|
-
// waits on whichever stream actually applies the pressure.
|
|
416
|
-
drained = ok && !needsDrain(res);
|
|
465
|
+
sink.write('event: load_chunk\ndata: [');
|
|
466
|
+
const ok = sink.write(out.includes('\n') ? out.replace(/\n/g, '') : out);
|
|
467
|
+
sink.write(']\n\n');
|
|
468
|
+
// Two pressure signals: `ok` is the write target's input buffer (the ENCODER on
|
|
469
|
+
// compressed SSE paths), sink.needsDrain() is the socket itself. sink.awaitDrain()
|
|
470
|
+
// below waits on whichever stream actually applies the pressure.
|
|
471
|
+
drained = ok && !sink.needsDrain();
|
|
417
472
|
} catch {
|
|
418
473
|
return;
|
|
419
474
|
}
|
|
420
475
|
// 写缓冲满则等 drain(或 close/error/超时任一 fulfill),防止浏览器侧 renderer OOM。
|
|
421
|
-
// helper 内部会在 fulfill 时把另外两个监听器从
|
|
476
|
+
// helper 内部会在 fulfill 时把另外两个监听器从 sink 上摘掉,避免 N 次 backpressure
|
|
422
477
|
// 累积出 N 个 stale close/error listener 触发 MaxListenersExceededWarning。
|
|
423
478
|
if (!drained) {
|
|
424
|
-
await
|
|
479
|
+
await sink.awaitDrain();
|
|
425
480
|
}
|
|
426
481
|
}, {
|
|
427
482
|
since: useIncremental ? sinceParam : undefined,
|
|
@@ -451,7 +506,7 @@ async function events(req, res, parsedUrl, isLocal, deps) {
|
|
|
451
506
|
loadStartData.hasMore = !!hasMore;
|
|
452
507
|
loadStartData.oldestTs = oldestTs || '';
|
|
453
508
|
}
|
|
454
|
-
|
|
509
|
+
sink.write(`event: load_start\ndata: ${JSON.stringify(loadStartData)}\n\n`);
|
|
455
510
|
},
|
|
456
511
|
});
|
|
457
512
|
|
|
@@ -485,10 +540,10 @@ async function events(req, res, parsedUrl, isLocal, deps) {
|
|
|
485
540
|
loadStartData.hasMore = !!meta.hasMore;
|
|
486
541
|
loadStartData.oldestTs = meta.oldestTimestamp || '';
|
|
487
542
|
}
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
for (const payload of native.convPayloads)
|
|
491
|
-
for (const payload of native.respPayloads)
|
|
543
|
+
sink.write(`event: load_start\ndata: ${JSON.stringify(loadStartData)}\n\n`);
|
|
544
|
+
sink.write(`event: v2_requests\ndata: ${rowsPayload}\n\n`);
|
|
545
|
+
for (const payload of native.convPayloads) sink.write(`event: v3_conv\ndata: ${payload}\n\n`);
|
|
546
|
+
for (const payload of native.respPayloads) sink.write(`event: v3_resp\ndata: ${payload}\n\n`);
|
|
492
547
|
// kv-cache / context_window sources: rebuild the newest completed
|
|
493
548
|
// mainAgent rows. Depth 3 mirrors the legacy scan-ring fallback (review
|
|
494
549
|
// P2-e): the newest main may lack usage/cached-content or be a
|
|
@@ -501,7 +556,7 @@ async function events(req, res, parsedUrl, isLocal, deps) {
|
|
|
501
556
|
} catch (err) { reportSwallowed('sse.v2_requests', err); }
|
|
502
557
|
}
|
|
503
558
|
|
|
504
|
-
|
|
559
|
+
sink.write(`event: load_end\ndata: {}\n\n`);
|
|
505
560
|
|
|
506
561
|
// S10a: v2 sources no longer invoke onScan (the two-pass window never
|
|
507
562
|
// stringifies out-of-window entries) — the adapter returns the newest-main
|
|
@@ -534,17 +589,21 @@ async function events(req, res, parsedUrl, isLocal, deps) {
|
|
|
534
589
|
|
|
535
590
|
// 发送最新 MainAgent 的 KV-Cache 和 context_window
|
|
536
591
|
if (latestKvCache) {
|
|
537
|
-
|
|
592
|
+
sink.write(`event: kv_cache_content\ndata: ${JSON.stringify(latestKvCache)}\n\n`);
|
|
538
593
|
}
|
|
539
594
|
if (latestContextWindow) {
|
|
540
|
-
|
|
595
|
+
sink.write(`event: context_window\ndata: ${JSON.stringify(latestContextWindow)}\n\n`);
|
|
541
596
|
pushedContextWindow = true;
|
|
542
597
|
}
|
|
543
598
|
// Fallback: no MainAgent in log (e.g. fresh session after -c), read context-window.json.
|
|
544
599
|
// Skipped for an empty live view: CONTEXT_WINDOW_FILE is a single GLOBAL file (not
|
|
545
600
|
// project-scoped), so pushing it here would paint another project's usage onto a
|
|
546
601
|
// brand-new workspace. The first real main turn pushes a genuine measurement instead.
|
|
547
|
-
|
|
602
|
+
// Per-workspace: the same global-file constraint applies to ANY non-bound view —
|
|
603
|
+
// a parallel project's viewers must not see the bound project's global fallback
|
|
604
|
+
// either (their live feed pushes their own measurement on the next main turn).
|
|
605
|
+
const viewedIsBound = !viewedProject || viewedProject === (_projectName || '');
|
|
606
|
+
if (!pushedContextWindow && !emptyLiveView && viewedIsBound) {
|
|
548
607
|
try {
|
|
549
608
|
const cwRaw = readFileSync(CONTEXT_WINDOW_FILE, 'utf-8');
|
|
550
609
|
const cwFile = JSON.parse(cwRaw);
|
|
@@ -560,7 +619,7 @@ async function events(req, res, parsedUrl, isLocal, deps) {
|
|
|
560
619
|
const effectiveSize = adaptContextWindow(contextSize, inputTokens);
|
|
561
620
|
const usedPct = effectiveSize > 0 ? Math.round((totalTokens / effectiveSize) * 100) : 0;
|
|
562
621
|
const data = { ...cw, context_window_size: effectiveSize, used_percentage: usedPct, remaining_percentage: 100 - usedPct };
|
|
563
|
-
|
|
622
|
+
sink.write(`event: context_window\ndata: ${JSON.stringify(data)}\n\n`);
|
|
564
623
|
}
|
|
565
624
|
} catch { }
|
|
566
625
|
}
|
|
@@ -569,45 +628,85 @@ async function events(req, res, parsedUrl, isLocal, deps) {
|
|
|
569
628
|
// 这样 watcher 的 sendToClients 不会在 load 阶段向该客户端推送 live entry。
|
|
570
629
|
// Stamp the viewed project so per-project feeds route live entries to exactly
|
|
571
630
|
// the clients viewing that project (unset ⇒ the bound project, the default).
|
|
572
|
-
|
|
631
|
+
// Stamped on the REGISTRABLE (the object in deps.clients): for SSE that is the res
|
|
632
|
+
// itself; for a WS-mux stream it is the per-stream shim (never the shared socket).
|
|
633
|
+
if (viewedProject) registrable._ccvViewProject = viewedProject;
|
|
573
634
|
// Multi-instance: stamp the viewed instance too, so a same-cwd project's live feed can
|
|
574
635
|
// route each instance's entries to exactly the clients viewing THAT instance (unset ⇒
|
|
575
636
|
// instance-agnostic, the pre-multi-instance default).
|
|
576
|
-
if (instanceParam)
|
|
577
|
-
|
|
637
|
+
if (instanceParam) registrable._ccvViewInstance = instanceParam;
|
|
638
|
+
// Push-guard: a client whose close fired mid-cold-load (dispose already ran via the
|
|
639
|
+
// entry-registered onClose hook) must NOT be re-added to the broadcast list.
|
|
640
|
+
if (!closed) deps.clients.push(registrable);
|
|
578
641
|
|
|
579
642
|
// 任务清单快照补推:内存态不在冷加载数据里,页面刷新/重连后横条要等下一次
|
|
580
643
|
// 任务事件才恢复 —— 连接建立即推一份当前快照(空列表也推,清掉客户端残留)。
|
|
644
|
+
// Per-workspace: the snapshot comes from the VIEWED scope's bucket, so a
|
|
645
|
+
// parallel view never receives the bound project's checklist (and vice versa).
|
|
646
|
+
// The scope key is built by the SAME normalizer the producers use (bound
|
|
647
|
+
// collapses to ''), so a bound view and its producer always meet in one bucket.
|
|
648
|
+
const _viewScopeKey = scopeKeyForProducer(
|
|
649
|
+
{ project: viewedProject || null, instance: instanceParam || null },
|
|
650
|
+
_projectName || '',
|
|
651
|
+
);
|
|
581
652
|
try {
|
|
582
|
-
const snap = deps.getTaskSnapshot
|
|
583
|
-
if (snap)
|
|
653
|
+
const snap = typeof deps.getTaskSnapshot === 'function' ? deps.getTaskSnapshot(_viewScopeKey) : null;
|
|
654
|
+
if (snap) sink.write(`event: task_update\ndata: ${JSON.stringify({ sessionId: snap.sessionId, tasks: snap.tasks, ts: Date.now() })}\n\n`);
|
|
584
655
|
} catch (err) { reportSwallowed('sse.task_update.snapshot', err); }
|
|
585
656
|
|
|
586
657
|
// Turn-level loading replay: a page refresh mid-turn restores the spinner in the
|
|
587
658
|
// same connect block (turn-level state is in-memory only, not in the cold load).
|
|
588
|
-
//
|
|
589
|
-
//
|
|
590
|
-
//
|
|
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.
|
|
659
|
+
// Per-workspace: the replay reads the VIEWED scope's bucket, so a parallel
|
|
660
|
+
// view restores ITS OWN spinner (previously bound-only — the parallel view
|
|
661
|
+
// simply never showed one).
|
|
594
662
|
try {
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
663
|
+
// 按返回值区分(不是按存在性):getTurnActiveFor 存在但查不到该 scope 的
|
|
664
|
+
// 活跃回合(false/undefined)时,仍回落 legacy 的 bound-only getPtyTurnActive —
|
|
665
|
+
// 否则一个「存在但恒 false」的退化实现会静默消灭整个连接回放。
|
|
666
|
+
const _forScope = typeof deps.getTurnActiveFor === 'function' ? deps.getTurnActiveFor(_viewScopeKey) : undefined;
|
|
667
|
+
const _turnActive = (_forScope !== undefined && _forScope !== null)
|
|
668
|
+
? _forScope
|
|
669
|
+
: (viewedIsBound && deps.getPtyTurnActive?.());
|
|
670
|
+
if (_turnActive) {
|
|
671
|
+
sink.write(`event: turn_active\ndata: ${JSON.stringify({ active: true, sessionId: null, ts: Date.now() })}\n\n`);
|
|
598
672
|
}
|
|
599
673
|
} catch (err) { reportSwallowed('sse.turn_active.replay', err); }
|
|
600
674
|
|
|
601
|
-
//
|
|
602
|
-
//
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
675
|
+
// Close cleanup lives in the entry-registered dispose (sink.onClose at the top): it clears
|
|
676
|
+
// the pingTimer and splices the registrable out of deps.clients, covering a disconnect at
|
|
677
|
+
// ANY point (mid-cold-load included — the previous tail-only listener leaked that window).
|
|
678
|
+
return { dispose };
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
// /events route handler — a thin SSE-only shell over serveEventStream. It negotiates the
|
|
682
|
+
// wire encoding, adapts the res into the sink contract, and lets the sink's onClose hook
|
|
683
|
+
// register dispose on the transport's req/res close/error. The WS-mux path calls
|
|
684
|
+
// serveEventStream directly with its own shim (whose onClose fires on stream teardown).
|
|
685
|
+
async function events(req, res, parsedUrl, isLocal, deps) {
|
|
686
|
+
// Negotiated Content-Encoding (br|identity). From here on, every byte of this response
|
|
687
|
+
// MUST go through sseWrite — a bare res.write would corrupt the compressed stream.
|
|
688
|
+
sseHead(req, res, 200, {
|
|
689
|
+
'Content-Type': 'text/event-stream',
|
|
690
|
+
'Cache-Control': 'no-cache',
|
|
691
|
+
'Connection': 'keep-alive',
|
|
692
|
+
});
|
|
693
|
+
// onClose is set BEFORE serveEventStream runs so the entry-registered hook captures it:
|
|
694
|
+
// serveEventStream calls sink.onClose(dispose) at entry, and we fan dispose out to the
|
|
695
|
+
// req/res lifecycle. (req close may not fire on some abrupt disconnects; res close/error
|
|
696
|
+
// backstop guarantees no ghost res is left in clients — preventing a slow write leak.)
|
|
697
|
+
const sink = {
|
|
698
|
+
registrable: res,
|
|
699
|
+
write: (s) => sseWrite(res, s),
|
|
700
|
+
alive: () => !res.destroyed && res.writable !== false,
|
|
701
|
+
needsDrain: () => needsDrain(res),
|
|
702
|
+
awaitDrain: () => awaitWireDrain(res, deps.SSE_BACKPRESSURE_TIMEOUT_MS),
|
|
703
|
+
onClose: (dispose) => {
|
|
704
|
+
req.on('close', dispose);
|
|
705
|
+
res.on('close', dispose);
|
|
706
|
+
res.on('error', dispose);
|
|
707
|
+
},
|
|
607
708
|
};
|
|
608
|
-
|
|
609
|
-
res.on('close', removeFromClients);
|
|
610
|
-
res.on('error', removeFromClients);
|
|
709
|
+
await serveEventStream(sink, paramsFromParsedUrl(parsedUrl), deps);
|
|
611
710
|
}
|
|
612
711
|
|
|
613
712
|
// API endpoint
|
|
@@ -14,10 +14,11 @@ import { detectHomebrewInstall } from '../lib/updater.js';
|
|
|
14
14
|
// not become a presence oracle over LOG_DIR — `?project=` is accepted ONLY
|
|
15
15
|
// when it resolves to a registered workspace / live PTY (or the bound
|
|
16
16
|
// project), never an arbitrary sanitized name.
|
|
17
|
-
function _resolveStatsName(projectParam, res) {
|
|
18
|
-
if (!projectParam) return _projectName || '';
|
|
17
|
+
function _resolveStatsName(projectParam, instanceParam, res) {
|
|
18
|
+
if (!projectParam && !instanceParam) return _projectName || '';
|
|
19
19
|
const r = resolveViewRoot({
|
|
20
20
|
projectParam,
|
|
21
|
+
instanceParam,
|
|
21
22
|
boundCwd: process.env.CCV_PROJECT_DIR || process.cwd(),
|
|
22
23
|
loadWorkspaces,
|
|
23
24
|
listLivePtys,
|
|
@@ -27,7 +28,12 @@ function _resolveStatsName(projectParam, res) {
|
|
|
27
28
|
res.end(JSON.stringify({ error: r.error }));
|
|
28
29
|
return null;
|
|
29
30
|
}
|
|
30
|
-
|
|
31
|
+
// The log-store name derives from the RAW matched path (basename BEFORE the
|
|
32
|
+
// resolver's realpath), which the wire/store layer keys on — never from the raw
|
|
33
|
+
// ?project= (forged names + a live instance would otherwise become a LOG_DIR
|
|
34
|
+
// presence oracle) and never from the realpath'd root (a leaf-symlinked workspace
|
|
35
|
+
// would then read the target's store name instead of its own).
|
|
36
|
+
return sanitizePathComponent(r.projectName);
|
|
31
37
|
}
|
|
32
38
|
|
|
33
39
|
// 判定当前 cc-viewer 的安装渠道,供前端精准匹配升级命令。
|
|
@@ -73,7 +79,8 @@ function projectStats(req, res, parsedUrl) {
|
|
|
73
79
|
// Multi-project (2026-10): project-stats resolves the VIEWED project
|
|
74
80
|
// (?project=, registered/live only — never an arbitrary name).
|
|
75
81
|
const projectParam = parsedUrl && parsedUrl.searchParams ? parsedUrl.searchParams.get('project') : null;
|
|
76
|
-
const
|
|
82
|
+
const instanceParam = parsedUrl && parsedUrl.searchParams ? parsedUrl.searchParams.get('instance') : null;
|
|
83
|
+
const name = _resolveStatsName(projectParam, instanceParam, res);
|
|
77
84
|
if (name === null) return; // error already replied
|
|
78
85
|
if (!name) {
|
|
79
86
|
res.writeHead(404, { 'Content-Type': 'application/json' });
|
|
@@ -19,10 +19,11 @@ import { listLivePtys } from '../pty-manager.js';
|
|
|
19
19
|
// not become a presence oracle over LOG_DIR — `?project=` is accepted ONLY
|
|
20
20
|
// when it resolves to a registered workspace / live PTY (or the bound
|
|
21
21
|
// project), never an arbitrary sanitized name.
|
|
22
|
-
function _resolveStatsName(projectParam, res) {
|
|
23
|
-
if (!projectParam) return _projectName || '';
|
|
22
|
+
function _resolveStatsName(projectParam, instanceParam, res) {
|
|
23
|
+
if (!projectParam && !instanceParam) return _projectName || '';
|
|
24
24
|
const r = resolveViewRoot({
|
|
25
25
|
projectParam,
|
|
26
|
+
instanceParam,
|
|
26
27
|
boundCwd: process.env.CCV_PROJECT_DIR || process.cwd(),
|
|
27
28
|
loadWorkspaces,
|
|
28
29
|
listLivePtys,
|
|
@@ -32,7 +33,9 @@ function _resolveStatsName(projectParam, res) {
|
|
|
32
33
|
res.end(JSON.stringify({ error: r.error }));
|
|
33
34
|
return null;
|
|
34
35
|
}
|
|
35
|
-
|
|
36
|
+
// Same contract as project-meta: the store name derives from the resolver's
|
|
37
|
+
// RAW matched path, never the raw request name nor the realpath'd root.
|
|
38
|
+
return sanitizePathComponent(r.projectName);
|
|
36
39
|
}
|
|
37
40
|
|
|
38
41
|
// GET /api/proxy-stats — returns the VIEWED project's proxy retry stats
|
|
@@ -41,7 +44,8 @@ function _resolveStatsName(projectParam, res) {
|
|
|
41
44
|
function getProxyStats(req, res, parsedUrl) {
|
|
42
45
|
try {
|
|
43
46
|
const projectParam = parsedUrl && parsedUrl.searchParams ? parsedUrl.searchParams.get('project') : null;
|
|
44
|
-
const
|
|
47
|
+
const instanceParam = parsedUrl && parsedUrl.searchParams ? parsedUrl.searchParams.get('instance') : null;
|
|
48
|
+
const name = _resolveStatsName(projectParam, instanceParam, res);
|
|
45
49
|
if (name === null) return; // error already replied
|
|
46
50
|
if (!name) {
|
|
47
51
|
res.writeHead(404, { 'Content-Type': 'application/json' });
|