@zzusp/ccsm 1.0.1 → 1.0.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.
Files changed (70) hide show
  1. package/LICENSE +21 -21
  2. package/README.md +236 -236
  3. package/bin/cli.mjs +52 -52
  4. package/dist/assets/{DiskUsage-CKhggLs5.js → DiskUsage-BY6XwffG.js} +2 -2
  5. package/dist/assets/DiskUsage-BY6XwffG.js.map +1 -0
  6. package/dist/assets/{ImportPage-wge4VhZ-.js → ImportPage-Cwq5bx7G.js} +2 -2
  7. package/dist/assets/ImportPage-Cwq5bx7G.js.map +1 -0
  8. package/dist/assets/MarkdownContent-BFu7Nkk_.js +2 -0
  9. package/dist/assets/MarkdownContent-BFu7Nkk_.js.map +1 -0
  10. package/dist/assets/{ProjectMemory-Q4XX40j_.js → ProjectMemory-CcE3KbUK.js} +2 -2
  11. package/dist/assets/ProjectMemory-CcE3KbUK.js.map +1 -0
  12. package/dist/assets/index-CrWxV6sb.css +1 -0
  13. package/dist/assets/index-DTbWl1jb.js +11 -0
  14. package/dist/assets/index-DTbWl1jb.js.map +1 -0
  15. package/dist/assets/markdown-Bag5rX3T.js +30 -0
  16. package/dist/assets/markdown-Bag5rX3T.js.map +1 -0
  17. package/dist/index.html +26 -26
  18. package/package.json +85 -83
  19. package/server/index.ts +130 -130
  20. package/server/lib/active-sessions.test.ts +119 -119
  21. package/server/lib/active-sessions.ts +95 -95
  22. package/server/lib/bundle.test.ts +182 -182
  23. package/server/lib/bundle.ts +86 -86
  24. package/server/lib/claude-paths.test.ts +126 -126
  25. package/server/lib/claude-paths.ts +43 -43
  26. package/server/lib/cleanup-suggestions.ts +131 -131
  27. package/server/lib/constants.ts +8 -8
  28. package/server/lib/delete-project.ts +100 -100
  29. package/server/lib/delete.test.ts +244 -244
  30. package/server/lib/delete.ts +192 -192
  31. package/server/lib/disk-usage.ts +81 -81
  32. package/server/lib/encode-cwd.ts +24 -24
  33. package/server/lib/export-bundle.ts +236 -236
  34. package/server/lib/export-import-bundle.test.ts +337 -337
  35. package/server/lib/fs-size.ts +38 -38
  36. package/server/lib/import-bundle.ts +488 -488
  37. package/server/lib/load-memory.ts +120 -120
  38. package/server/lib/load-session.ts +209 -209
  39. package/server/lib/modified-files.test.ts +280 -280
  40. package/server/lib/modified-files.ts +228 -228
  41. package/server/lib/open-folder.ts +47 -47
  42. package/server/lib/parse-jsonl.ts +160 -139
  43. package/server/lib/port.ts +23 -23
  44. package/server/lib/safe-id.test.ts +41 -41
  45. package/server/lib/safe-id.ts +6 -6
  46. package/server/lib/safe-remove.test.ts +73 -73
  47. package/server/lib/safe-remove.ts +25 -25
  48. package/server/lib/scan.ts +289 -286
  49. package/server/lib/search-all.ts +130 -130
  50. package/server/lib/search-session.ts +203 -203
  51. package/server/lib/system-tags.ts +20 -20
  52. package/server/lib/update.ts +67 -67
  53. package/server/lib/version.test.ts +39 -39
  54. package/server/lib/version.ts +117 -117
  55. package/server/routes/disk-cleanup.ts +54 -54
  56. package/server/routes/disk.ts +9 -9
  57. package/server/routes/import.ts +87 -87
  58. package/server/routes/projects.ts +104 -104
  59. package/server/routes/search.ts +79 -79
  60. package/server/routes/sessions.ts +130 -130
  61. package/server/routes/version.ts +34 -34
  62. package/server/types.ts +1 -1
  63. package/shared/constants.ts +7 -7
  64. package/shared/types.ts +513 -511
  65. package/dist/assets/DiskUsage-CKhggLs5.js.map +0 -1
  66. package/dist/assets/ImportPage-wge4VhZ-.js.map +0 -1
  67. package/dist/assets/ProjectMemory-Q4XX40j_.js.map +0 -1
  68. package/dist/assets/index-7aMrnHJG.js +0 -7
  69. package/dist/assets/index-7aMrnHJG.js.map +0 -1
  70. package/dist/assets/index-BOeI_J4B.css +0 -1
