cc-viewer 1.9.1 → 1.9.2

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.
@@ -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
@@ -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:
@@ -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
  }