@ccpocket/bridge 1.5.1 → 1.5.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.
- package/dist/gallery-store.d.ts +1 -0
- package/dist/gallery-store.js +30 -7
- package/dist/gallery-store.js.map +1 -1
- package/dist/image-store.d.ts +2 -1
- package/dist/image-store.js +35 -6
- package/dist/image-store.js.map +1 -1
- package/dist/sdk-process.d.ts +2 -2
- package/dist/sdk-process.js +4 -2
- package/dist/sdk-process.js.map +1 -1
- package/dist/session.d.ts +0 -1
- package/dist/session.js +1 -2
- package/dist/session.js.map +1 -1
- package/dist/sessions-index.d.ts +13 -2
- package/dist/sessions-index.js +452 -168
- package/dist/sessions-index.js.map +1 -1
- package/dist/websocket.js +33 -26
- package/dist/websocket.js.map +1 -1
- package/package.json +1 -1
package/dist/sessions-index.js
CHANGED
|
@@ -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
|
-
*
|
|
25
|
-
*
|
|
85
|
+
* Run async tasks with a concurrency limit.
|
|
86
|
+
* Returns results in the same order as the input tasks.
|
|
26
87
|
*/
|
|
27
|
-
|
|
28
|
-
const
|
|
29
|
-
let
|
|
30
|
-
|
|
31
|
-
|
|
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
|
-
|
|
34
|
-
|
|
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
|
-
|
|
37
|
-
|
|
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
|
|
40
|
-
const
|
|
41
|
-
|
|
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
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
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
|
|
69
|
-
if (
|
|
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
|
-
|
|
75
|
-
|
|
76
|
-
if (
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
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
|
-
|
|
82
|
-
|
|
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
|
-
|
|
85
|
-
|
|
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
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
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
|
-
|
|
108
|
-
|
|
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
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
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
|
-
|
|
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
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
const
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
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
|
-
|
|
168
|
-
|
|
169
|
-
|
|
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
|
-
|
|
389
|
+
raw = await readFile(indexPath, "utf-8");
|
|
172
390
|
}
|
|
173
391
|
catch {
|
|
174
|
-
|
|
175
|
-
continue;
|
|
392
|
+
// No sessions-index.json — will try JSONL scan for worktree dirs
|
|
176
393
|
}
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
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
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
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
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
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
|
-
|
|
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 (
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
670
|
+
hasMessages = true;
|
|
387
671
|
lastAssistantText = text;
|
|
388
672
|
}
|
|
389
673
|
}
|
|
390
674
|
}
|
|
391
|
-
if (!projectPath ||
|
|
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
|
}
|