cc-viewer 1.9.1 → 1.9.3

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.
@@ -6,6 +6,7 @@
6
6
  // and the swallowed ENOENT would silently drop v2 lines (plan risk F2).
7
7
 
8
8
  import { mkdirSync, writeFileSync, renameSync, existsSync, openSync, writeSync, fsyncSync, closeSync, readdirSync, statSync } from 'node:fs';
9
+ import { readdir as readdirAsync, stat as statAsync } from 'node:fs/promises';
9
10
  import { join, resolve } from 'node:path';
10
11
 
11
12
  // The wire-format version this build writes AND the only one it reads.
@@ -107,6 +108,30 @@ export function dirSizeSync(dir) {
107
108
  return total;
108
109
  }
109
110
 
111
+ /**
112
+ * Async sibling of dirSizeSync (2026-10-08): same semantics (recursive byte size,
113
+ * raced-deletion tolerant), but never blocks the event loop. Used by read-only,
114
+ * latency-sensitive paths (the /api/workspaces list) where freezing the whole
115
+ * Node process for ~2.4s while we walk 80k files is unacceptable — SSE streams
116
+ * and PTY output starve. The sync variant stays for offline converter / writer
117
+ * paths where blocking is fine (spawn-time, not request-time).
118
+ */
119
+ export async function dirSizeAsync(dir) {
120
+ let total = 0;
121
+ let entries;
122
+ try { entries = await readdirAsync(dir, { withFileTypes: true }); } catch { return total; }
123
+ // Sequential per directory (avoid fd exhaustion on wide trees); parallel across
124
+ // sibling directories would still serialize on disk I/O in practice.
125
+ for (const e of entries) {
126
+ const p = join(dir, e.name);
127
+ try {
128
+ if (e.isDirectory()) total += await dirSizeAsync(p);
129
+ else if (e.isFile()) total += (await statAsync(p)).size;
130
+ } catch { /* raced deletion — skip */ }
131
+ }
132
+ return total;
133
+ }
134
+
110
135
  export function convEpochPath(paths, convKey, epoch) {
111
136
  return join(paths.conversationsDir, sanitizePathComponent(convKey), `e${epoch}.jsonl`);
112
137
  }
@@ -1060,8 +1060,13 @@ export function killPty() {
1060
1060
  * same dead-state a natural exit produces). Records whose process already
1061
1061
  * exited are never re-anchor targets.
1062
1062
  *
1063
+ * The killed record's persistent `cwd` is captured BEFORE the kill and
1064
+ * returned so the close route can tell "the BOUND project was closed" apart
1065
+ * from "a parallel project was closed" by exact-cwd comparison against
1066
+ * CCV_PROJECT_DIR (never by basename — same-name twins would false-hit).
1067
+ *
1063
1068
  * @returns {{ ok:boolean, key?:string, killedActive?:boolean,
1064
- * reattachedTo?:string|null, reason?:string }}
1069
+ * reattachedTo?:string|null, cwd?:string, reason?:string }}
1065
1070
  */
1066
1071
  export function killPtyFor({ cwd, project, instanceKey } = {}) {
1067
1072
  // Multi-instance forced disambiguation (2026-10-06): a project-name-only close that matches
@@ -1076,6 +1081,10 @@ export function killPtyFor({ cwd, project, instanceKey } = {}) {
1076
1081
  if (!key) return { ok: false, reason: 'not-found' };
1077
1082
  const s = ptys.get(key);
1078
1083
  if (!s) return { ok: false, reason: 'not-found' };
1084
+ // Capture the record's ORIGINAL spawn cwd before the kill (never realpath'd —
1085
+ // both sides of the comparison stay in the same path space; a realpath would
1086
+ // split /tmp vs /private/tmp and miss the bound-cwd match).
1087
+ const killedCwd = s.cwd || s.currentWorkspacePath || s.lastWorkspacePath || '';
1079
1088
  _killPtyRecord(s);
1080
1089
  const killedActive = key === activePtyKey;
1081
1090
  let reattachedTo = null;
@@ -1085,7 +1094,7 @@ export function killPtyFor({ cwd, project, instanceKey } = {}) {
1085
1094
  }
1086
1095
  if (reattachedTo) activePtyKey = reattachedTo;
1087
1096
  }
1088
- return { ok: true, key, killedActive, reattachedTo };
1097
+ return { ok: true, key, killedActive, reattachedTo, cwd: killedCwd };
1089
1098
  }
1090
1099
 
1091
1100
  // Kill EVERY main PTY across all projects (workspaces stop / process teardown). The no-arg