@@ -1,286 +1,289 @@
1
- import fs from 'node:fs';
2
- import path from 'node:path';
3
- import { PATHS } from './claude-paths.ts';
4
- import { RECENT_ACTIVITY_WINDOW_MS } from './constants.ts';
5
- import { decodeCwd } from './encode-cwd.ts';
6
- import { dirSize, fileSize } from './fs-size.ts';
7
- import { parseJsonlMeta } from './parse-jsonl.ts';
8
- import { buildActiveSessionMap } from './active-sessions.ts';
9
- import type { ProjectSummary, RelatedBytes, SessionSummary } from '../types.ts';
10
-
11
- const JSONL_EXT = '.jsonl';
12
-
13
- function listSessionIdsInProject(projectDir: string): string[] {
14
- if (!fs.existsSync(projectDir)) return [];
15
- const ids: string[] = [];
16
- for (const ent of fs.readdirSync(projectDir, { withFileTypes: true })) {
17
- if (ent.isFile() && ent.name.endsWith(JSONL_EXT)) {
18
- ids.push(ent.name.slice(0, -JSONL_EXT.length));
19
- }
20
- }
21
- return ids;
22
- }
23
-
24
- function decodeProjectId(encoded: string, sampleCwd: string | null): {
25
- decoded: string;
26
- resolved: boolean;
27
- } {
28
- if (sampleCwd) return { decoded: sampleCwd, resolved: true };
29
- const decoded = decodeCwd(encoded);
30
- let resolved = false;
31
- try {
32
- resolved = fs.statSync(decoded).isDirectory();
33
- } catch {
34
- resolved = false;
35
- }
36
- return { decoded, resolved };
37
- }
38
-
39
- export async function resolveProjectCwd(
40
- projectId: string,
41
- ): Promise<{ decoded: string; resolved: boolean } | null> {
42
- const projectDir = path.join(PATHS.projects, projectId);
43
- if (!fs.existsSync(projectDir)) return null;
44
-
45
- const sessionIds = listSessionIdsInProject(projectDir);
46
- let sampleCwd: string | null = null;
47
- for (const id of sessionIds) {
48
- const jsonlPath = path.join(projectDir, `${id}${JSONL_EXT}`);
49
- if (!fs.existsSync(jsonlPath)) continue;
50
- const meta = await parseJsonlMeta(jsonlPath);
51
- if (meta.cwdFromMessages) {
52
- sampleCwd = meta.cwdFromMessages;
53
- break;
54
- }
55
- }
56
- return decodeProjectId(projectId, sampleCwd);
57
- }
58
-
59
- export async function listProjects(): Promise<ProjectSummary[]> {
60
- if (!fs.existsSync(PATHS.projects)) return [];
61
- const result: ProjectSummary[] = [];
62
-
63
- for (const ent of fs.readdirSync(PATHS.projects, { withFileTypes: true })) {
64
- if (!ent.isDirectory()) continue;
65
- const projectId = ent.name;
66
- const projectDir = path.join(PATHS.projects, projectId);
67
-
68
- const sessionIds = listSessionIdsInProject(projectDir);
69
- let sampleCwd: string | null = null;
70
- let totalBytes = 0;
71
- let lastActiveAt: string | null = null;
72
-
73
- for (const id of sessionIds) {
74
- const jsonlPath = path.join(projectDir, `${id}${JSONL_EXT}`);
75
- const subdirPath = path.join(projectDir, id);
76
- totalBytes += fileSize(jsonlPath);
77
- totalBytes += dirSize(subdirPath);
78
- totalBytes += dirSize(path.join(PATHS.fileHistory, id));
79
- totalBytes += dirSize(path.join(PATHS.sessionEnv, id));
80
-
81
- if (!sampleCwd && fs.existsSync(jsonlPath)) {
82
- const meta = await parseJsonlMeta(jsonlPath);
83
- sampleCwd = meta.cwdFromMessages;
84
- if (meta.lastAt && (!lastActiveAt || meta.lastAt > lastActiveAt)) {
85
- lastActiveAt = meta.lastAt;
86
- }
87
- } else if (fs.existsSync(jsonlPath)) {
88
- try {
89
- const mtime = fs.statSync(jsonlPath).mtime.toISOString();
90
- if (!lastActiveAt || mtime > lastActiveAt) lastActiveAt = mtime;
91
- } catch {
92
- // ignore
93
- }
94
- }
95
- }
96
-
97
- const { decoded, resolved } = decodeProjectId(projectId, sampleCwd);
98
-
99
- result.push({
100
- id: projectId,
101
- encodedCwd: projectId,
102
- decodedCwd: decoded,
103
- cwdResolved: resolved,
104
- sessionCount: sessionIds.length,
105
- totalBytes,
106
- lastActiveAt,
107
- });
108
- }
109
-
110
- result.sort((a, b) => {
111
- const at = a.lastActiveAt ?? '';
112
- const bt = b.lastActiveAt ?? '';
113
- return bt.localeCompare(at);
114
- });
115
- return result;
116
- }
117
-
118
- export async function listSessionsForProject(projectId: string): Promise<SessionSummary[]> {
119
- const projectDir = path.join(PATHS.projects, projectId);
120
- if (!fs.existsSync(projectDir)) return [];
121
-
122
- const activeMap = buildActiveSessionMap();
123
- const ids = listSessionIdsInProject(projectDir);
124
- const out: SessionSummary[] = [];
125
-
126
- for (const id of ids) {
127
- const jsonlPath = path.join(projectDir, `${id}${JSONL_EXT}`);
128
- const subdirPath = path.join(projectDir, id);
129
- const fhPath = path.join(PATHS.fileHistory, id);
130
- const sePath = path.join(PATHS.sessionEnv, id);
131
-
132
- const related: RelatedBytes = {
133
- jsonl: fileSize(jsonlPath),
134
- subdir: dirSize(subdirPath),
135
- fileHistory: dirSize(fhPath),
136
- sessionEnv: dirSize(sePath),
137
- };
138
-
139
- let title = '(no jsonl)';
140
- let customTitle: string | null = null;
141
- let firstAt: string | null = null;
142
- let lastAt: string | null = null;
143
- let messageCount = 0;
144
- let lastTurnIncomplete = false;
145
-
146
- if (fs.existsSync(jsonlPath)) {
147
- const meta = await parseJsonlMeta(jsonlPath);
148
- title = meta.title;
149
- customTitle = meta.customTitle;
150
- firstAt = meta.firstAt;
151
- lastAt = meta.lastAt;
152
- messageCount = meta.messageCount;
153
- lastTurnIncomplete = meta.lastTurnIncomplete;
154
- }
155
-
156
- const livePid = activeMap.get(id) ?? null;
157
- let isRecentlyActive = false;
158
- if (fs.existsSync(jsonlPath)) {
159
- try {
160
- const mtimeMs = fs.statSync(jsonlPath).mtimeMs;
161
- isRecentlyActive = Date.now() - mtimeMs < RECENT_ACTIVITY_WINDOW_MS;
162
- } catch {
163
- // ignore
164
- }
165
- }
166
-
167
- out.push({
168
- id,
169
- projectId,
170
- title,
171
- customTitle,
172
- firstAt,
173
- lastAt,
174
- messageCount,
175
- bytes: related.jsonl,
176
- relatedBytes: related,
177
- isLivePid: livePid !== null,
178
- isRecentlyActive,
179
- livePid,
180
- // "Working" narrows "live" to actively-processing: a live PID, fresh file
181
- // activity, and an unfinished last turn. The live-PID gate keeps a session
182
- // that crashed mid-turn (file frozen with a trailing `user` record) from
183
- // reading as "working".
184
- isWorking: livePid !== null && isRecentlyActive && lastTurnIncomplete,
185
- });
186
- }
187
-
188
- out.sort((a, b) => (b.lastAt ?? '').localeCompare(a.lastAt ?? ''));
189
- return out;
190
- }
191
-
192
- export interface DiskScanSession {
193
- id: string;
194
- title: string;
195
- customTitle: string | null;
196
- lastAt: string | null;
197
- relatedBytes: RelatedBytes;
198
- }
199
-
200
- export interface DiskScanProject {
201
- id: string;
202
- decodedCwd: string;
203
- cwdResolved: boolean;
204
- totalBytes: number;
205
- sessionCount: number;
206
- sessions: DiskScanSession[];
207
- }
208
-
209
- /**
210
- * 磁盘视图(disk-usage + cleanup-suggestions)专用的单遍扫描:逐会话算
211
- * relatedBytes + 解析一次 jsonl meta,项目 totalBytes = 其会话之和。
212
- *
213
- * 刻意不做 live-PID / recently-active 探测:两个磁盘视图都不展示这些,而
214
- * `listSessionsForProject` 会按项目各建一次 active map(Windows = 一次
215
- * `tasklist` spawn,~400-700ms),被这两个接口按项目数放大成几十次 tasklist,
216
- * 是磁盘页加载慢的主因。size/meta 原语与 `listSessionsForProject` 复用,只去掉
217
- * active map 与「listProjects + listSessionsForProject」之间重复的 size 遍历。
218
- */
219
- export async function scanProjectsForDisk(): Promise<DiskScanProject[]> {
220
- if (!fs.existsSync(PATHS.projects)) return [];
221
-
222
- const projectIds = fs
223
- .readdirSync(PATHS.projects, { withFileTypes: true })
224
- .filter((ent) => ent.isDirectory())
225
- .map((ent) => ent.name);
226
-
227
- const result: DiskScanProject[] = [];
228
- for (const projectId of projectIds) {
229
- const projectDir = path.join(PATHS.projects, projectId);
230
- const ids = listSessionIdsInProject(projectDir);
231
-
232
- const scanned = await Promise.all(ids.map((id) => scanDiskSession(projectDir, id)));
233
-
234
- let totalBytes = 0;
235
- let sampleCwd: string | null = null;
236
- const sessions: DiskScanSession[] = [];
237
- for (const { session, cwdFromMessages } of scanned) {
238
- const r = session.relatedBytes;
239
- totalBytes += r.jsonl + r.subdir + r.fileHistory + r.sessionEnv;
240
- if (!sampleCwd && cwdFromMessages) sampleCwd = cwdFromMessages;
241
- sessions.push(session);
242
- }
243
-
244
- const { decoded, resolved } = decodeProjectId(projectId, sampleCwd);
245
- result.push({
246
- id: projectId,
247
- decodedCwd: decoded,
248
- cwdResolved: resolved,
249
- totalBytes,
250
- sessionCount: ids.length,
251
- sessions,
252
- });
253
- }
254
-
255
- return result;
256
- }
257
-
258
- async function scanDiskSession(
259
- projectDir: string,
260
- id: string,
261
- ): Promise<{ session: DiskScanSession; cwdFromMessages: string | null }> {
262
- const jsonlPath = path.join(projectDir, `${id}${JSONL_EXT}`);
263
- const relatedBytes: RelatedBytes = {
264
- jsonl: fileSize(jsonlPath),
265
- subdir: dirSize(path.join(projectDir, id)),
266
- fileHistory: dirSize(path.join(PATHS.fileHistory, id)),
267
- sessionEnv: dirSize(path.join(PATHS.sessionEnv, id)),
268
- };
269
-
270
- let title = '(no jsonl)';
271
- let customTitle: string | null = null;
272
- let lastAt: string | null = null;
273
- let cwdFromMessages: string | null = null;
274
- if (fs.existsSync(jsonlPath)) {
275
- const meta = await parseJsonlMeta(jsonlPath);
276
- title = meta.title;
277
- customTitle = meta.customTitle;
278
- lastAt = meta.lastAt;
279
- cwdFromMessages = meta.cwdFromMessages;
280
- }
281
-
282
- return {
283
- session: { id, title, customTitle, lastAt, relatedBytes },
284
- cwdFromMessages,
285
- };
286
- }
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { PATHS } from './claude-paths.ts';
4
+ import { RECENT_ACTIVITY_WINDOW_MS } from './constants.ts';
5
+ import { decodeCwd } from './encode-cwd.ts';
6
+ import { dirSize, fileSize } from './fs-size.ts';
7
+ import { parseJsonlMeta } from './parse-jsonl.ts';
8
+ import { buildActiveSessionMap } from './active-sessions.ts';
9
+ import type { ProjectSummary, RelatedBytes, SessionSummary } from '../types.ts';
10
+
11
+ const JSONL_EXT = '.jsonl';
12
+
13
+ function listSessionIdsInProject(projectDir: string): string[] {
14
+ if (!fs.existsSync(projectDir)) return [];
15
+ const ids: string[] = [];
16
+ for (const ent of fs.readdirSync(projectDir, { withFileTypes: true })) {
17
+ if (ent.isFile() && ent.name.endsWith(JSONL_EXT)) {
18
+ ids.push(ent.name.slice(0, -JSONL_EXT.length));
19
+ }
20
+ }
21
+ return ids;
22
+ }
23
+
24
+ function decodeProjectId(encoded: string, sampleCwd: string | null): {
25
+ decoded: string;
26
+ resolved: boolean;
27
+ } {
28
+ if (sampleCwd) return { decoded: sampleCwd, resolved: true };
29
+ const decoded = decodeCwd(encoded);
30
+ let resolved = false;
31
+ try {
32
+ resolved = fs.statSync(decoded).isDirectory();
33
+ } catch {
34
+ resolved = false;
35
+ }
36
+ return { decoded, resolved };
37
+ }
38
+
39
+ export async function resolveProjectCwd(
40
+ projectId: string,
41
+ ): Promise<{ decoded: string; resolved: boolean } | null> {
42
+ const projectDir = path.join(PATHS.projects, projectId);
43
+ if (!fs.existsSync(projectDir)) return null;
44
+
45
+ const sessionIds = listSessionIdsInProject(projectDir);
46
+ let sampleCwd: string | null = null;
47
+ for (const id of sessionIds) {
48
+ const jsonlPath = path.join(projectDir, `${id}${JSONL_EXT}`);
49
+ if (!fs.existsSync(jsonlPath)) continue;
50
+ const meta = await parseJsonlMeta(jsonlPath);
51
+ if (meta.cwdFromMessages) {
52
+ sampleCwd = meta.cwdFromMessages;
53
+ break;
54
+ }
55
+ }
56
+ return decodeProjectId(projectId, sampleCwd);
57
+ }
58
+
59
+ export async function listProjects(): Promise<ProjectSummary[]> {
60
+ if (!fs.existsSync(PATHS.projects)) return [];
61
+ const result: ProjectSummary[] = [];
62
+
63
+ for (const ent of fs.readdirSync(PATHS.projects, { withFileTypes: true })) {
64
+ if (!ent.isDirectory()) continue;
65
+ const projectId = ent.name;
66
+ const projectDir = path.join(PATHS.projects, projectId);
67
+
68
+ const sessionIds = listSessionIdsInProject(projectDir);
69
+ let sampleCwd: string | null = null;
70
+ let totalBytes = 0;
71
+ let lastActiveAt: string | null = null;
72
+
73
+ for (const id of sessionIds) {
74
+ const jsonlPath = path.join(projectDir, `${id}${JSONL_EXT}`);
75
+ const subdirPath = path.join(projectDir, id);
76
+ totalBytes += fileSize(jsonlPath);
77
+ totalBytes += dirSize(subdirPath);
78
+ totalBytes += dirSize(path.join(PATHS.fileHistory, id));
79
+ totalBytes += dirSize(path.join(PATHS.sessionEnv, id));
80
+
81
+ if (!sampleCwd && fs.existsSync(jsonlPath)) {
82
+ const meta = await parseJsonlMeta(jsonlPath);
83
+ sampleCwd = meta.cwdFromMessages;
84
+ if (meta.lastAt && (!lastActiveAt || meta.lastAt > lastActiveAt)) {
85
+ lastActiveAt = meta.lastAt;
86
+ }
87
+ } else if (fs.existsSync(jsonlPath)) {
88
+ try {
89
+ const mtime = fs.statSync(jsonlPath).mtime.toISOString();
90
+ if (!lastActiveAt || mtime > lastActiveAt) lastActiveAt = mtime;
91
+ } catch {
92
+ // ignore
93
+ }
94
+ }
95
+ }
96
+
97
+ const { decoded, resolved } = decodeProjectId(projectId, sampleCwd);
98
+
99
+ result.push({
100
+ id: projectId,
101
+ encodedCwd: projectId,
102
+ decodedCwd: decoded,
103
+ cwdResolved: resolved,
104
+ sessionCount: sessionIds.length,
105
+ totalBytes,
106
+ lastActiveAt,
107
+ });
108
+ }
109
+
110
+ result.sort((a, b) => {
111
+ const at = a.lastActiveAt ?? '';
112
+ const bt = b.lastActiveAt ?? '';
113
+ return bt.localeCompare(at);
114
+ });
115
+ return result;
116
+ }
117
+
118
+ export async function listSessionsForProject(projectId: string): Promise<SessionSummary[]> {
119
+ const projectDir = path.join(PATHS.projects, projectId);
120
+ if (!fs.existsSync(projectDir)) return [];
121
+
122
+ const activeMap = buildActiveSessionMap();
123
+ const ids = listSessionIdsInProject(projectDir);
124
+ const out: SessionSummary[] = [];
125
+
126
+ for (const id of ids) {
127
+ const jsonlPath = path.join(projectDir, `${id}${JSONL_EXT}`);
128
+ const subdirPath = path.join(projectDir, id);
129
+ const fhPath = path.join(PATHS.fileHistory, id);
130
+ const sePath = path.join(PATHS.sessionEnv, id);
131
+
132
+ const related: RelatedBytes = {
133
+ jsonl: fileSize(jsonlPath),
134
+ subdir: dirSize(subdirPath),
135
+ fileHistory: dirSize(fhPath),
136
+ sessionEnv: dirSize(sePath),
137
+ };
138
+
139
+ let title = '(no jsonl)';
140
+ let customTitle: string | null = null;
141
+ let firstAt: string | null = null;
142
+ let lastAt: string | null = null;
143
+ let messageCount = 0;
144
+ let errorCount = 0;
145
+ let lastTurnIncomplete = false;
146
+
147
+ if (fs.existsSync(jsonlPath)) {
148
+ const meta = await parseJsonlMeta(jsonlPath);
149
+ title = meta.title;
150
+ customTitle = meta.customTitle;
151
+ firstAt = meta.firstAt;
152
+ lastAt = meta.lastAt;
153
+ messageCount = meta.messageCount;
154
+ errorCount = meta.errorCount;
155
+ lastTurnIncomplete = meta.lastTurnIncomplete;
156
+ }
157
+
158
+ const livePid = activeMap.get(id) ?? null;
159
+ let isRecentlyActive = false;
160
+ if (fs.existsSync(jsonlPath)) {
161
+ try {
162
+ const mtimeMs = fs.statSync(jsonlPath).mtimeMs;
163
+ isRecentlyActive = Date.now() - mtimeMs < RECENT_ACTIVITY_WINDOW_MS;
164
+ } catch {
165
+ // ignore
166
+ }
167
+ }
168
+
169
+ out.push({
170
+ id,
171
+ projectId,
172
+ title,
173
+ customTitle,
174
+ firstAt,
175
+ lastAt,
176
+ messageCount,
177
+ errorCount,
178
+ bytes: related.jsonl,
179
+ relatedBytes: related,
180
+ isLivePid: livePid !== null,
181
+ isRecentlyActive,
182
+ livePid,
183
+ // "Working" narrows "live" to actively-processing: a live PID, fresh file
184
+ // activity, and an unfinished last turn. The live-PID gate keeps a session
185
+ // that crashed mid-turn (file frozen with a trailing `user` record) from
186
+ // reading as "working".
187
+ isWorking: livePid !== null && isRecentlyActive && lastTurnIncomplete,
188
+ });
189
+ }
190
+
191
+ out.sort((a, b) => (b.lastAt ?? '').localeCompare(a.lastAt ?? ''));
192
+ return out;
193
+ }
194
+
195
+ export interface DiskScanSession {
196
+ id: string;
197
+ title: string;
198
+ customTitle: string | null;
199
+ lastAt: string | null;
200
+ relatedBytes: RelatedBytes;
201
+ }
202
+
203
+ export interface DiskScanProject {
204
+ id: string;
205
+ decodedCwd: string;
206
+ cwdResolved: boolean;
207
+ totalBytes: number;
208
+ sessionCount: number;
209
+ sessions: DiskScanSession[];
210
+ }
211
+
212
+ /**
213
+ * 磁盘视图(disk-usage + cleanup-suggestions)专用的单遍扫描:逐会话算
214
+ * relatedBytes + 解析一次 jsonl meta,项目 totalBytes = 其会话之和。
215
+ *
216
+ * 刻意不做 live-PID / recently-active 探测:两个磁盘视图都不展示这些,而
217
+ * `listSessionsForProject` 会按项目各建一次 active map(Windows = 一次
218
+ * `tasklist` spawn,~400-700ms),被这两个接口按项目数放大成几十次 tasklist,
219
+ * 是磁盘页加载慢的主因。size/meta 原语与 `listSessionsForProject` 复用,只去掉
220
+ * active map 与「listProjects + listSessionsForProject」之间重复的 size 遍历。
221
+ */
222
+ export async function scanProjectsForDisk(): Promise<DiskScanProject[]> {
223
+ if (!fs.existsSync(PATHS.projects)) return [];
224
+
225
+ const projectIds = fs
226
+ .readdirSync(PATHS.projects, { withFileTypes: true })
227
+ .filter((ent) => ent.isDirectory())
228
+ .map((ent) => ent.name);
229
+
230
+ const result: DiskScanProject[] = [];
231
+ for (const projectId of projectIds) {
232
+ const projectDir = path.join(PATHS.projects, projectId);
233
+ const ids = listSessionIdsInProject(projectDir);
234
+
235
+ const scanned = await Promise.all(ids.map((id) => scanDiskSession(projectDir, id)));
236
+
237
+ let totalBytes = 0;
238
+ let sampleCwd: string | null = null;
239
+ const sessions: DiskScanSession[] = [];
240
+ for (const { session, cwdFromMessages } of scanned) {
241
+ const r = session.relatedBytes;
242
+ totalBytes += r.jsonl + r.subdir + r.fileHistory + r.sessionEnv;
243
+ if (!sampleCwd && cwdFromMessages) sampleCwd = cwdFromMessages;
244
+ sessions.push(session);
245
+ }
246
+
247
+ const { decoded, resolved } = decodeProjectId(projectId, sampleCwd);
248
+ result.push({
249
+ id: projectId,
250
+ decodedCwd: decoded,
251
+ cwdResolved: resolved,
252
+ totalBytes,
253
+ sessionCount: ids.length,
254
+ sessions,
255
+ });
256
+ }
257
+
258
+ return result;
259
+ }
260
+
261
+ async function scanDiskSession(
262
+ projectDir: string,
263
+ id: string,
264
+ ): Promise<{ session: DiskScanSession; cwdFromMessages: string | null }> {
265
+ const jsonlPath = path.join(projectDir, `${id}${JSONL_EXT}`);
266
+ const relatedBytes: RelatedBytes = {
267
+ jsonl: fileSize(jsonlPath),
268
+ subdir: dirSize(path.join(projectDir, id)),
269
+ fileHistory: dirSize(path.join(PATHS.fileHistory, id)),
270
+ sessionEnv: dirSize(path.join(PATHS.sessionEnv, id)),
271
+ };
272
+
273
+ let title = '(no jsonl)';
274
+ let customTitle: string | null = null;
275
+ let lastAt: string | null = null;
276
+ let cwdFromMessages: string | null = null;
277
+ if (fs.existsSync(jsonlPath)) {
278
+ const meta = await parseJsonlMeta(jsonlPath);
279
+ title = meta.title;
280
+ customTitle = meta.customTitle;
281
+ lastAt = meta.lastAt;
282
+ cwdFromMessages = meta.cwdFromMessages;
283
+ }
284
+
285
+ return {
286
+ session: { id, title, customTitle, lastAt, relatedBytes },
287
+ cwdFromMessages,
288
+ };
289
+ }