@ccpocket/bridge 1.5.2 → 1.6.0

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.
@@ -1,6 +1,62 @@
1
- import { readdir, readFile, writeFile, appendFile, stat } from "node:fs/promises";
1
+ import { readdir, readFile, writeFile, appendFile, stat, open } from "node:fs/promises";
2
2
  import { basename, join } from "node:path";
3
3
  import { homedir } from "node:os";
4
+ function createRecentSessionsPerfStats() {
5
+ return {
6
+ claudeProjectDirs: 0,
7
+ claudeIndexDirs: 0,
8
+ claudeJsonlOnlyDirs: 0,
9
+ claudeIndexEntries: 0,
10
+ claudeJsonlFilesTotal: 0,
11
+ claudeJsonlFilesExcluded: 0,
12
+ claudeJsonlFilesRead: 0,
13
+ claudeJsonlEntries: 0,
14
+ codexFilesTotal: 0,
15
+ codexFilesRead: 0,
16
+ codexEntries: 0,
17
+ claudeNamedOnlyFastPathUsed: false,
18
+ counts: {
19
+ beforeArchive: 0,
20
+ afterArchive: 0,
21
+ afterProvider: 0,
22
+ afterNamedOnly: 0,
23
+ afterSearch: 0,
24
+ returned: 0,
25
+ },
26
+ };
27
+ }
28
+ function markDuration(durations, key, startedAt) {
29
+ const elapsedMs = Number(process.hrtime.bigint() - startedAt) / 1_000_000;
30
+ durations[key] = elapsedMs;
31
+ }
32
+ function shouldLogRecentSessionsPerf() {
33
+ const v = process.env.BRIDGE_RECENT_SESSIONS_PROFILE;
34
+ return v === "1" || v === "true";
35
+ }
36
+ function logRecentSessionsPerf(options, durations, stats) {
37
+ if (!shouldLogRecentSessionsPerf())
38
+ return;
39
+ const projectPath = options.projectPath;
40
+ const projectPathLabel = projectPath
41
+ ? projectPath.length > 72
42
+ ? `${projectPath.slice(0, 69)}...`
43
+ : projectPath
44
+ : "";
45
+ const payload = {
46
+ options: {
47
+ limit: options.limit ?? 20,
48
+ offset: options.offset ?? 0,
49
+ projectPath: projectPathLabel || undefined,
50
+ provider: options.provider ?? "all",
51
+ namedOnly: options.namedOnly ?? false,
52
+ searchQuery: options.searchQuery ? "<set>" : "<none>",
53
+ archivedSessionIds: options.archivedSessionIds?.size ?? 0,
54
+ },
55
+ durationsMs: Object.fromEntries(Object.entries(durations).map(([k, v]) => [k, Number(v.toFixed(1))])),
56
+ stats,
57
+ };
58
+ console.info(`[recent-sessions][perf] ${JSON.stringify(payload)}`);
59
+ }
4
60
  /** Convert a filesystem path to Claude's project directory slug (e.g. /foo/bar → -foo-bar). */
