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.
- package/README.md +5 -5
- package/cli.js +29 -2
- package/dist/assets/App-Bj_-wfrB.js +2 -0
- package/dist/assets/App-DuDeDOuX.css +1 -0
- package/dist/assets/{MdxEditorPanel-Ck8B5a5v.js → MdxEditorPanel-DD2IrRnW.js} +1 -1
- package/dist/assets/{Mobile-B8AkKb0z.js → Mobile-BQBQkr2V.js} +1 -1
- package/dist/assets/{ProxyStatsModal-DsDVMf-c.js → ProxyStatsModal-8VYl6n39.js} +1 -1
- package/dist/assets/index-CloXv0sm.js +2 -0
- package/dist/assets/{seqResourceLoaders-BsMFNfvR.css → seqResourceLoaders-Bg9Lz8pH.css} +1 -1
- package/dist/assets/seqResourceLoaders-M4XGblXm.js +2 -0
- package/dist/index.html +1 -1
- package/package.json +1 -1
- package/server/interceptor.js +176 -14
- package/server/lib/git-diff.js +14 -8
- package/server/lib/scope-router.js +68 -0
- package/server/lib/sdk-manager.js +1524 -895
- 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/workflow-watcher.js +13 -6
- package/server/lib/ws-mux.js +473 -0
- package/server/pty-manager.js +53 -0
- package/server/routes/ask-perm.js +13 -8
- package/server/routes/events.js +190 -74
- package/server/routes/resume.js +79 -1
- package/server/routes/sdk-context.js +24 -0
- package/server/routes/sdk-mcp-status.js +24 -0
- package/server/routes/session-pin.js +12 -2
- package/server/routes/workflow-journal.js +5 -2
- package/server/routes/workspaces.js +31 -2
- package/server/scratch-pty-manager.js +127 -46
- package/server/server.js +616 -108
- package/dist/assets/App-BzXO6ATr.js +0 -2
- package/dist/assets/App-DJ4yNvwz.css +0 -1
- package/dist/assets/index-Dq5UPr6v.js +0 -2
- package/dist/assets/seqResourceLoaders-BsQ6KXpV.js +0 -2
package/server/routes/events.js
CHANGED
|
@@ -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,7 +50,18 @@ function turnEndNotify(req, res, parsedUrl, isLocal, deps) {
|
|
|
49
50
|
res.end(JSON.stringify({ error: 'malformed JSON body' }));
|
|
50
51
|
return;
|
|
51
52
|
}
|
|
52
|
-
|
|
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);
|
|
60
|
+
// Turn-level loading clears on the RAW Stop POST arrival — the debounced
|
|
61
|
+
// `turn_end` SSE frame (voice-pack semantics) would keep the spinner up for
|
|
62
|
+
// the whole debounce window. Idempotent on the server side.
|
|
63
|
+
try { deps.onTurnActiveClear?.(scope); }
|
|
64
|
+
catch (err) { reportSwallowed('turn-end-notify.turn-active-clear', err); }
|
|
53
65
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
54
66
|
res.end(JSON.stringify({ ok: true }));
|
|
55
67
|
});
|
|
@@ -146,6 +158,28 @@ function taskEventNotify(req, res, parsedUrl, isLocal, deps) {
|
|
|
146
158
|
});
|
|
147
159
|
}
|
|
148
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
|
+
|
|
149
183
|
// SSE endpoint
|
|
150
184
|
// 构造「有新版」徽标的 SSE 帧(pending = {version, source})。pending 为空返回 null。
|
|
151
185
|
// 抽成纯函数便于单测帧格式;events() 在新连接上调用它补推,使版本徽标跨刷新持续显示。
|
|
@@ -155,15 +189,50 @@ export function sseUpdateBadgeFrame(pending) {
|
|
|
155
189
|
: null;
|
|
156
190
|
}
|
|
157
191
|
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
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
|
+
};
|
|
167
236
|
|
|
168
237
|
// 注意:不要在此处 clients.push(res)!
|
|
169
238
|
// 必须等 load_end + kv_cache + context_window 全部发送完毕后再加入广播列表,
|
|
@@ -172,8 +241,8 @@ async function events(req, res, parsedUrl, isLocal, deps) {
|
|
|
172
241
|
// 已处理的 live entry,导致 对话条目"显示→消失→重现"闪烁。
|
|
173
242
|
|
|
174
243
|
// SSE 心跳保活:每 30s 发送 ping 事件,防止连接被 OS/代理/浏览器静默断开
|
|
175
|
-
|
|
176
|
-
try {
|
|
244
|
+
pingTimer = setInterval(() => {
|
|
245
|
+
try { sink.write('event: ping\ndata: {}\n\n'); } catch {}
|
|
177
246
|
}, 30000);
|
|
178
247
|
|
|
179
248
|
// server_config: 给前端推一次性的关键运行时常量,让前端 cooldown / debounce 等
|
|
@@ -181,15 +250,15 @@ async function events(req, res, parsedUrl, isLocal, deps) {
|
|
|
181
250
|
// 见 server.js 顶部 turnEnd debounce 的 SUNSET-MARKER 注释。
|
|
182
251
|
// write 失败要 warn:之前静默吞会让 env override 漂移到前端硬常量 10s 而无人发觉。
|
|
183
252
|
try {
|
|
184
|
-
|
|
253
|
+
sink.write(`event: server_config\ndata: ${JSON.stringify({ turnEndDebounceMs: deps.turnEndDebounceMs, wireV3: !!deps.wireV3, build: deps.serverBuild || '' })}\n\n`);
|
|
185
254
|
} catch (err) {
|
|
186
255
|
console.warn(`[server_config] SSE write failed (turnEndDebounceMs=${deps.turnEndDebounceMs}):`, err && err.message);
|
|
187
256
|
}
|
|
188
257
|
|
|
189
258
|
// 补推「有新版」徽标:复用启动检查缓存的结果,让刷新/新标签页连上即恢复徽标。
|
|
190
|
-
// 置于 deps.clients.push
|
|
259
|
+
// 置于 deps.clients.push 之前 → 新客户端仅得一份,不与一次性广播竞态。
|
|
191
260
|
const updFrame = sseUpdateBadgeFrame(deps.pendingMajorUpdate);
|
|
192
|
-
if (updFrame)
|
|
261
|
+
if (updFrame) sink.write(updFrame);
|
|
193
262
|
|
|
194
263
|
// 1.7.0 迁移引导(P2):「正在被查看的项目」仍有未转换的 v1 日志 → 连接即推 migrate_prompt。
|
|
195
264
|
// 是否弹窗由客户端决定(「不再提醒」偏好在客户端;continued=true 时无视 dismissed
|
|
@@ -198,17 +267,15 @@ async function events(req, res, parsedUrl, isLocal, deps) {
|
|
|
198
267
|
// (declared below from ?project=) scopes the check to the project the client is
|
|
199
268
|
// actually viewing, not always the bound one.
|
|
200
269
|
try {
|
|
201
|
-
const migProject =
|
|
270
|
+
const migProject = projectParam || _projectName || '';
|
|
202
271
|
const mig = migrationStatus(LOG_DIR, migProject);
|
|
203
272
|
if (mig.pending) {
|
|
204
|
-
|
|
273
|
+
sink.write(`event: migrate_prompt\ndata: ${JSON.stringify({ ...mig, continued: isContinuedLaunch() })}\n\n`);
|
|
205
274
|
}
|
|
206
275
|
} catch (e) { reportSwallowed('sse.migrate_prompt', e); }
|
|
207
276
|
|
|
208
|
-
// 增量加载参数:移动端带 since/cc/project
|
|
209
|
-
|
|
210
|
-
const ccParam = parseInt(parsedUrl.searchParams.get('cc'), 10) || 0;
|
|
211
|
-
const projectParam = parsedUrl.searchParams.get('project');
|
|
277
|
+
// 增量加载参数:移动端带 since/cc/project 请求增量数据(sinceParam/ccParam/projectParam
|
|
278
|
+
// 已在头部从 params 解构)。
|
|
212
279
|
// Multi-project (2026-10): a foreign `?project=` (a parallel-project VIEW)
|
|
213
280
|
// may ALSO resume incrementally — the view-state cache keeps the departing
|
|
214
281
|
// project's lastTs and the client returns with `?project=<p>&since=<lastTs>`.
|
|
@@ -219,12 +286,12 @@ async function events(req, res, parsedUrl, isLocal, deps) {
|
|
|
219
286
|
// so the bound-match condition is dropped (cc>0 + a valid date still guard).
|
|
220
287
|
const useIncremental = !!(sinceParam && ccParam > 0 && !isNaN(new Date(sinceParam).getTime()));
|
|
221
288
|
|
|
222
|
-
//
|
|
289
|
+
// 分页参数(limitRaw/limitGiven 已在头部从 params 解构,保留 absent≠0 语义):
|
|
223
290
|
// - mobile 首次加载传 ?limit=200
|
|
224
291
|
// - bare desktop 请求(无任何 query 参数)默认套 DEFAULT_EVENTS_LIMIT
|
|
225
292
|
// - 显式 ?limit=0 表示"我要全量"(保留旧行为入口)
|
|
226
|
-
const limitParamRaw =
|
|
227
|
-
const limitParamGiven =
|
|
293
|
+
const limitParamRaw = limitRaw;
|
|
294
|
+
const limitParamGiven = limitGiven;
|
|
228
295
|
const limitParamNum = parseInt(limitParamRaw, 10);
|
|
229
296
|
let effectiveLimit = 0;
|
|
230
297
|
if (!useIncremental) {
|
|
@@ -245,18 +312,10 @@ async function events(req, res, parsedUrl, isLocal, deps) {
|
|
|
245
312
|
// - `?project=<p>` (no sid): project <p>'s CURRENT session — the newest
|
|
246
313
|
// readable, non-teammate session with a main turn — so a parallel-project
|
|
247
314
|
// chip can be VIEWED without respawning or killing its process.
|
|
248
|
-
|
|
249
|
-
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;
|
|
250
|
-
// Multi-instance (2026-10-06): `?instance=<instanceKey>` narrows the view to ONE concurrent
|
|
251
|
-
// process of a same-cwd project (which otherwise share a basename session dir pool). It
|
|
252
|
-
// scopes the no-sid cold-load pick (each instance resolves to ITS OWN latest session) and is
|
|
253
|
-
// stamped on the SSE response so the per-project live feed can route to exactly the clients
|
|
254
|
-
// viewing that instance. Validated to the minted `ccv-<hex>` shape.
|
|
255
|
-
const instanceParamRaw = parsedUrl.searchParams.get('instance');
|
|
256
|
-
const instanceParam = (typeof instanceParamRaw === 'string' && /^ccv-[0-9a-f]+$/.test(instanceParamRaw)) ? instanceParamRaw : null;
|
|
315
|
+
// sidParam / instanceParam 已在头部从 params 解构并校验(sid=uuid 形状、instance=ccv-<hex>)。
|
|
257
316
|
// `projectParam` (declared above for the incremental guard) doubles as the view
|
|
258
317
|
// scope. The project this client is VIEWING: the explicit ?project= override,
|
|
259
|
-
// else the bound project. Stamped onto the
|
|
318
|
+
// else the bound project. Stamped onto the sink registrable so per-project feeds can
|
|
260
319
|
// route live entries to exactly the clients viewing that project.
|
|
261
320
|
const viewedProject = projectParam || (_projectName || '');
|
|
262
321
|
const resolveColdSource = () => {
|
|
@@ -311,7 +370,7 @@ async function events(req, res, parsedUrl, isLocal, deps) {
|
|
|
311
370
|
// it attached — tell the client explicitly so it can toast + auto-detach.
|
|
312
371
|
const sidNotFound = !!(sidParam && !viewColdDir);
|
|
313
372
|
if (sidNotFound) {
|
|
314
|
-
try {
|
|
373
|
+
try { sink.write(`event: sid-not-found\ndata: ${JSON.stringify({ sid: sidParam, ts: Date.now() })}\n\n`); } catch { }
|
|
315
374
|
}
|
|
316
375
|
// Multi-instance (2026-10-06): an explicit `?instance=` that resolves to NO session dir must
|
|
317
376
|
// likewise NOT silently fall through to the bound project's current session (the instance
|
|
@@ -341,7 +400,7 @@ async function events(req, res, parsedUrl, isLocal, deps) {
|
|
|
341
400
|
}
|
|
342
401
|
}
|
|
343
402
|
if (instanceNoSession && !instanceAlive) {
|
|
344
|
-
try {
|
|
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 { }
|
|
345
404
|
}
|
|
346
405
|
// When a view-scoped dir was requested AND resolved, it replaces the live-source
|
|
347
406
|
// cold load; otherwise fall back to the standard single-current-session source.
|
|
@@ -393,30 +452,28 @@ async function events(req, res, parsedUrl, isLocal, deps) {
|
|
|
393
452
|
const coldLoadResult = v3Cold ? null : await streamRawEntriesAsync(emptyLiveView ? '' : (coldSource || getLiveLogSource({ serveInFlight: !!deps.wireV3 })), async (raw) => {
|
|
394
453
|
// 直接发送原始 JSON 字符串,不做 parse/reconstruct/stringify
|
|
395
454
|
// ExitPlanMode V2 空 input 的条目按需补全 plan / planFilePath,其它原样透传
|
|
396
|
-
if (
|
|
455
|
+
if (!sink.alive()) return;
|
|
397
456
|
const out = enrichRawIfNeeded(raw);
|
|
398
457
|
// SSE data 字段不允许裸换行,去除 pretty-printed JSON 的换行
|
|
399
|
-
// 写入路径整体 try-catch 兜底:连接在
|
|
400
|
-
// 把 EPIPE 抛穿 async callback;
|
|
458
|
+
// 写入路径整体 try-catch 兜底:连接在 write 之间被对端 RST/destroy 时不至于
|
|
459
|
+
// 把 EPIPE 抛穿 async callback;sink 的 close/error 已会做 clients 数组清理。
|
|
401
460
|
let drained = true;
|
|
402
461
|
try {
|
|
403
|
-
|
|
404
|
-
const ok =
|
|
405
|
-
|
|
406
|
-
// Two pressure signals: `ok` is the write target's input buffer (the
|
|
407
|
-
//
|
|
408
|
-
//
|
|
409
|
-
|
|
410
|
-
// waits on whichever stream actually applies the pressure.
|
|
411
|
-
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();
|
|
412
469
|
} catch {
|
|
413
470
|
return;
|
|
414
471
|
}
|
|
415
472
|
// 写缓冲满则等 drain(或 close/error/超时任一 fulfill),防止浏览器侧 renderer OOM。
|
|
416
|
-
// helper 内部会在 fulfill 时把另外两个监听器从
|
|
473
|
+
// helper 内部会在 fulfill 时把另外两个监听器从 sink 上摘掉,避免 N 次 backpressure
|
|
417
474
|
// 累积出 N 个 stale close/error listener 触发 MaxListenersExceededWarning。
|
|
418
475
|
if (!drained) {
|
|
419
|
-
await
|
|
476
|
+
await sink.awaitDrain();
|
|
420
477
|
}
|
|
421
478
|
}, {
|
|
422
479
|
since: useIncremental ? sinceParam : undefined,
|
|
@@ -446,7 +503,7 @@ async function events(req, res, parsedUrl, isLocal, deps) {
|
|
|
446
503
|
loadStartData.hasMore = !!hasMore;
|
|
447
504
|
loadStartData.oldestTs = oldestTs || '';
|
|
448
505
|
}
|
|
449
|
-
|
|
506
|
+
sink.write(`event: load_start\ndata: ${JSON.stringify(loadStartData)}\n\n`);
|
|
450
507
|
},
|
|
451
508
|
});
|
|
452
509
|
|
|
@@ -480,10 +537,10 @@ async function events(req, res, parsedUrl, isLocal, deps) {
|
|
|
480
537
|
loadStartData.hasMore = !!meta.hasMore;
|
|
481
538
|
loadStartData.oldestTs = meta.oldestTimestamp || '';
|
|
482
539
|
}
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
for (const payload of native.convPayloads)
|
|
486
|
-
for (const payload of native.respPayloads)
|
|
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`);
|
|
487
544
|
// kv-cache / context_window sources: rebuild the newest completed
|
|
488
545
|
// mainAgent rows. Depth 3 mirrors the legacy scan-ring fallback (review
|
|
489
546
|
// P2-e): the newest main may lack usage/cached-content or be a
|
|
@@ -496,7 +553,7 @@ async function events(req, res, parsedUrl, isLocal, deps) {
|
|
|
496
553
|
} catch (err) { reportSwallowed('sse.v2_requests', err); }
|
|
497
554
|
}
|
|
498
555
|
|
|
499
|
-
|
|
556
|
+
sink.write(`event: load_end\ndata: {}\n\n`);
|
|
500
557
|
|
|
501
558
|
// S10a: v2 sources no longer invoke onScan (the two-pass window never
|
|
502
559
|
// stringifies out-of-window entries) — the adapter returns the newest-main
|
|
@@ -529,17 +586,21 @@ async function events(req, res, parsedUrl, isLocal, deps) {
|
|
|
529
586
|
|
|
530
587
|
// 发送最新 MainAgent 的 KV-Cache 和 context_window
|
|
531
588
|
if (latestKvCache) {
|
|
532
|
-
|
|
589
|
+
sink.write(`event: kv_cache_content\ndata: ${JSON.stringify(latestKvCache)}\n\n`);
|
|
533
590
|
}
|
|
534
591
|
if (latestContextWindow) {
|
|
535
|
-
|
|
592
|
+
sink.write(`event: context_window\ndata: ${JSON.stringify(latestContextWindow)}\n\n`);
|
|
536
593
|
pushedContextWindow = true;
|
|
537
594
|
}
|
|
538
595
|
// Fallback: no MainAgent in log (e.g. fresh session after -c), read context-window.json.
|
|
539
596
|
// Skipped for an empty live view: CONTEXT_WINDOW_FILE is a single GLOBAL file (not
|
|
540
597
|
// project-scoped), so pushing it here would paint another project's usage onto a
|
|
541
598
|
// brand-new workspace. The first real main turn pushes a genuine measurement instead.
|
|
542
|
-
|
|
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) {
|
|
543
604
|
try {
|
|
544
605
|
const cwRaw = readFileSync(CONTEXT_WINDOW_FILE, 'utf-8');
|
|
545
606
|
const cwFile = JSON.parse(cwRaw);
|
|
@@ -555,7 +616,7 @@ async function events(req, res, parsedUrl, isLocal, deps) {
|
|
|
555
616
|
const effectiveSize = adaptContextWindow(contextSize, inputTokens);
|
|
556
617
|
const usedPct = effectiveSize > 0 ? Math.round((totalTokens / effectiveSize) * 100) : 0;
|
|
557
618
|
const data = { ...cw, context_window_size: effectiveSize, used_percentage: usedPct, remaining_percentage: 100 - usedPct };
|
|
558
|
-
|
|
619
|
+
sink.write(`event: context_window\ndata: ${JSON.stringify(data)}\n\n`);
|
|
559
620
|
}
|
|
560
621
|
} catch { }
|
|
561
622
|
}
|
|
@@ -564,30 +625,85 @@ async function events(req, res, parsedUrl, isLocal, deps) {
|
|
|
564
625
|
// 这样 watcher 的 sendToClients 不会在 load 阶段向该客户端推送 live entry。
|
|
565
626
|
// Stamp the viewed project so per-project feeds route live entries to exactly
|
|
566
627
|
// the clients viewing that project (unset ⇒ the bound project, the default).
|
|
567
|
-
|
|
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;
|
|
568
631
|
// Multi-instance: stamp the viewed instance too, so a same-cwd project's live feed can
|
|
569
632
|
// route each instance's entries to exactly the clients viewing THAT instance (unset ⇒
|
|
570
633
|
// instance-agnostic, the pre-multi-instance default).
|
|
571
|
-
if (instanceParam)
|
|
572
|
-
|
|
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);
|
|
573
638
|
|
|
574
639
|
// 任务清单快照补推:内存态不在冷加载数据里,页面刷新/重连后横条要等下一次
|
|
575
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
|
+
);
|
|
576
649
|
try {
|
|
577
|
-
const snap = deps.getTaskSnapshot
|
|
578
|
-
if (snap)
|
|
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`);
|
|
579
652
|
} catch (err) { reportSwallowed('sse.task_update.snapshot', err); }
|
|
580
653
|
|
|
581
|
-
//
|
|
582
|
-
//
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
654
|
+
// Turn-level loading replay: a page refresh mid-turn restores the spinner in the
|
|
655
|
+
// same connect block (turn-level state is in-memory only, not in the cold load).
|
|
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).
|
|
659
|
+
try {
|
|
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`);
|
|
669
|
+
}
|
|
670
|
+
} catch (err) { reportSwallowed('sse.turn_active.replay', err); }
|
|
671
|
+
|
|
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
|
+
},
|
|
587
705
|
};
|
|
588
|
-
|
|
589
|
-
res.on('close', removeFromClients);
|
|
590
|
-
res.on('error', removeFromClients);
|
|
706
|
+
await serveEventStream(sink, paramsFromParsedUrl(parsedUrl), deps);
|
|
591
707
|
}
|
|
592
708
|
|
|
593
709
|
// API endpoint
|
package/server/routes/resume.js
CHANGED
|
@@ -38,6 +38,8 @@
|
|
|
38
38
|
// The aggregation itself lives in lib/v2/resume-list.js (pure, unit-tested);
|
|
39
39
|
// this handler is a thin HTTP wrapper.
|
|
40
40
|
|
|
41
|
+
import { realpathSync } from 'node:fs';
|
|
42
|
+
import { join, dirname, basename } from 'node:path';
|
|
41
43
|
import { LOG_DIR } from '../../findcc.js';
|
|
42
44
|
import { _projectName, _v2Writer, initForWorkspace } from '../interceptor.js';
|
|
43
45
|
// Static import (not the route-local dynamic import) so the close-rebind helper
|
|
@@ -121,6 +123,30 @@ async function getLiveProcessesHandler(req, res, parsedUrl) {
|
|
|
121
123
|
});
|
|
122
124
|
}
|
|
123
125
|
} catch (err) { reportSwallowed('resume-route.live-main', err); }
|
|
126
|
+
// SDK mode (2026-10-12): SDK sessions are NOT PTYs, so listLivePtys() never contains them —
|
|
127
|
+
// without an entry the header's tab/chip source has no anchor for an SDK workspace, and the
|
|
128
|
+
// first launch's PTY-less list collapsed to just the new project. Enumerate the per-cwd SDK
|
|
129
|
+
// session registry (multi-session) as `kind:'main'` entries so each launched workspace shows
|
|
130
|
+
// up exactly like a PTY project. `active` marks the session the SDK composer is bound to;
|
|
131
|
+
// `closable:false` (no PTY to kill) tells the header to suppress the ×.
|
|
132
|
+
if (process.env.CCV_SDK_MODE === '1') {
|
|
133
|
+
try {
|
|
134
|
+
const sdk = await import('../lib/sdk-manager.js');
|
|
135
|
+
for (const e of sdk.listSdkSessions()) {
|
|
136
|
+
if (!e || !e.cwd) continue;
|
|
137
|
+
if (processes.some((p) => p && p.cwd === e.cwd)) continue; // a real PTY already covers this cwd
|
|
138
|
+
processes.push({
|
|
139
|
+
kind: 'main',
|
|
140
|
+
project: projectKeyForCwd(e.cwd),
|
|
141
|
+
cwd: e.cwd,
|
|
142
|
+
pid: null,
|
|
143
|
+
active: e.active === true,
|
|
144
|
+
instanceKey: null,
|
|
145
|
+
closable: false, // SDK resident query — killed via session switch, not the × close path
|
|
146
|
+
});
|
|
147
|
+
}
|
|
148
|
+
} catch (err) { reportSwallowed('resume-route.live-sdk', err); }
|
|
149
|
+
}
|
|
124
150
|
sendJson(res, 200, { processes, currentProject: _projectName || '', currentInstanceKey });
|
|
125
151
|
}
|
|
126
152
|
|
|
@@ -236,6 +262,55 @@ function postLiveProcessCloseHandler(req, res, parsedUrl, isLocal, deps) {
|
|
|
236
262
|
try {
|
|
237
263
|
rebound = _maybeRebindAfterClose(r, deps);
|
|
238
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); }
|
|
282
|
+
// Closing a project/instance must also reap its scratch (mini-terminal) shells, or
|
|
283
|
+
// the viewed-then-switched-away workspace's shells stay alive as orphans until a
|
|
284
|
+
// full workspacesStop/process exit. The killed record's original spawn cwd keys
|
|
285
|
+
// the scratch buckets; scratch cwds were realpathSync-normalized at the upgrade,
|
|
286
|
+
// so match BOTH path spaces — when the workspace dir was later deleted,
|
|
287
|
+
// realpathSync(raw) throws and the raw spawn cwd is the only way to still hit the
|
|
288
|
+
// (realpath-keyed) bucket. Skip the reap when a surviving live claude record shares
|
|
289
|
+
// the cwd (a deliberate same-cwd second instance): its viewers still use those
|
|
290
|
+
// shared scratch shells.
|
|
291
|
+
try {
|
|
292
|
+
if (r.cwd) {
|
|
293
|
+
// listLivePtys() already only yields LIVE records (ptyProcess non-null), so a
|
|
294
|
+
// cwd match here is a surviving instance sharing the dir.
|
|
295
|
+
const survivorSharesCwd = listLivePtys().some((p) => p && p.cwd === r.cwd);
|
|
296
|
+
if (!survivorSharesCwd) {
|
|
297
|
+
// Build the candidate set in BOTH path spaces (raw spawn cwd + realpath).
|
|
298
|
+
// A deleted workspace dir makes realpathSync(raw) throw — then fall back to
|
|
299
|
+
// realpath(dirname)+basename, which still resolves a deleted LEAF dir through
|
|
300
|
+
// macOS's /tmp→/private/tmp and /var→/private/var symlinks (the exact case
|
|
301
|
+
// where the realpath-keyed bucket would otherwise be missed and leak).
|
|
302
|
+
const cands = [r.cwd];
|
|
303
|
+
const add = (p) => { if (p && !cands.includes(p)) cands.push(p); };
|
|
304
|
+
try {
|
|
305
|
+
add(realpathSync(r.cwd));
|
|
306
|
+
} catch {
|
|
307
|
+
try { add(join(realpathSync(dirname(r.cwd)), basename(r.cwd))); } catch { /* give up */ }
|
|
308
|
+
}
|
|
309
|
+
const { killScratchForCwd } = await import('../scratch-pty-manager.js');
|
|
310
|
+
killScratchForCwd(cands);
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
} catch (err) { reportSwallowed('resume-route.close-kill-scratch', err); }
|
|
239
314
|
sendJson(res, 200, { ok: true, project, instanceKey: r.key || instanceKey || null, killedActive: r.killedActive === true, rebound });
|
|
240
315
|
} catch (err) {
|
|
241
316
|
reportSwallowed('resume-route.close', err);
|
|
@@ -415,7 +490,10 @@ function postResumeSessionHandler(req, res, parsedUrl, isLocal, deps) {
|
|
|
415
490
|
// Read the env at request time (not module top) so tests can flip it per case.
|
|
416
491
|
if (process.env.CCV_SDK_MODE === '1') {
|
|
417
492
|
const sdk = await import('../lib/sdk-manager.js');
|
|
418
|
-
|
|
493
|
+
// Multi-session (2026-10-12): honor the requested workspace — resume the VIEWED
|
|
494
|
+
// session, not always the active one. project is the workspace name from the client.
|
|
495
|
+
const anchor = (typeof project === 'string' && project) ? { project } : undefined;
|
|
496
|
+
const r = sdk.switchToSession(sessionUuid, anchor);
|
|
419
497
|
if (r.ok) { sendJson(res, 200, { ok: true }); return; }
|
|
420
498
|
if (r.reason === 'busy') { sendJson(res, 409, { ok: false, reason: 'busy' }); return; }
|
|
421
499
|
if (r.reason === 'unavailable') { sendJson(res, 409, { ok: false, reason: 'unavailable' }); return; }
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
// SDK-mode "Context usage" on-demand route. The panel opens/refreshes via this HTTP pull;
|
|
2
|
+
// the automatic turn-end/compact refreshes flow over the WS broadcast instead. A
|
|
3
|
+
// `session-inactive` outcome is a NORMAL state (no turn run yet / query dead), so it
|
|
4
|
+
// returns 200 with { ok:false, reason } rather than an HTTP error.
|
|
5
|
+
import { reportSwallowed } from '@ccv/core/error-report';
|
|
6
|
+
|
|
7
|
+
async function sdkContextUsage(req, res) {
|
|
8
|
+
try {
|
|
9
|
+
// Dynamic import: sdk-manager is only loaded in SDK mode (resume.js parity). The
|
|
10
|
+
// literal specifier satisfies the boundary verifier's R7 rule.
|
|
11
|
+
const sdk = await import('../lib/sdk-manager.js');
|
|
12
|
+
const r = await sdk.fetchContextUsage();
|
|
13
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
14
|
+
res.end(JSON.stringify(r.ok ? { ok: true, snapshot: r.snapshot } : { ok: false, reason: r.reason }));
|
|
15
|
+
} catch (err) {
|
|
16
|
+
reportSwallowed('sdk-context-route', err);
|
|
17
|
+
res.writeHead(500, { 'Content-Type': 'application/json' });
|
|
18
|
+
res.end(JSON.stringify({ error: String(err?.message || err) }));
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export const sdkContextRoutes = [
|
|
23
|
+
{ method: 'GET', match: 'exact', path: '/api/sdk-context-usage', handler: sdkContextUsage },
|
|
24
|
+
];
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
// SDK-mode "MCP server status" on-demand route. The panel opens/refreshes via this HTTP
|
|
2
|
+
// pull; there is no turn-driven refresh (MCP status is not turn-correlated). A
|
|
3
|
+
// `session-inactive` outcome is a NORMAL state (no turn run yet / query dead), so it
|
|
4
|
+
// returns 200 with { ok:false, reason } rather than an HTTP error.
|
|
5
|
+
import { reportSwallowed } from '@ccv/core/error-report';
|
|
6
|
+
|
|
7
|
+
async function sdkMcpStatus(req, res) {
|
|
8
|
+
try {
|
|
9
|
+
// Dynamic import: sdk-manager is only loaded in SDK mode (resume.js parity). The
|
|
10
|
+
// literal specifier satisfies the boundary verifier's R7 rule.
|
|
11
|
+
const sdk = await import('../lib/sdk-manager.js');
|
|
12
|
+
const r = await sdk.fetchMcpStatus();
|
|
13
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
14
|
+
res.end(JSON.stringify(r.ok ? { ok: true, snapshot: r.snapshot } : { ok: false, reason: r.reason }));
|
|
15
|
+
} catch (err) {
|
|
16
|
+
reportSwallowed('sdk-mcp-status-route', err);
|
|
17
|
+
res.writeHead(500, { 'Content-Type': 'application/json' });
|
|
18
|
+
res.end(JSON.stringify({ error: String(err?.message || err) }));
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export const sdkMcpStatusRoutes = [
|
|
23
|
+
{ method: 'GET', match: 'exact', path: '/api/sdk-mcp-server-status', handler: sdkMcpStatus },
|
|
24
|
+
];
|
|
@@ -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
|
-
|
|
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 {} });
|