@@ -318,13 +318,42 @@ async function events(req, res, parsedUrl, isLocal, deps) {
318
318
  // filter excludes legacy/other-instance dirs, so an empty result would otherwise show the
319
319
  // WRONG project's/instance's conversation). Tell the client explicitly (same posture as
320
320
  // sid-not-found) so it can surface an empty state instead of cross-project bleed.
321
- const instanceNotFound = !!(instanceParam && !sidParam && !viewColdDir);
322
- if (instanceNotFound) {
321
+ //
322
+ // Liveness-aware (2026-10-10): a NEWLY created workspace's claude is alive but has not
323
+ // sent its first request yet, so it legitimately has no session dir. Flagging that as an
324
+ // error made the client detach back to the bound project — and the cold load below then
325
+ // streamed the BOUND project's current session (cross-project bleed). A LIVE instance
326
+ // therefore gets an EMPTY view instead: the cold stream is pinned to the empty source
327
+ // (load_start{total:0} → load_end, no chunks), the client stays on this instance, and the
328
+ // per-project live feed streams its first turn in place the moment the PTY writes its
329
+ // first session dir. Only a DEAD instance keeps the explicit sid-not-found posture (the
330
+ // client toasts + auto-detaches to the bound view — unchanged from before this fix).
331
+ const instanceNoSession = !!(instanceParam && !sidParam && !viewColdDir);
332
+ let instanceAlive = false;
333
+ if (instanceNoSession) {
334
+ try {
335
+ instanceAlive = listLivePtys().some((p) => p && p.instanceKey === instanceParam);
336
+ } catch (err) {
337
+ // Lookup failure degrades to the explicit dead-instance posture (never
338
+ // silently blank the client on a lookup error).
339
+ instanceAlive = false;
340
+ reportSwallowed('events.instance-live', err, { instance: instanceParam });
341
+ }
342
+ }
343
+ if (instanceNoSession && !instanceAlive) {
323
344
  try { sseWrite(res, `event: sid-not-found\ndata: ${JSON.stringify({ sid: null, instance: instanceParam, reason: 'instance-no-session', ts: Date.now() })}\n\n`); } catch { }
324
345
  }
325
346
  // When a view-scoped dir was requested AND resolved, it replaces the live-source
326
347
  // cold load; otherwise fall back to the standard single-current-session source.
327
348
  const coldSource = viewColdDir || null;
349
+ // An instance with no session dir has NO legitimate cold source — pin EVERY cold
350
+ // read to the literal empty source, whether the instance is alive (empty live view:
351
+ // the per-project feed streams its first turn in place once written) or dead (the
352
+ // client detaches on the sid-not-found above; the cold frames would be dropped by
353
+ // its stale guard anyway). Both fallback sites below (`coldSource || getLiveLogSource()`,
354
+ // on the legacy wire AND the v3 wire) would otherwise resolve the BOUND project's
355
+ // current session — the exact cross-project bleed this guard exists to stop.
356
+ const emptyLiveView = instanceNoSession;
328
357
  // A client viewing a non-bound project needs that project's live feed running
329
358
  // (its PTY is alive but its feed is lazy). Start it idempotently; the feed's
330
359
  // per-project client filter keeps its entries scoped to this project's viewers.
@@ -352,7 +381,7 @@ async function events(req, res, parsedUrl, isLocal, deps) {
352
381
  // Wire v3 (V3.S5): flagged + v2 source ⇒ the legacy full-entry cold stream
353
382
  // is REPLACED by rows + native lines (the byte win); the client assembler
354
383
  // rebuilds entries locally. v1 legacy files keep the entry pipeline.
355
- const _v3Src = deps.wireV3 ? (coldSource || getLiveLogSource()) : null;
384
+ const _v3Src = (deps.wireV3 && !emptyLiveView) ? (coldSource || getLiveLogSource()) : null;
356
385
  const v3Cold = !!(_v3Src && existsSync(join(_v3Src, 'journal.jsonl')));
357
386
 
358
387
  // S6b: the cold-load source is the current v2 session dir when the v2
@@ -361,7 +390,7 @@ async function events(req, res, parsedUrl, isLocal, deps) {
361
390
  // in-flight placeholder, which the client batch gate still blocks — the
362
391
  // previous conversation is the better cold load there (interceptor.js
363
392
  // getLiveLogSource header comment).
364
- const coldLoadResult = v3Cold ? null : await streamRawEntriesAsync(coldSource || getLiveLogSource({ serveInFlight: !!deps.wireV3 }), async (raw) => {
393
+ const coldLoadResult = v3Cold ? null : await streamRawEntriesAsync(emptyLiveView ? '' : (coldSource || getLiveLogSource({ serveInFlight: !!deps.wireV3 })), async (raw) => {
365
394
  // 直接发送原始 JSON 字符串,不做 parse/reconstruct/stringify
366
395
  // ExitPlanMode V2 空 input 的条目按需补全 plan / planFilePath,其它原样透传
367
396
  if (res.destroyed || !res.writable) return;
@@ -406,6 +435,12 @@ async function events(req, res, parsedUrl, isLocal, deps) {
406
435
  // Pass 1 完成、Pass 2 开始前:发送 load_start
407
436
  // 增量模式下不显示 loading 遮罩,非增量模式显示进度
408
437
  const loadStartData = { total: totalCount, incremental: !!useIncremental };
438
+ // An instance with no session dir (new workspace / pruned while alive):
439
+ // flag the empty view so the client invalidates any view-cache snapshot it
440
+ // restored for this scope — otherwise an incremental `?since&cc=` resume
441
+ // would merge an empty delta onto the CACHED (now-deleted) conversation and
442
+ // keep rendering it. load_end fires right after with zero chunks.
443
+ if (emptyLiveView) loadStartData.empty = true;
409
444
  // 分页模式下附加 hasMore/oldestTs(增量模式由客户端从缓存自行判断)
410
445
  if (useLimit) {
411
446
  loadStartData.hasMore = !!hasMore;
@@ -438,6 +473,9 @@ async function events(req, res, parsedUrl, isLocal, deps) {
438
473
  for (const p of native.convPayloads) v3Bytes += p.length;
439
474
  for (const p of native.respPayloads) v3Bytes += p.length;
440
475
  const loadStartData = { total: meta.totalCount, incremental: !!useIncremental, v3Bytes };
476
+ // Same empty-view flag as the legacy onReady path above (unreachable on the
477
+ // v3 wire today — an empty source is never a v2 dir — but kept symmetric).
478
+ if (emptyLiveView) loadStartData.empty = true;
441
479
  if (useLimit) {
442
480
  loadStartData.hasMore = !!meta.hasMore;
443
481
  loadStartData.oldestTs = meta.oldestTimestamp || '';
@@ -497,8 +535,11 @@ async function events(req, res, parsedUrl, isLocal, deps) {
497
535
  sseWrite(res, `event: context_window\ndata: ${JSON.stringify(latestContextWindow)}\n\n`);
498
536
  pushedContextWindow = true;
499
537
  }
500
- // Fallback: no MainAgent in log (e.g. fresh session after -c), read context-window.json
501
- if (!pushedContextWindow) {
538
+ // Fallback: no MainAgent in log (e.g. fresh session after -c), read context-window.json.
539
+ // Skipped for an empty live view: CONTEXT_WINDOW_FILE is a single GLOBAL file (not
540
+ // project-scoped), so pushing it here would paint another project's usage onto a
541
+ // brand-new workspace. The first real main turn pushes a genuine measurement instead.
542
+ if (!pushedContextWindow && !emptyLiveView) {
502
543
  try {
503
544
  const cwRaw = readFileSync(CONTEXT_WINDOW_FILE, 'utf-8');
504
545
  const cwFile = JSON.parse(cwRaw);
@@ -17,7 +17,12 @@
17
17
  // POST /api/live-processes/close { project }
18
18
  // Kill that project's main PTY (the web header tab's × button). Admin-only
19
19
  // (loopback or authenticated remote admin); 403 otherwise, 404 when the
20
- // project has no record. The record is kept for a later re-launch.
20
+ // project has no record. The record is kept for a later re-launch. When the
21
+ // killed project WAS the bound one and a survivor is still live, the server
22
+ // re-binds its workspace identity (_projectName + CCV_PROJECT_DIR) to that
23
+ // survivor (a same-name/same-cwd twin degrades this to a dir-only rebind),
24
+ // broadcasts workspace_started(rebound:true) so every client lands on the
25
+ // survivor, and the response carries `rebound: { project, cwd }`.
21
26
  //
22
27
  // POST /api/resume-session { sessionUuid, project?, instanceKey? }
23
28
  // TRUE /resume (2026-10-06): inject `/resume <uuid>` into the target
@@ -34,11 +39,18 @@
34
39
  // this handler is a thin HTTP wrapper.
35
40
 
36
41
  import { LOG_DIR } from '../../findcc.js';
37
- import { _projectName, _v2Writer } from '../interceptor.js';
42
+ import { _projectName, _v2Writer, initForWorkspace } from '../interceptor.js';
43
+ // Static import (not the route-local dynamic import) so the close-rebind helper
44
+ // can call listLivePtys synchronously — a static import keeps the single shared
45
+ // module instance, and module state is shared either way.
46
+ import { listLivePtys } from '../pty-manager.js';
38
47
  import { listResumeSessions } from '../lib/v2/resume-list.js';
39
48
  import { projectKeyForCwd } from '../lib/system-prompt-snapshots.js';
49
+ import { readClaudeProjectModel } from '../lib/context-watcher.js';
50
+ import { bumpWorkspacesVersion } from '../lib/file-access-policy.js';
40
51
  import { isAdminReq } from '../lib/is-admin.js';
41
52
  import { isSameOriginBrowserRequest } from '../lib/same-origin.js';
53
+ import { sseWrite } from '../lib/wire-compress.js';
42
54
  import { reportSwallowed } from '@ccv/core/error-report';
43
55
 
44
56
  function sendJson(res, code, obj) {
@@ -166,8 +178,11 @@ function postLiveProcessAttachHandler(req, res, parsedUrl, isLocal, deps) {
166
178
  // possible with a mere view token). 404 when the project has no record. When
167
179
  // the killed PTY was the terminal's active attachment, the server re-anchors
168
180
  // the attachment to another live record internally (killedActive in the
169
- // response); the client decides its own view repair from the remaining tab
170
- // list (it does not need the re-anchor target).
181
+ // response). When the killed project WAS the bound one and a survivor is still
182
+ // live, the server ALSO re-binds its workspace identity (_projectName +
183
+ // CCV_PROJECT_DIR) to that survivor and broadcasts workspace_started(rebound:true)
184
+ // so every client lands on the survivor (the "当前项目" label would otherwise
185
+ // stay on the just-closed project); the response then carries `rebound`.
171
186
  function postLiveProcessCloseHandler(req, res, parsedUrl, isLocal, deps) {
172
187
  // Two gates, both required: admin identity (loopback or authenticated remote
173
188
  // admin) AND same-origin browser context. The server intentionally answers
@@ -210,7 +225,18 @@ function postLiveProcessCloseHandler(req, res, parsedUrl, isLocal, deps) {
210
225
  sendJson(res, 404, { ok: false, reason: r.reason || 'not-found' });
211
226
  return;
212
227
  }
213
- sendJson(res, 200, { ok: true, project, instanceKey: r.key || instanceKey || null, killedActive: r.killedActive === true });
228
+ // Bound-project rebound (2026-10): when the closed PTY WAS the server's bound
229
+ // project, the binding (projectName / CCV_PROJECT_DIR / log dir) would otherwise
230
+ // keep pointing at a dead project — the "当前项目" label stays on the just-closed
231
+ // name while the survivor is left as a mere chip. Rebind to a survivor and
232
+ // broadcast workspace_started so every client (including the closing tab) lands
233
+ // on the survivor. Judgment is by the killed record's ORIGINAL cwd vs
234
+ // CCV_PROJECT_DIR — never by basename, or a same-name twin close would false-hit.
235
+ let rebound = null;
236
+ try {
237
+ rebound = _maybeRebindAfterClose(r, deps);
238
+ } catch (err) { reportSwallowed('resume-route.close-rebind', err); }
239
+ sendJson(res, 200, { ok: true, project, instanceKey: r.key || instanceKey || null, killedActive: r.killedActive === true, rebound });
214
240
  } catch (err) {
215
241
  reportSwallowed('resume-route.close', err);
216
242
  sendJson(res, 500, { ok: false, reason: 'close-failed' });
@@ -218,6 +244,135 @@ function postLiveProcessCloseHandler(req, res, parsedUrl, isLocal, deps) {
218
244
  });
219
245
  }
220
246
 
247
+ // Bound-project rebound helper (2026-10). After killPtyFor removed the BOUND
248
+ // project's PTY, pick a surviving live claude record and re-bind the server's
249
+ // workspace identity to it. A full rebind additionally broadcasts
250
+ // workspace_started (with `rebound:true` / `reboundFrom`) so the affected SSE
251
+ // clients re-scope to the survivor; a DIR-ONLY rebind (a same-name twin owns
252
+ // the name's session space) moves CCV_PROJECT_DIR without broadcasting.
253
+ // Returns { project, cwd } when a rebind happened, else null (closed project
254
+ // was not the bound one / no survivor / a same-cwd twin still lives / Electron
255
+ // multi-tab).
256
+ // May throw only BEFORE the rebind is applied (a pre-state-change failure →
257
+ // the caller wraps it into a 200 with rebound:null, which is accurate then);
258
+ // after the state change it never throws (the restart side-effects are each
259
+ // wrapped), so a returned rebound always matches reality.
260
+ //
261
+ // ORDERING INVARIANT: the broadcast (full rebind only) is emitted BEFORE the
262
+ // best-effort feed/timer restarts, and the whole kill→rebind→broadcast block
263
+ // is synchronous (no await) — under Node's single thread no interleaved
264
+ // request can observe a half-applied rebind, and a throwing side-effect can no
265
+ // longer strand the "binding moved but nobody was told" state (the broadcast
266
+ // has already gone out by then).
267
+ function _maybeRebindAfterClose(killResult, deps) {
268
+ // Electron multi-tab: the manager process does NOT own log init (the tab
269
+ // workers do); mirroring workspaces.js:56, skip rebind entirely there.
270
+ if (process.env.CCV_ELECTRON_MULTITAB === '1') return null;
271
+ const killedCwd = typeof killResult.cwd === 'string' ? killResult.cwd : '';
272
+ const boundCwd = process.env.CCV_PROJECT_DIR || process.cwd();
273
+ // Exact-cwd equality, no realpath — both sides stay in the same path space
274
+ // (a realpath would split /tmp vs /private/tmp and miss the hit).
275
+ if (!killedCwd || killedCwd !== boundCwd) return null;
276
+
277
+ // Pick the survivor: a still-live CLAUDE record (listLivePtys also returns
278
+ // 'shell' scratch PTYs, which /api/live-processes itself filters out of its
279
+ // own processes[]), preferring the record killPtyFor just re-anchored the
280
+ // terminal attachment to (reattachedTo), then the terminal's live attachment
281
+ // (isActive — preserves the launch-time invariant bound == attached), else
282
+ // the first live claude record in Map order.
283
+ const live = listLivePtys().filter((p) => p && p.cwd && (!p.ptyKind || p.ptyKind === 'claude'));
284
+ if (!live.length) return null;
285
+ // A same-cwd TWIN of the closed bound project still lives → the bound
286
+ // identity (same project name, same log dir) is already correct for it;
287
+ // rebinding away would only wipe that twin's session bindings for nothing.
288
+ if (live.some((p) => p.cwd === killedCwd)) return null;
289
+ const survivor = live.find((p) => p.key === killResult.reattachedTo)
290
+ || live.find((p) => p.isActive)
291
+ || live[0];
292
+ if (!survivor || !survivor.cwd) return null;
293
+
294
+ const killedName = projectKeyForCwd(killedCwd);
295
+ const survivorProject = projectKeyForCwd(survivor.cwd);
296
+ // initForWorkspace's scoped resetSessions keys on the SANITIZED project name
297
+ // (v2-writer.js). So any time the CLOSED project's name still has a live
298
+ // record (a same-name twin in another dir, or the survivor itself is a
299
+ // same-name twin), running initForWorkspace would wipe that LIVE project's
300
+ // session bindings. In every such case the project NAME (and thus the log
301
+ // dir) does not change — so do a DIR-ONLY rebind instead: point
302
+ // CCV_PROJECT_DIR at the survivor (file-tree/git/skills/allowlist roots follow
303
+ // the live project) while skipping initForWorkspace entirely.
304
+ const closedNameStillLive = live.some((p) => projectKeyForCwd(p.cwd) === killedName);
305
+ const survivorIsSameName = survivorProject === killedName;
306
+ const dirOnly = closedNameStillLive || survivorIsSameName;
307
+
308
+ let result;
309
+ if (dirOnly) {
310
+ // _projectName / _logDir stay (same sanitized name — already correct);
311
+ // only the bound ROOT moves to the surviving twin's directory. No broadcast
312
+ // either (see below): the name — and with it every client's SSE scope stamp
313
+ // — is unchanged, so there is nothing to re-scope.
314
+ result = { projectName: killedName };
315
+ } else {
316
+ result = initForWorkspace(survivor.cwd);
317
+ }
318
+ process.env.CCV_PROJECT_DIR = survivor.cwd;
319
+ // The file-access allowlist caches its roots (including the bound project
320
+ // dir) and is only invalidated on register/remove or LOG_DIR change — none of
321
+ // which fire on a close-rebind. Bump it so the survivor's dir is an allowed
322
+ // root even when it never went through this server's registerWorkspace.
323
+ try { bumpWorkspacesVersion(); } catch (err) { reportSwallowed('resume-route.close-rebind.roots', err); }
324
+
325
+ // Broadcast workspace_started to EVERY SSE client (same fan-out as launch,
326
+ // workspaces.js:101-107) BEFORE the throwable side-effects below, so a
327
+ // failure there can't leave "binding moved but clients untold" — but ONLY for
328
+ // a full rebind. A dir-only rebind keeps the project NAME — and with it every
329
+ // client's SSE scope stamp — moving only the bound ROOT to the survivor's dir,
330
+ // so there is nothing for any client to re-scope: a broadcast would needlessly
331
+ // hard-reset every same-name viewer mid-stream for a root-only change.
332
+ // Deliberately NO load_start/load_chunk/load_end replay: those handlers have
333
+ // no project guard on the client (AppBase.jsx:1990-2148) and would pour the
334
+ // survivor's transcript into a tab that is viewing a THIRD project. Each
335
+ // AFFECTED client re-scopes and cold-loads its own view on receipt (the
336
+ // `rebound:true` marker + its own local view state decide; see the
337
+ // workspace_started handler in AppBase.jsx).
338
+ if (!dirOnly) {
339
+ const payload = `event: workspace_started\ndata: ${JSON.stringify({
340
+ projectName: result.projectName,
341
+ path: survivor.cwd,
342
+ claudeProjectModel: readClaudeProjectModel(survivor.cwd),
343
+ rebound: true,
344
+ // The project name this client's SSE scope was stamped with before the
345
+ // rebind. Clients whose SSE (or an attach/parallel view) is still pinned
346
+ // to it are the affected domain and must reset + reconnect; a tab viewing
347
+ // an unrelated project must keep its view and its live feed.
348
+ reboundFrom: killedName,
349
+ })}\n\n`;
350
+ const clients = (deps && deps.clients) || [];
351
+ clients.forEach((client) => {
352
+ try { sseWrite(client, payload); } catch (err) { reportSwallowed('resume-route.close-rebind.write', err); }
353
+ });
354
+ }
355
+
356
+ // Best-effort feed/timer restarts for the new bound project. Without
357
+ // startLogWatch the survivor's writer activity hits "no feed → drop" and the
358
+ // real-time pane never updates until a manual refresh; statsWorker/
359
+ // streamingStatusTimer mirror workspaces.js:71-75. All deps.* are
360
+ // optional-chained so unit tests need only stub what they assert on. Each is
361
+ // individually wrapped AND runs AFTER the broadcast: if one throws it is
362
+ // reported and swallowed, never turning the (already-applied) rebind into a
363
+ // misleading rebound:null on the 200 response.
364
+ if (deps && typeof deps.startLogWatch === 'function') {
365
+ try { deps.startLogWatch(); } catch (err) { reportSwallowed('resume-route.close-rebind.logwatch', err); }
366
+ }
367
+ if (deps && !deps.statsWorker && typeof deps.startStatsWorker === 'function') {
368
+ try { deps.startStatsWorker(); } catch (err) { reportSwallowed('resume-route.close-rebind.stats', err); }
369
+ }
370
+ if (deps && typeof deps.startStreamingStatusTimer === 'function') {
371
+ try { deps.startStreamingStatusTimer(); } catch (err) { reportSwallowed('resume-route.close-rebind.stream', err); }
372
+ }
373
+ return { project: result.projectName, cwd: survivor.cwd };
374
+ }
375
+
221
376
  const RESUME_UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
222
377
 
223
378
  // POST /api/resume-session { sessionUuid, project?, instanceKey? } — TRUE /resume:
@@ -255,6 +410,18 @@ function postResumeSessionHandler(req, res, parsedUrl, isLocal, deps) {
255
410
  return;
256
411
  }
257
412
  try {
413
+ // SDK mode: there is no claude PTY to inject `/resume` into — the sdk-manager
414
+ // switches the session identity directly (next user message resumes it).
415
+ // Read the env at request time (not module top) so tests can flip it per case.
416
+ if (process.env.CCV_SDK_MODE === '1') {
417
+ const sdk = await import('../lib/sdk-manager.js');
418
+ const r = sdk.switchToSession(sessionUuid);
419
+ if (r.ok) { sendJson(res, 200, { ok: true }); return; }
420
+ if (r.reason === 'busy') { sendJson(res, 409, { ok: false, reason: 'busy' }); return; }
421
+ if (r.reason === 'unavailable') { sendJson(res, 409, { ok: false, reason: 'unavailable' }); return; }
422
+ sendJson(res, 400, { ok: false, reason: r.reason || 'bad-uuid' });
423
+ return;
424
+ }
258
425
  const pm = await import('../pty-manager.js');
259
426
  const targetProject = project || (_projectName || '');
260
427
  // Multi-instance: a project-name-only resume that matches 2+ live same-basename
@@ -21,9 +21,15 @@ import { sseWrite } from '../lib/wire-compress.js';
21
21
 
22
22
  function workspacesList(req, res, parsedUrl, isLocal, deps) {
23
23
  import('../workspace-registry.js').then(async ({ getWorkspaces }) => {
24
- const workspaces = await getWorkspaces();
24
+ // ?limit=N(2026-10-08):parsedUrl 是 WHATWG URL,只有 .searchParams 没有 .query。
25
+ // 先排序后富化,只算前 N 个的 dirSize/isDiscardable。默认 0(全部),上限 50
26
+ // 与 resume.js 的对齐。total 是富化前总数,预留给将来「按需加载」的前端。
27
+ const rawLimit = parsedUrl?.searchParams?.get('limit');
28
+ const parsedLimit = rawLimit != null ? Number(rawLimit) : 0;
29
+ const limit = Number.isFinite(parsedLimit) && parsedLimit > 0 ? Math.min(Math.floor(parsedLimit), 50) : 0;
30
+ const { workspaces, total } = await getWorkspaces({ limit });
25
31
  res.writeHead(200, { 'Content-Type': 'application/json' });
26
- res.end(JSON.stringify({ workspaces, workspaceMode: deps.isWorkspaceMode && !deps.workspaceLaunched }));
32
+ res.end(JSON.stringify({ workspaces, total, workspaceMode: deps.isWorkspaceMode && !deps.workspaceLaunched }));
27
33
  }).catch(err => {
28
34
  res.writeHead(500, { 'Content-Type': 'application/json' });
29
35
  res.end(JSON.stringify({ error: err.message }));
@@ -1,13 +1,19 @@
1
1
  // Workspace Registry - 工作区持久化管理
2
- import { readdir, stat } from 'node:fs/promises';
2
+ import { readdir, stat, readFile } from 'node:fs/promises';
3
3
  import { statSync } from 'node:fs';
4
4
  import { mutateJson, readJsonSafe, writeJsonAtomic } from './lib/json-store.js';
5
- import { dirSizeSync } from './lib/v2/layout.js';
5
+ import { dirSizeAsync } from './lib/v2/layout.js';
6
6
  import { isDiscardableSession } from './lib/v2/session-select.js';
7
7
  import { join, basename, resolve, dirname } from 'node:path';
8
8
  import { randomBytes } from 'node:crypto';
9
9
  import { LOG_DIR } from '../findcc.js';
10
10
 
11
+ // 与 stats-worker.js 的磁盘缓存对齐(2026-10-08):getWorkspaces 富化时优先读
12
+ // LOG_DIR/<project>/<project>.json 的 files["sessions/<sid>"],以 journalSize+lastModified
13
+ // 作新鲜度 key;命中即跳过 dirSizeSync。版本不一致或 key 缺失则回退到实时遍历。
14
+ // 版本号必须与 stats-worker.js 的 STATS_VERSION 保持一致,否则旧缓存被错误信任。
15
+ const STATS_CACHE_VERSION = 12;
16
+
11
17
  // 动态获取(LOG_DIR 可能在运行时被 setLogDir 修改)
12
18
  function getWorkspacesFile() { return join(LOG_DIR, 'workspaces.json'); }
13
19
 
@@ -97,45 +103,108 @@ export function saveWorkspaces(list) {
97
103
  }
98
104
  }
99
105
 
100
- export async function getWorkspaces() {
106
+ export async function getWorkspaces(opts = {}) {
107
+ const limit = Number.isFinite(opts.limit) && opts.limit > 0 ? Math.floor(opts.limit) : null;
101
108
  const list = loadWorkspaces();
102
- const enriched = await Promise.all(list.map(async (w) => {
103
- // wire-v2 (1.7.0): logs live in per-session dirs under sessions/. The v1
104
- // *.jsonl glob is kept as unmigratedV1Count — the combined logCount must
105
- // stay non-zero for a declined-migration workspace, because the launcher's
106
- // `logCount > 0 → auto -c` heuristic hangs off it (WorkspaceList.jsx).
107
- let sessionCount = 0;
108
- let unmigratedV1Count = 0;
109
- let totalSize = 0;
110
- const logDir = join(LOG_DIR, w.projectName);
111
- try {
112
- const files = await readdir(logDir);
113
- for (const f of files) {
114
- if (f.endsWith('.jsonl') && !f.endsWith('_temp.jsonl')) {
115
- unmigratedV1Count++;
116
- try { totalSize += (await stat(join(logDir, f))).size; } catch { }
117
- }
109
+ // 先按 lastUsed 降序(几乎免费,只是 8.5 KB JSON 的内存排序),再切片、再富化。
110
+ // 不调换顺序的话:全量富化 40 个工作区 ≈ 3.3s;先切片后富化,limit=5 ≈ 200ms。
111
+ const sorted = list.slice().sort((a, b) => new Date(b.lastUsed) - new Date(a.lastUsed));
112
+ const total = sorted.length;
113
+ const toEnrich = limit ? sorted.slice(0, limit) : sorted;
114
+ // 一次性读出涉及项目的 stats 磁盘缓存(每个项目一次读盘,富化期全部命中内存)。
115
+ // Map + Promise.all 的 has/set 不防重:同一 projectName 的多个 entry 在 await 之前都通过 has 检查,
116
+ // 导致重复读盘。先 Set 去重再读。
117
+ const uniqueProjects = [...new Set(toEnrich.map((w) => w.projectName))];
118
+ const cacheByProject = new Map();
119
+ await Promise.all(uniqueProjects.map(async (projectName) => {
120
+ cacheByProject.set(projectName, await _readStatsCache(projectName));
121
+ }));
122
+ const enriched = await Promise.all(toEnrich.map((w) => _enrichWorkspace(w, cacheByProject.get(w.projectName))));
123
+ return { workspaces: enriched, total };
124
+ }
125
+
126
+ /**
127
+ * 读一个工作区的 stats 磁盘缓存(若存在且版本匹配)。
128
+ * 缓存 schema:LOG_DIR/<project>/<project>.json 的 files["sessions/<sid>"] = {
129
+ * size, journalSize, lastModified, ...
130
+ * },以 (journalSize, lastModified) 作新鲜度 key —— session 文件一变动,journal.jsonl
131
+ * 的 size/mtime 就变,缓存自然 miss 走实时遍历。
132
+ * 返回 files 对象;失败/版本不匹配返回 null(调用方整体回退到实时遍历)。
133
+ */
134
+ async function _readStatsCache(projectName) {
135
+ // 防御:projectName 来自 workspaces.json(用户可手改),registerWorkspace 写入时只保证
136
+ // basename 级别消毒。若 workspaces.json 被改成包含 '/' / '\\' / '..' 的 projectName,
137
+ // 这里的 join 会越出 LOG_DIR。命中即拒,回退到实时遍历(性能降级但安全)。
138
+ if (typeof projectName !== 'string'
139
+ || projectName.includes('/')
140
+ || projectName.includes('\\')
141
+ || projectName.includes('..')) {
142
+ return null;
143
+ }
144
+ try {
145
+ const statsFile = join(LOG_DIR, projectName, `${projectName}.json`);
146
+ const raw = await readFile(statsFile, 'utf-8');
147
+ const parsed = JSON.parse(raw);
148
+ if (!parsed || parsed._v !== STATS_CACHE_VERSION || !parsed.files || typeof parsed.files !== 'object') return null;
149
+ return parsed.files;
150
+ } catch {
151
+ return null; // 缓存不存在/损坏都算 miss,调用方走实时路径
152
+ }
153
+ }
154
+
155
+ /**
156
+ * 富化单个工作区的日志统计。命中 stats 缓存的 session 直接用缓存的 size;
157
+ * 未命中的 session 用 dirSizeAsync 异步递归(不阻塞事件循环,SSE/PTY 不再被冻结)。
158
+ * isDiscardableSession 仍是同步调用 —— 它只读 meta.json + 扫 journal 头几行,
159
+ * 远比 dirSizeSync 便宜;完全异步化留作后续。
160
+ */
161
+ async function _enrichWorkspace(w, statsCache) {
162
+ // wire-v2 (1.7.0): logs live in per-session dirs under sessions/. The v1
163
+ // *.jsonl glob is kept as unmigratedV1Count — the combined logCount must
164
+ // stay non-zero for a declined-migration workspace, because the launcher's
165
+ // `logCount > 0 → auto -c` heuristic hangs off it (WorkspaceList.jsx).
166
+ let sessionCount = 0;
167
+ let unmigratedV1Count = 0;
168
+ let totalSize = 0;
169
+ const logDir = join(LOG_DIR, w.projectName);
170
+ try {
171
+ const files = await readdir(logDir);
172
+ for (const f of files) {
173
+ if (f.endsWith('.jsonl') && !f.endsWith('_temp.jsonl')) {
174
+ unmigratedV1Count++;
175
+ try { totalSize += (await stat(join(logDir, f))).size; } catch { }
118
176
  }
119
- } catch { }
120
- try {
121
- const sids = await readdir(join(logDir, 'sessions'), { withFileTypes: true });
122
- for (const e of sids) {
123
- if (!e.isDirectory()) continue;
124
- const sessionDir = join(logDir, 'sessions', e.name);
125
- try {
126
- await stat(join(sessionDir, 'journal.jsonl'));
127
- // Quota-probe orphans must not count: logCount>0 drives the
128
- // auto -c heuristic, and a probe-only workspace would auto-continue
129
- // into a conversation that does not exist (2026-07-16).
130
- if (isDiscardableSession(sessionDir)) continue;
131
- // Folder size, not journal size — conv/blob/response files carry
132
- // most of a session's bytes (journal alone undercounts ~12x).
133
- totalSize += dirSizeSync(sessionDir);
177
+ }
178
+ } catch { }
179
+ try {
180
+ const sids = await readdir(join(logDir, 'sessions'), { withFileTypes: true });
181
+ for (const e of sids) {
182
+ if (!e.isDirectory()) continue;
183
+ const sessionDir = join(logDir, 'sessions', e.name);
184
+ const unitKey = `sessions/${e.name}`;
185
+ try {
186
+ const journalStat = await stat(join(sessionDir, 'journal.jsonl'));
187
+ // 缓存命中条件与 stats-worker 相同:journalSize + lastModified 双 key。
188
+ // 命中即非 discardable(stats-worker v11+ 的缓存条目写入前已过滤 probe-only session),
189
+ // 所以命中路径可以跳过 isDiscardableSession 的同步读 —— 省 ~200ms(实测 1787 session 的
190
+ // 热路径成本)。未命中再走原顺序:先判 probe,再递归 dirSizeAsync。
191
+ const cached = statsCache?.[unitKey];
192
+ const lastModified = journalStat.mtime.toISOString();
193
+ if (cached && cached.journalSize === journalStat.size && cached.lastModified === lastModified && typeof cached.size === 'number') {
194
+ totalSize += cached.size;
134
195
  sessionCount++;
135
- } catch { /* dir without a journal is not a session yet */ }
136
- }
137
- } catch { }
138
- return { ...w, logCount: sessionCount + unmigratedV1Count, sessionCount, unmigratedV1Count, totalSize };
139
- }));
140
- return enriched.sort((a, b) => new Date(b.lastUsed) - new Date(a.lastUsed));
196
+ continue;
197
+ }
198
+ // Quota-probe orphans must not count: logCount>0 drives the
199
+ // auto -c heuristic, and a probe-only workspace would auto-continue
200
+ // into a conversation that does not exist (2026-07-16).
201
+ if (isDiscardableSession(sessionDir)) continue;
202
+ // Folder size, not journal size — conv/blob/response files carry
203
+ // most of a session's bytes (journal alone undercounts ~12x).
204
+ totalSize += await dirSizeAsync(sessionDir);
205
+ sessionCount++;
206
+ } catch { /* dir without a journal is not a session yet */ }
207
+ }
208
+ } catch { }
209
+ return { ...w, logCount: sessionCount + unmigratedV1Count, sessionCount, unmigratedV1Count, totalSize };
141
210
  }