5
61
  export function pathToSlug(p) {
6
62
  return p.replaceAll("/", "-").replaceAll("_", "-");
@@ -20,119 +76,279 @@ export function normalizeWorktreePath(p) {
20
76
  export function isWorktreeSlug(dirSlug, projectSlug) {
21
77
  return dirSlug.startsWith(projectSlug + "-worktrees-");
22
78
  }
79
+ /** Concurrency limit for parallel file reads to avoid fd exhaustion. */
80
+ const PARALLEL_FILE_READ_LIMIT = 32;
81
+ /** Head/Tail byte sizes for partial JSONL reads. */
82
+ const HEAD_BYTES = 16384; // 16KB — covers first user entry + metadata
83
+ const TAIL_BYTES = 8192; // 8KB — covers last entries for modified/lastPrompt
23
84
  /**
24
- * Scan a directory for JSONL session files and create SessionIndexEntry objects.
25
- * Used as a fallback when sessions-index.json is missing (common for worktree sessions).
85
+ * Run async tasks with a concurrency limit.
86
+ * Returns results in the same order as the input tasks.
26
87
  */
27
- export async function scanJsonlDir(dirPath) {
28
- const entries = [];
29
- let files;
30
- try {
31
- files = await readdir(dirPath);
88
+ async function parallelMap(items, concurrency, fn) {
89
+ const results = new Array(items.length);
90
+ let nextIndex = 0;
91
+ async function worker() {
92
+ while (nextIndex < items.length) {
93
+ const i = nextIndex++;
94
+ results[i] = await fn(items[i]);
95
+ }
32
96
  }
33
- catch {
34
- return entries;
97
+ const workers = Array.from({ length: Math.min(concurrency, items.length) }, () => worker());
98
+ await Promise.all(workers);
99
+ return results;
100
+ }
101
+ // Regexes for fast field extraction without JSON.parse
102
+ const RE_TYPE_USER = /"type"\s*:\s*"user"/;
103
+ const RE_TYPE_ASSISTANT = /"type"\s*:\s*"assistant"/;
104
+ const RE_TIMESTAMP = /"timestamp"\s*:\s*"([^"]+)"/;
105
+ const RE_GIT_BRANCH = /"gitBranch"\s*:\s*"([^"]+)"/;
106
+ const RE_CWD = /"cwd"\s*:\s*"([^"]+)"/;
107
+ const RE_IS_SIDECHAIN = /"isSidechain"\s*:\s*true/;
108
+ /** Extract user prompt text from a parsed JSONL entry. */
109
+ function extractUserPromptText(entry) {
110
+ const message = entry.message;
111
+ if (!message?.content)
112
+ return "";
113
+ if (typeof message.content === "string")
114
+ return message.content;
115
+ if (Array.isArray(message.content)) {
116
+ const textBlock = message.content.find((c) => c.type === "text" && c.text);
117
+ return textBlock?.text ?? "";
35
118
  }
36
- for (const file of files) {
37
- if (!file.endsWith(".jsonl"))
119
+ return "";
120
+ }
121
+ /**
122
+ * Parse head and optional tail text chunks to build a SessionIndexEntry.
123
+ * Uses regex for most fields, JSON.parse only for first/last user lines.
124
+ */
125
+ function parseFromChunks(sessionId, head, tail) {
126
+ let firstPrompt = "";
127
+ let lastPrompt = "";
128
+ let created = "";
129
+ let modified = "";
130
+ let gitBranch = "";
131
+ let projectPath = "";
132
+ let isSidechain = false;
133
+ let hasAnyMessage = false;
134
+ // --- Scan head lines ---
135
+ const headLines = head.split("\n");
136
+ for (const line of headLines) {
137
+ if (!line.trim())
38
138
  continue;
39
- const sessionId = basename(file, ".jsonl");
40
- const filePath = join(dirPath, file);
41
- let raw;
42
- try {
43
- raw = await readFile(filePath, "utf-8");
44
- }
45
- catch {
139
+ const isUser = RE_TYPE_USER.test(line);
140
+ const isAssistant = !isUser && RE_TYPE_ASSISTANT.test(line);
141
+ if (!isUser && !isAssistant)
46
142
  continue;
143
+ hasAnyMessage = true;
144
+ const tsMatch = line.match(RE_TIMESTAMP);
145
+ if (tsMatch) {
146
+ if (!created)
147
+ created = tsMatch[1];
148
+ modified = tsMatch[1];
47
149
  }
48
- const lines = raw.split("\n");
49
- let firstPrompt = "";
50
- let lastPrompt = "";
51
- let messageCount = 0;
52
- let created = "";
53
- let modified = "";
54
- let gitBranch = "";
55
- let projectPath = "";
56
- let isSidechain = false;
57
- let summary;
58
- for (const line of lines) {
59
- if (!line.trim())
60
- continue;
61
- let entry;
150
+ if (!gitBranch) {
151
+ const gbMatch = line.match(RE_GIT_BRANCH);
152
+ if (gbMatch)
153
+ gitBranch = gbMatch[1];
154
+ }
155
+ if (!projectPath) {
156
+ const cwdMatch = line.match(RE_CWD);
157
+ if (cwdMatch)
158
+ projectPath = normalizeWorktreePath(cwdMatch[1]);
159
+ }
160
+ if (!isSidechain && RE_IS_SIDECHAIN.test(line)) {
161
+ isSidechain = true;
162
+ }
163
+ if (isUser && !firstPrompt) {
164
+ // JSON.parse only the first user line to extract prompt text
62
165
  try {
63
- entry = JSON.parse(line);
166
+ const entry = JSON.parse(line);
167
+ firstPrompt = extractUserPromptText(entry);
64
168
  }
65
- catch {
169
+ catch { /* skip */ }
170
+ }
171
+ }
172
+ // --- Scan tail lines (if separate from head) ---
173
+ if (tail) {
174
+ const tailLines = tail.split("\n");
175
+ // Find last timestamp and last user prompt from tail (scan in reverse)
176
+ let lastUserLine = null;
177
+ for (let i = tailLines.length - 1; i >= 0; i--) {
178
+ const line = tailLines[i];
179
+ if (!line.trim())
66
180
  continue;
67
- }
68
- const type = entry.type;
69
- if (type === "summary" && entry.summary) {
70
- summary = entry.summary;
71
- }
72
- if (type !== "user" && type !== "assistant")
181
+ const isUser = RE_TYPE_USER.test(line);
182
+ const isAssistant = !isUser && RE_TYPE_ASSISTANT.test(line);
183
+ if (!isUser && !isAssistant)
73
184
  continue;
74
- messageCount++;
75
- const timestamp = entry.timestamp;
76
- if (timestamp) {
77
- if (!created)
78
- created = timestamp;
79
- modified = timestamp;
185
+ hasAnyMessage = true;
186
+ // Get the last modified timestamp
187
+ if (!modified || true) {
188
+ // Always update modified from tail (tail is later in file)
189
+ const tsMatch = line.match(RE_TIMESTAMP);
190
+ if (tsMatch) {
191
+ modified = tsMatch[1];
192
+ // We found the last message — we're done with timestamps
193
+ if (isUser && !lastUserLine)
194
+ lastUserLine = line;
195
+ break;
196
+ }
80
197
  }
81
- if (!gitBranch && entry.gitBranch) {
82
- gitBranch = entry.gitBranch;
198
+ }
199
+ // Also find last user line if not found in reverse timestamp scan
200
+ if (!lastUserLine) {
201
+ for (let i = tailLines.length - 1; i >= 0; i--) {
202
+ const line = tailLines[i];
203
+ if (!line.trim())
204
+ continue;
205
+ if (RE_TYPE_USER.test(line)) {
206
+ lastUserLine = line;
207
+ break;
208
+ }
83
209
  }
84
- if (!projectPath && entry.cwd) {
85
- projectPath = normalizeWorktreePath(entry.cwd);
210
+ }
211
+ // JSON.parse only the last user line for lastPrompt
212
+ if (lastUserLine) {
213
+ try {
214
+ const entry = JSON.parse(lastUserLine);
215
+ const text = extractUserPromptText(entry);
216
+ if (text)
217
+ lastPrompt = text;
86
218
  }
87
- if (type === "user") {
88
- const message = entry.message;
89
- if (message?.content) {
90
- let text = "";
91
- if (typeof message.content === "string") {
92
- text = message.content;
93
- }
94
- else if (Array.isArray(message.content)) {
95
- const textBlock = message.content.find((c) => c.type === "text" && c.text);
96
- if (textBlock?.text) {
97
- text = textBlock.text;
98
- }
99
- }
100
- if (text) {
101
- if (!firstPrompt)
102
- firstPrompt = text;
103
- lastPrompt = text;
104
- }
219
+ catch { /* skip */ }
220
+ }
221
+ // Fill in metadata from tail if head didn't have it
222
+ if (!gitBranch || !projectPath) {
223
+ for (const line of tailLines) {
224
+ if (!line.trim())
225
+ continue;
226
+ if (!RE_TYPE_USER.test(line) && !RE_TYPE_ASSISTANT.test(line))
227
+ continue;
228
+ if (!gitBranch) {
229
+ const gbMatch = line.match(RE_GIT_BRANCH);
230
+ if (gbMatch)
231
+ gitBranch = gbMatch[1];
105
232
  }
106
- }
107
- if (entry.isSidechain) {
108
- isSidechain = true;
233
+ if (!projectPath) {
234
+ const cwdMatch = line.match(RE_CWD);
235
+ if (cwdMatch)
236
+ projectPath = normalizeWorktreePath(cwdMatch[1]);
237
+ }
238
+ if (gitBranch && projectPath)
239
+ break;
109
240
  }
110
241
  }
111
- if (messageCount > 0) {
112
- entries.push({
113
- sessionId,
114
- provider: "claude",
115
- summary,
116
- firstPrompt,
117
- ...(lastPrompt && lastPrompt !== firstPrompt ? { lastPrompt } : {}),
118
- messageCount,
119
- created,
120
- modified,
121
- gitBranch,
122
- projectPath,
123
- isSidechain,
124
- });
242
+ }
243
+ if (!hasAnyMessage)
244
+ return null;
245
+ return {
246
+ sessionId,
247
+ provider: "claude",
248
+ firstPrompt,
249
+ ...(lastPrompt && lastPrompt !== firstPrompt ? { lastPrompt } : {}),
250
+ created,
251
+ modified,
252
+ gitBranch,
253
+ projectPath,
254
+ isSidechain,
255
+ };
256
+ }
257
+ /**
258
+ * Fast parse a Claude JSONL file using partial (head+tail) reads.
259
+ * Only reads the first 16KB and last 8KB of the file, avoiding full I/O.
260
+ * JSON.parse is called at most twice (first + last user lines).
261
+ */
262
+ async function parseClaudeJsonlFileFast(sessionId, filePath) {
263
+ let fh;
264
+ try {
265
+ fh = await open(filePath, "r");
266
+ }
267
+ catch {
268
+ return null;
269
+ }
270
+ try {
271
+ const fileStat = await fh.stat();
272
+ const fileSize = fileStat.size;
273
+ if (fileSize === 0)
274
+ return null;
275
+ // Small files: read entirely (no benefit from partial reads)
276
+ if (fileSize <= HEAD_BYTES + TAIL_BYTES) {
277
+ const buf = Buffer.alloc(fileSize);
278
+ await fh.read(buf, 0, fileSize, 0);
279
+ return parseFromChunks(sessionId, buf.toString("utf-8"), null);
125
280
  }
281
+ // Head read
282
+ const headBuf = Buffer.alloc(HEAD_BYTES);
283
+ await fh.read(headBuf, 0, HEAD_BYTES, 0);
284
+ const headStr = headBuf.toString("utf-8");
285
+ // Tail read — discard the first partial line
286
+ const tailBuf = Buffer.alloc(TAIL_BYTES);
287
+ await fh.read(tailBuf, 0, TAIL_BYTES, fileSize - TAIL_BYTES);
288
+ const tailRaw = tailBuf.toString("utf-8");
289
+ const firstNewline = tailRaw.indexOf("\n");
290
+ const cleanTail = firstNewline >= 0 ? tailRaw.slice(firstNewline + 1) : "";
291
+ return parseFromChunks(sessionId, headStr, cleanTail);
126
292
  }
127
- return entries;
293
+ finally {
294
+ await fh.close();
295
+ }
296
+ }
297
+ /**
298
+ * Scan a directory for JSONL session files and create SessionIndexEntry objects.
299
+ * Used as a fallback when sessions-index.json is missing (common for worktree sessions).
300
+ * File reads are parallelized and use head+tail partial reads for performance.
301
+ */
302
+ export async function scanJsonlDir(dirPath, options = {}) {
303
+ const scanStats = options.stats;
304
+ let files;
305
+ try {
306
+ files = await readdir(dirPath);
307
+ }
308
+ catch {
309
+ return [];
310
+ }
311
+ // Filter to JSONL files and apply exclusions
312
+ const targets = [];
313
+ for (const file of files) {
314
+ if (!file.endsWith(".jsonl"))
315
+ continue;
316
+ scanStats && (scanStats.filesTotal += 1);
317
+ const sessionId = basename(file, ".jsonl");
318
+ if (options.excludeSessionIds?.has(sessionId)) {
319
+ scanStats && (scanStats.filesExcluded += 1);
320
+ continue;
321
+ }
322
+ targets.push({ sessionId, filePath: join(dirPath, file) });
323
+ }
324
+ // Read and parse files in parallel using fast head+tail reads
325
+ const results = await parallelMap(targets, PARALLEL_FILE_READ_LIMIT, async ({ sessionId, filePath }) => {
326
+ const entry = await parseClaudeJsonlFileFast(sessionId, filePath);
327
+ if (entry) {
328
+ scanStats && (scanStats.filesRead += 1);
329
+ scanStats && (scanStats.entriesReturned += 1);
330
+ }
331
+ else {
332
+ scanStats && (scanStats.filesRead += 1);
333
+ }
334
+ return entry;
335
+ });
336
+ return results.filter((e) => e !== null);
128
337
  }
129
338
  export async function getAllRecentSessions(options = {}) {
339
+ const totalStartedAt = process.hrtime.bigint();
340
+ const durations = {};
341
+ const perfStats = createRecentSessionsPerfStats();
130
342
  const limit = options.limit ?? 20;
131
343
  const offset = options.offset ?? 0;
132
344
  const filterProjectPath = options.projectPath;
345
+ const shouldLoadClaude = options.provider !== "codex";
346
+ const shouldLoadCodex = options.provider !== "claude";
347
+ const includeOnlyNamedClaude = options.namedOnly === true;
133
348
  const projectsDir = join(homedir(), ".claude", "projects");
134
349
  const entries = [];
135
350
  let projectDirs;
351
+ const loadProjectDirsStartedAt = process.hrtime.bigint();
136
352
  try {
137
353
  projectDirs = await readdir(projectsDir);
138
354
  }
@@ -140,94 +356,161 @@ export async function getAllRecentSessions(options = {}) {
140
356
  // ~/.claude/projects doesn't exist
141
357
  projectDirs = [];
142
358
  }
359
+ markDuration(durations, "loadClaudeProjectDirs", loadProjectDirsStartedAt);
143
360
  // Compute worktree slug prefix for projectPath filtering
144
361
  const projectSlug = filterProjectPath
145
362
  ? pathToSlug(filterProjectPath)
146
363
  : null;
147
- for (const dirName of projectDirs) {
148
- // Skip hidden directories
149
- if (dirName.startsWith("."))
150
- continue;
151
- // When filtering by project, skip unrelated directories early
152
- const isProjectDir = projectSlug ? dirName === projectSlug : false;
153
- const isWorktreeDir = projectSlug
154
- ? isWorktreeSlug(dirName, projectSlug)
155
- : false;
156
- if (filterProjectPath && !isProjectDir && !isWorktreeDir)
157
- continue;
158
- const dirPath = join(projectsDir, dirName);
159
- const indexPath = join(dirPath, "sessions-index.json");
160
- let raw = null;
161
- try {
162
- raw = await readFile(indexPath, "utf-8");
163
- }
164
- catch {
165
- // No sessions-index.json — will try JSONL scan for worktree dirs
364
+ // --- Load Claude and Codex sessions in parallel ---
365
+ const loadClaudeStartedAt = process.hrtime.bigint();
366
+ const claudeEntriesPromise = (async () => {
367
+ if (!shouldLoadClaude)
368
+ return [];
369
+ // Filter directories first (sync), then process in parallel
370
+ const relevantDirs = [];
371
+ for (const dirName of projectDirs) {
372
+ if (dirName.startsWith("."))
373
+ continue;
374
+ const isProjectDir = projectSlug ? dirName === projectSlug : false;
375
+ const isWorktreeDir = projectSlug
376
+ ? isWorktreeSlug(dirName, projectSlug)
377
+ : false;
378
+ if (filterProjectPath && !isProjectDir && !isWorktreeDir)
379
+ continue;
380
+ relevantDirs.push(dirName);
166
381
  }
167
- if (raw !== null) {
168
- // Parse sessions-index.json
169
- let index;
382
+ perfStats.claudeProjectDirs = relevantDirs.length;
383
+ // Process directories in parallel
384
+ const dirResults = await parallelMap(relevantDirs, PARALLEL_FILE_READ_LIMIT, async (dirName) => {
385
+ const dirPath = join(projectsDir, dirName);
386
+ const indexPath = join(dirPath, "sessions-index.json");
387
+ let raw = null;
170
388
  try {
171
- index = JSON.parse(raw);
389
+ raw = await readFile(indexPath, "utf-8");
172
390
  }
173
391
  catch {
174
- console.error(`[sessions-index] Failed to parse ${indexPath}`);
175
- continue;
392
+ // No sessions-index.json will try JSONL scan for worktree dirs
176
393
  }
177
- if (!Array.isArray(index.entries))
178
- continue;
179
- const indexedIds = new Set();
180
- for (const entry of index.entries) {
181
- indexedIds.add(entry.sessionId);
182
- const mapped = {
183
- sessionId: entry.sessionId,
184
- provider: "claude",
185
- name: entry.customTitle || undefined,
186
- summary: entry.summary,
187
- firstPrompt: entry.firstPrompt ?? "",
188
- messageCount: entry.messageCount ?? 0,
189
- created: entry.created ?? "",
190
- modified: entry.modified ?? "",
191
- gitBranch: entry.gitBranch ?? "",
192
- projectPath: normalizeWorktreePath(entry.projectPath ?? ""),
193
- isSidechain: entry.isSidechain ?? false,
194
- };
195
- entries.push(mapped);
394
+ const result = {
395
+ entries: [],
396
+ indexDirs: 0,
397
+ indexEntries: 0,
398
+ jsonlOnlyDirs: 0,
399
+ jsonlStats: { filesTotal: 0, filesExcluded: 0, filesRead: 0, entriesReturned: 0 },
400
+ };
401
+ if (raw !== null) {
402
+ result.indexDirs = 1;
403
+ let index;
404
+ try {
405
+ index = JSON.parse(raw);
406
+ }
407
+ catch {
408
+ console.error(`[sessions-index] Failed to parse ${indexPath}`);
409
+ return result;
410
+ }
411
+ if (!Array.isArray(index.entries))
412
+ return result;
413
+ const indexedIds = new Set();
414
+ for (const entry of index.entries) {
415
+ const name = entry.customTitle || undefined;
416
+ if (includeOnlyNamedClaude && (!name || name === "")) {
417
+ continue;
418
+ }
419
+ indexedIds.add(entry.sessionId);
420
+ result.entries.push({
421
+ sessionId: entry.sessionId,
422
+ provider: "claude",
423
+ name,
424
+ summary: entry.summary,
425
+ firstPrompt: entry.firstPrompt ?? "",
426
+ created: entry.created ?? "",
427
+ modified: entry.modified ?? "",
428
+ gitBranch: entry.gitBranch ?? "",
429
+ projectPath: normalizeWorktreePath(entry.projectPath ?? ""),
430
+ isSidechain: entry.isSidechain ?? false,
431
+ });
432
+ result.indexEntries += 1;
433
+ }
434
+ if (!includeOnlyNamedClaude) {
435
+ const scanned = await scanJsonlDir(dirPath, {
436
+ excludeSessionIds: indexedIds,
437
+ stats: result.jsonlStats,
438
+ });
439
+ result.entries.push(...scanned);
440
+ }
441
+ else {
442
+ perfStats.claudeNamedOnlyFastPathUsed = true;
443
+ }
196
444
  }
197
- // Supplement: scan JSONL files not covered by the index.
198
- // Claude CLI may not register every session (e.g. `claude -r` resumes)
199
- // into sessions-index.json, so we pick up any orphaned JSONL files here.
200
- const scanned = await scanJsonlDir(dirPath);
201
- for (const s of scanned) {
202
- if (!indexedIds.has(s.sessionId)) {
203
- entries.push(s);
445
+ else {
446
+ if (includeOnlyNamedClaude) {
447
+ perfStats.claudeNamedOnlyFastPathUsed = true;
448
+ return result;
204
449
  }
450
+ result.jsonlOnlyDirs = 1;
451
+ const scanned = await scanJsonlDir(dirPath, { stats: result.jsonlStats });
452
+ result.entries.push(...scanned);
205
453
  }
454
+ return result;
455
+ });
456
+ // Aggregate stats and entries
457
+ const allEntries = [];
458
+ for (const r of dirResults) {
459
+ allEntries.push(...r.entries);
460
+ perfStats.claudeIndexDirs += r.indexDirs;
461
+ perfStats.claudeIndexEntries += r.indexEntries;
462
+ perfStats.claudeJsonlOnlyDirs += r.jsonlOnlyDirs;
463
+ perfStats.claudeJsonlFilesTotal += r.jsonlStats.filesTotal;
464
+ perfStats.claudeJsonlFilesExcluded += r.jsonlStats.filesExcluded;
465
+ perfStats.claudeJsonlFilesRead += r.jsonlStats.filesRead;
466
+ perfStats.claudeJsonlEntries += r.jsonlStats.entriesReturned;
206
467
  }
207
- else {
208
- // No sessions-index.json: scan JSONL files directly.
209
- // Directories are already filtered above, so all remaining dirs are relevant.
210
- const scanned = await scanJsonlDir(dirPath);
211
- entries.push(...scanned);
212
- }
213
- }
214
- const codexEntries = await getAllRecentCodexSessions({
215
- projectPath: filterProjectPath,
216
- });
217
- entries.push(...codexEntries);
468
+ return allEntries;
469
+ })();
470
+ const loadCodexStartedAt = process.hrtime.bigint();
471
+ const codexEntriesPromise = (async () => {
472
+ if (!shouldLoadCodex)
473
+ return [];
474
+ const codexPerf = {
475
+ filesTotal: 0,
476
+ filesRead: 0,
477
+ entriesReturned: 0,
478
+ };
479
+ const codexEntries = await getAllRecentCodexSessions({
480
+ projectPath: filterProjectPath,
481
+ perfStats: codexPerf,
482
+ });
483
+ perfStats.codexFilesTotal = codexPerf.filesTotal;
484
+ perfStats.codexFilesRead = codexPerf.filesRead;
485
+ perfStats.codexEntries = codexPerf.entriesReturned;
486
+ return codexEntries;
487
+ })();
488
+ // Wait for both Claude and Codex loading to complete in parallel
489
+ const [claudeEntries, codexEntries] = await Promise.all([
490
+ claudeEntriesPromise,
491
+ codexEntriesPromise,
492
+ ]);
493
+ markDuration(durations, "loadClaudeSessions", loadClaudeStartedAt);
494
+ markDuration(durations, "loadCodexSessions", loadCodexStartedAt);
495
+ // Combine results
496
+ entries.push(...claudeEntries, ...codexEntries);
218
497
  // Filter out archived sessions
219
498
  const archivedIds = options.archivedSessionIds;
220
499
  let filtered = archivedIds
221
500
  ? entries.filter((e) => !archivedIds.has(e.sessionId))
222
501
  : [...entries];
502
+ perfStats.counts.beforeArchive = entries.length;
503
+ perfStats.counts.afterArchive = filtered.length;
223
504
  // Filter by provider
224
505
  if (options.provider) {
225
506
  filtered = filtered.filter((e) => e.provider === options.provider);
226
507
  }
508
+ perfStats.counts.afterProvider = filtered.length;
227
509
  // Filter named only
228
510
  if (options.namedOnly) {
229
511
  filtered = filtered.filter((e) => e.name != null && e.name !== "");
230
512
  }
513
+ perfStats.counts.afterNamedOnly = filtered.length;
231
514
  // Filter by search query (name, firstPrompt, lastPrompt, summary)
232
515
  if (options.searchQuery) {
233
516
  const q = options.searchQuery.toLowerCase();
@@ -236,14 +519,22 @@ export async function getAllRecentSessions(options = {}) {
236
519
  e.lastPrompt?.toLowerCase().includes(q) ||
237
520
  e.summary?.toLowerCase().includes(q));
238
521
  }
522
+ perfStats.counts.afterSearch = filtered.length;
239
523
  // Sort by modified descending
524
+ const sortStartedAt = process.hrtime.bigint();
240
525
  filtered.sort((a, b) => {
241
526
  const ta = new Date(a.modified).getTime();
242
527
  const tb = new Date(b.modified).getTime();
243
528
  return tb - ta;
244
529
  });
530
+ markDuration(durations, "sortSessions", sortStartedAt);
531
+ const paginateStartedAt = process.hrtime.bigint();
245
532
  const sliced = filtered.slice(offset, offset + limit);
246
533
  const hasMore = offset + limit < filtered.length;
534
+ perfStats.counts.returned = sliced.length;
535
+ markDuration(durations, "paginate", paginateStartedAt);
536
+ markDuration(durations, "total", totalStartedAt);
537
+ logRecentSessionsPerf(options, durations, perfStats);
247
538
  return { sessions: sliced, hasMore };
248
539
  }
249
540
  async function listCodexSessionFiles() {
@@ -254,24 +545,17 @@ async function listCodexSessionFiles() {
254
545
  const dir = stack.pop();
255
546
  let children;
256
547
  try {
257
- children = await readdir(dir);
548
+ children = await readdir(dir, { withFileTypes: true });
258
549
  }
259
550
  catch {
260
551
  continue;
261
552
  }
262
553
  for (const child of children) {
263
- const p = join(dir, child);
264
- let st;
265
- try {
266
- st = await stat(p);
267
- }
268
- catch {
269
- continue;
270
- }
271
- if (st.isDirectory()) {
554
+ const p = join(dir, child.name);
555
+ if (child.isDirectory()) {
272
556
  stack.push(p);
273
557
  }
274
- else if (st.isFile() && p.endsWith(".jsonl")) {
558
+ else if (child.isFile() && p.endsWith(".jsonl")) {
275
559
  files.push(p);
276
560
  }
277
561
  }
@@ -289,7 +573,7 @@ function parseCodexSessionJsonl(raw, fallbackSessionId) {
289
573
  let firstPrompt = "";
290
574
  let lastPrompt = "";
291
575
  let summary = "";
292
- let messageCount = 0;
576
+ let hasMessages = false;
293
577
  let lastAssistantText = "";
294
578
  // Settings extracted from the first turn_context entry
295
579
  let approvalPolicy;
@@ -362,7 +646,7 @@ function parseCodexSessionJsonl(raw, fallbackSessionId) {
362
646
  if (entry.type === "event_msg") {
363
647
  const payload = entry.payload;
364
648
  if (payload?.type === "user_message" && typeof payload.message === "string") {
365
- messageCount += 1;
649
+ hasMessages = true;
366
650
  if (!firstPrompt)
367
651
  firstPrompt = payload.message;
368
652
  lastPrompt = payload.message;
@@ -383,12 +667,12 @@ function parseCodexSessionJsonl(raw, fallbackSessionId) {
383
667
  .join("\n")
384
668
  .trim();
385
669
  if (text.length > 0) {
386
- messageCount += 1;
670
+ hasMessages = true;
387
671
  lastAssistantText = text;
388
672
  }
389
673
  }
390
674
  }
391
- if (!projectPath || messageCount === 0)
675
+ if (!projectPath || !hasMessages)
392
676
  return null;
393
677
  summary = lastAssistantText || summary;
394
678
  const codexSettings = (approvalPolicy
@@ -414,7 +698,6 @@ function parseCodexSessionJsonl(raw, fallbackSessionId) {
414
698
  summary: summary || undefined,
415
699
  firstPrompt,
416
700
  ...(lastPrompt && lastPrompt !== firstPrompt ? { lastPrompt } : {}),
417
- messageCount,
418
701
  created,
419
702
  modified,
420
703
  gitBranch,
@@ -494,7 +777,6 @@ export async function renameClaudeSession(projectPath, claudeSessionId, name) {
494
777
  let firstPrompt = "";
495
778
  let created = new Date().toISOString();
496
779
  let modified = created;
497
- let messageCount = 0;
498
780
  let gitBranch = "";
499
781
  try {
500
782
  const raw = await readFile(jsonlPath, "utf-8");
@@ -506,7 +788,6 @@ export async function renameClaudeSession(projectPath, claudeSessionId, name) {
506
788
  const type = entry.type;
507
789
  if (type !== "user" && type !== "assistant")
508
790
  continue;
509
- messageCount++;
510
791
  const ts = entry.timestamp;
511
792
  if (ts) {
512
793
  if (!firstPrompt)
@@ -539,7 +820,7 @@ export async function renameClaudeSession(projectPath, claudeSessionId, name) {
539
820
  fileMtime: Date.now(),
540
821
  firstPrompt,
541
822
  customTitle: name,
542
- messageCount,
823
+ messageCount: 0,
543
824
  created,
544
825
  modified,
545
826
  gitBranch,
@@ -603,6 +884,7 @@ export async function renameCodexSession(threadId, name) {
603
884
  async function getAllRecentCodexSessions(options = {}) {
604
885
  const files = await listCodexSessionFiles();
605
886
  const entries = [];
887
+ options.perfStats && (options.perfStats.filesTotal = files.length);
606
888
  const normalizedProjectPath = options.projectPath
607
889
  ? normalizeWorktreePath(options.projectPath)
608
890
  : null;
@@ -616,6 +898,7 @@ async function getAllRecentCodexSessions(options = {}) {
616
898
  catch {
617
899
  continue;
618
900
  }
901
+ options.perfStats && (options.perfStats.filesRead += 1);
619
902
  const fallbackSessionId = basename(filePath, ".jsonl");
620
903
  const parsed = parseCodexSessionJsonl(raw, fallbackSessionId);
621
904
  if (!parsed)
@@ -629,6 +912,7 @@ async function getAllRecentCodexSessions(options = {}) {
629
912
  parsed.entry.name = threadName;
630
913
  }
631
914
  entries.push(parsed.entry);
915
+ options.perfStats && (options.perfStats.entriesReturned += 1);
632
916
  }
633
917
  return entries;
634
918
  }