@ccpocket-base-auth/bridge 1.26.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.
- package/README.md +67 -0
- package/dist/archive-store.d.ts +28 -0
- package/dist/archive-store.js +68 -0
- package/dist/archive-store.js.map +1 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +82 -0
- package/dist/cli.js.map +1 -0
- package/dist/codex-process.d.ts +171 -0
- package/dist/codex-process.js +1928 -0
- package/dist/codex-process.js.map +1 -0
- package/dist/debug-trace-store.d.ts +15 -0
- package/dist/debug-trace-store.js +78 -0
- package/dist/debug-trace-store.js.map +1 -0
- package/dist/doctor.d.ts +58 -0
- package/dist/doctor.js +663 -0
- package/dist/doctor.js.map +1 -0
- package/dist/firebase-auth.d.ts +35 -0
- package/dist/firebase-auth.js +132 -0
- package/dist/firebase-auth.js.map +1 -0
- package/dist/gallery-store.d.ts +67 -0
- package/dist/gallery-store.js +333 -0
- package/dist/gallery-store.js.map +1 -0
- package/dist/image-store.d.ts +23 -0
- package/dist/image-store.js +142 -0
- package/dist/image-store.js.map +1 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +191 -0
- package/dist/index.js.map +1 -0
- package/dist/mdns.d.ts +7 -0
- package/dist/mdns.js +49 -0
- package/dist/mdns.js.map +1 -0
- package/dist/parser.d.ts +465 -0
- package/dist/parser.js +251 -0
- package/dist/parser.js.map +1 -0
- package/dist/project-history.d.ts +10 -0
- package/dist/project-history.js +73 -0
- package/dist/project-history.js.map +1 -0
- package/dist/prompt-history-backup.d.ts +15 -0
- package/dist/prompt-history-backup.js +46 -0
- package/dist/prompt-history-backup.js.map +1 -0
- package/dist/proxy.d.ts +15 -0
- package/dist/proxy.js +95 -0
- package/dist/proxy.js.map +1 -0
- package/dist/push-i18n.d.ts +7 -0
- package/dist/push-i18n.js +75 -0
- package/dist/push-i18n.js.map +1 -0
- package/dist/push-relay.d.ts +29 -0
- package/dist/push-relay.js +70 -0
- package/dist/push-relay.js.map +1 -0
- package/dist/recording-store.d.ts +51 -0
- package/dist/recording-store.js +158 -0
- package/dist/recording-store.js.map +1 -0
- package/dist/screenshot.d.ts +28 -0
- package/dist/screenshot.js +98 -0
- package/dist/screenshot.js.map +1 -0
- package/dist/sdk-process.d.ts +180 -0
- package/dist/sdk-process.js +937 -0
- package/dist/sdk-process.js.map +1 -0
- package/dist/session.d.ts +142 -0
- package/dist/session.js +615 -0
- package/dist/session.js.map +1 -0
- package/dist/sessions-index.d.ts +128 -0
- package/dist/sessions-index.js +1767 -0
- package/dist/sessions-index.js.map +1 -0
- package/dist/setup-launchd.d.ts +8 -0
- package/dist/setup-launchd.js +109 -0
- package/dist/setup-launchd.js.map +1 -0
- package/dist/setup-systemd.d.ts +8 -0
- package/dist/setup-systemd.js +118 -0
- package/dist/setup-systemd.js.map +1 -0
- package/dist/startup-info.d.ts +8 -0
- package/dist/startup-info.js +92 -0
- package/dist/startup-info.js.map +1 -0
- package/dist/usage.d.ts +69 -0
- package/dist/usage.js +545 -0
- package/dist/usage.js.map +1 -0
- package/dist/version.d.ts +13 -0
- package/dist/version.js +43 -0
- package/dist/version.js.map +1 -0
- package/dist/websocket.d.ts +127 -0
- package/dist/websocket.js +2482 -0
- package/dist/websocket.js.map +1 -0
- package/dist/worktree-store.d.ts +25 -0
- package/dist/worktree-store.js +59 -0
- package/dist/worktree-store.js.map +1 -0
- package/dist/worktree.d.ts +47 -0
- package/dist/worktree.js +313 -0
- package/dist/worktree.js.map +1 -0
- package/package.json +68 -0
|
@@ -0,0 +1,1767 @@
|
|
|
1
|
+
import { readdir, readFile, writeFile, appendFile, stat, open } from "node:fs/promises";
|
|
2
|
+
import { createReadStream } from "node:fs";
|
|
3
|
+
import { createInterface } from "node:readline";
|
|
4
|
+
import { basename, join } from "node:path";
|
|
5
|
+
import { homedir } from "node:os";
|
|
6
|
+
function createRecentSessionsPerfStats() {
|
|
7
|
+
return {
|
|
8
|
+
claudeProjectDirs: 0,
|
|
9
|
+
claudeIndexDirs: 0,
|
|
10
|
+
claudeJsonlOnlyDirs: 0,
|
|
11
|
+
claudeIndexEntries: 0,
|
|
12
|
+
claudeJsonlFilesTotal: 0,
|
|
13
|
+
claudeJsonlFilesExcluded: 0,
|
|
14
|
+
claudeJsonlFilesRead: 0,
|
|
15
|
+
claudeJsonlEntries: 0,
|
|
16
|
+
codexFilesTotal: 0,
|
|
17
|
+
codexFilesRead: 0,
|
|
18
|
+
codexEntries: 0,
|
|
19
|
+
claudeNamedOnlyFastPathUsed: false,
|
|
20
|
+
counts: {
|
|
21
|
+
beforeArchive: 0,
|
|
22
|
+
afterArchive: 0,
|
|
23
|
+
afterProvider: 0,
|
|
24
|
+
afterNamedOnly: 0,
|
|
25
|
+
afterSearch: 0,
|
|
26
|
+
returned: 0,
|
|
27
|
+
},
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
function markDuration(durations, key, startedAt) {
|
|
31
|
+
const elapsedMs = Number(process.hrtime.bigint() - startedAt) / 1_000_000;
|
|
32
|
+
durations[key] = elapsedMs;
|
|
33
|
+
}
|
|
34
|
+
function shouldLogRecentSessionsPerf() {
|
|
35
|
+
const v = process.env.BRIDGE_RECENT_SESSIONS_PROFILE;
|
|
36
|
+
return v === "1" || v === "true";
|
|
37
|
+
}
|
|
38
|
+
function logRecentSessionsPerf(options, durations, stats) {
|
|
39
|
+
if (!shouldLogRecentSessionsPerf())
|
|
40
|
+
return;
|
|
41
|
+
const projectPath = options.projectPath;
|
|
42
|
+
const projectPathLabel = projectPath
|
|
43
|
+
? projectPath.length > 72
|
|
44
|
+
? `${projectPath.slice(0, 69)}...`
|
|
45
|
+
: projectPath
|
|
46
|
+
: "";
|
|
47
|
+
const payload = {
|
|
48
|
+
options: {
|
|
49
|
+
limit: options.limit ?? 20,
|
|
50
|
+
offset: options.offset ?? 0,
|
|
51
|
+
projectPath: projectPathLabel || undefined,
|
|
52
|
+
provider: options.provider ?? "all",
|
|
53
|
+
namedOnly: options.namedOnly ?? false,
|
|
54
|
+
searchQuery: options.searchQuery ? "<set>" : "<none>",
|
|
55
|
+
archivedSessionIds: options.archivedSessionIds?.size ?? 0,
|
|
56
|
+
},
|
|
57
|
+
durationsMs: Object.fromEntries(Object.entries(durations).map(([k, v]) => [k, Number(v.toFixed(1))])),
|
|
58
|
+
stats,
|
|
59
|
+
};
|
|
60
|
+
console.info(`[recent-sessions][perf] ${JSON.stringify(payload)}`);
|
|
61
|
+
}
|
|
62
|
+
/** Convert a filesystem path to Claude's project directory slug (e.g. /foo/bar → -foo-bar). */
|
|
63
|
+
export function pathToSlug(p) {
|
|
64
|
+
return p.replaceAll("/", "-").replaceAll("_", "-");
|
|
65
|
+
}
|
|
66
|
+
/**
|
|
67
|
+
* Normalize a worktree cwd back to the main project path.
|
|
68
|
+
* e.g. /path/to/project-worktrees/branch → /path/to/project
|
|
69
|
+
*/
|
|
70
|
+
export function normalizeWorktreePath(p) {
|
|
71
|
+
const match = p.match(/^(.+)-worktrees\/[^/]+$/);
|
|
72
|
+
return match?.[1] ?? p;
|
|
73
|
+
}
|
|
74
|
+
/**
|
|
75
|
+
* Check if a directory slug represents a worktree directory for a given project slug.
|
|
76
|
+
* e.g. "-Users-x-proj-worktrees-branch" is a worktree dir for "-Users-x-proj".
|
|
77
|
+
*/
|
|
78
|
+
export function isWorktreeSlug(dirSlug, projectSlug) {
|
|
79
|
+
return dirSlug.startsWith(projectSlug + "-worktrees-");
|
|
80
|
+
}
|
|
81
|
+
/** Concurrency limit for parallel file reads to avoid fd exhaustion. */
|
|
82
|
+
const PARALLEL_FILE_READ_LIMIT = 32;
|
|
83
|
+
/** Head/Tail byte sizes for partial JSONL reads. */
|
|
84
|
+
const HEAD_BYTES = 16384; // 16KB — covers first user entry + metadata
|
|
85
|
+
const TAIL_BYTES = 8192; // 8KB — covers last entries for modified/lastPrompt
|
|
86
|
+
/**
|
|
87
|
+
* Run async tasks with a concurrency limit.
|
|
88
|
+
* Returns results in the same order as the input tasks.
|
|
89
|
+
*/
|
|
90
|
+
async function parallelMap(items, concurrency, fn) {
|
|
91
|
+
const results = new Array(items.length);
|
|
92
|
+
let nextIndex = 0;
|
|
93
|
+
async function worker() {
|
|
94
|
+
while (nextIndex < items.length) {
|
|
95
|
+
const i = nextIndex++;
|
|
96
|
+
results[i] = await fn(items[i]);
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
const workers = Array.from({ length: Math.min(concurrency, items.length) }, () => worker());
|
|
100
|
+
await Promise.all(workers);
|
|
101
|
+
return results;
|
|
102
|
+
}
|
|
103
|
+
// Regexes for fast field extraction without JSON.parse
|
|
104
|
+
const RE_TYPE_USER = /"type"\s*:\s*"user"/;
|
|
105
|
+
const RE_TYPE_ASSISTANT = /"type"\s*:\s*"assistant"/;
|
|
106
|
+
const RE_TIMESTAMP = /"timestamp"\s*:\s*"([^"]+)"/;
|
|
107
|
+
const RE_GIT_BRANCH = /"gitBranch"\s*:\s*"([^"]+)"/;
|
|
108
|
+
const RE_CWD = /"cwd"\s*:\s*"([^"]+)"/;
|
|
109
|
+
const RE_IS_SIDECHAIN = /"isSidechain"\s*:\s*true/;
|
|
110
|
+
const RE_TYPE_CUSTOM_TITLE = /"type"\s*:\s*"custom-title"/;
|
|
111
|
+
const RE_CUSTOM_TITLE = /"customTitle"\s*:\s*"([^"]+)"/;
|
|
112
|
+
/**
|
|
113
|
+
* Detect system-injected messages that should be skipped when determining
|
|
114
|
+
* the user's first/last prompt text (e.g. local-command-caveat, stderr/stdout
|
|
115
|
+
* captures, team notifications, skill loading).
|
|
116
|
+
*/
|
|
117
|
+
const RE_SYSTEM_INJECTED = /^<(?:local-command-caveat|local-command-std(?:err|out)|task-notification|teammate-message|bash-(?:input|stdout))>/;
|
|
118
|
+
function isSystemInjectedText(text) {
|
|
119
|
+
return RE_SYSTEM_INJECTED.test(text) || text.startsWith("Base directory for this skill:");
|
|
120
|
+
}
|
|
121
|
+
/** Extract user prompt text from a parsed JSONL entry. */
|
|
122
|
+
function extractUserPromptText(entry) {
|
|
123
|
+
const message = entry.message;
|
|
124
|
+
if (!message?.content)
|
|
125
|
+
return "";
|
|
126
|
+
if (typeof message.content === "string")
|
|
127
|
+
return message.content;
|
|
128
|
+
if (Array.isArray(message.content)) {
|
|
129
|
+
const textBlock = message.content.find((c) => c.type === "text" && c.text);
|
|
130
|
+
return textBlock?.text ?? "";
|
|
131
|
+
}
|
|
132
|
+
return "";
|
|
133
|
+
}
|
|
134
|
+
function parseFromChunks(sessionId, head, tail) {
|
|
135
|
+
let firstPrompt = "";
|
|
136
|
+
let lastPrompt = "";
|
|
137
|
+
let created = "";
|
|
138
|
+
let modified = "";
|
|
139
|
+
let gitBranch = "";
|
|
140
|
+
let projectPath = "";
|
|
141
|
+
let customTitle = "";
|
|
142
|
+
let isSidechain = false;
|
|
143
|
+
let hasAnyMessage = false;
|
|
144
|
+
let headFoundFirstPrompt = false;
|
|
145
|
+
let headFoundProjectPath = false;
|
|
146
|
+
let headFoundGitBranch = false;
|
|
147
|
+
// --- Scan head lines ---
|
|
148
|
+
const headLines = head.split("\n");
|
|
149
|
+
for (const line of headLines) {
|
|
150
|
+
if (!line.trim())
|
|
151
|
+
continue;
|
|
152
|
+
// Extract custom-title (typically the first line in the JSONL)
|
|
153
|
+
if (!customTitle && RE_TYPE_CUSTOM_TITLE.test(line)) {
|
|
154
|
+
const ctMatch = line.match(RE_CUSTOM_TITLE);
|
|
155
|
+
if (ctMatch)
|
|
156
|
+
customTitle = ctMatch[1];
|
|
157
|
+
continue;
|
|
158
|
+
}
|
|
159
|
+
const isUser = RE_TYPE_USER.test(line);
|
|
160
|
+
const isAssistant = !isUser && RE_TYPE_ASSISTANT.test(line);
|
|
161
|
+
if (!isUser && !isAssistant)
|
|
162
|
+
continue;
|
|
163
|
+
hasAnyMessage = true;
|
|
164
|
+
const tsMatch = line.match(RE_TIMESTAMP);
|
|
165
|
+
if (tsMatch) {
|
|
166
|
+
if (!created)
|
|
167
|
+
created = tsMatch[1];
|
|
168
|
+
modified = tsMatch[1];
|
|
169
|
+
}
|
|
170
|
+
if (!gitBranch) {
|
|
171
|
+
const gbMatch = line.match(RE_GIT_BRANCH);
|
|
172
|
+
if (gbMatch) {
|
|
173
|
+
gitBranch = gbMatch[1];
|
|
174
|
+
headFoundGitBranch = true;
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
if (!projectPath) {
|
|
178
|
+
const cwdMatch = line.match(RE_CWD);
|
|
179
|
+
if (cwdMatch) {
|
|
180
|
+
projectPath = normalizeWorktreePath(cwdMatch[1]);
|
|
181
|
+
headFoundProjectPath = true;
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
if (!isSidechain && RE_IS_SIDECHAIN.test(line)) {
|
|
185
|
+
isSidechain = true;
|
|
186
|
+
}
|
|
187
|
+
if (isUser && !firstPrompt) {
|
|
188
|
+
// JSON.parse only user lines to extract prompt text, skipping
|
|
189
|
+
// system-injected messages (e.g. <local-command-caveat>)
|
|
190
|
+
try {
|
|
191
|
+
const entry = JSON.parse(line);
|
|
192
|
+
const text = extractUserPromptText(entry);
|
|
193
|
+
if (text && !isSystemInjectedText(text)) {
|
|
194
|
+
firstPrompt = text;
|
|
195
|
+
headFoundFirstPrompt = true;
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
catch { /* skip */ }
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
// --- Scan tail lines (if separate from head) ---
|
|
202
|
+
if (tail) {
|
|
203
|
+
const tailLines = tail.split("\n");
|
|
204
|
+
// Find last timestamp and last user prompt from tail (scan in reverse)
|
|
205
|
+
let lastUserLine = null;
|
|
206
|
+
for (let i = tailLines.length - 1; i >= 0; i--) {
|
|
207
|
+
const line = tailLines[i];
|
|
208
|
+
if (!line.trim())
|
|
209
|
+
continue;
|
|
210
|
+
const isUser = RE_TYPE_USER.test(line);
|
|
211
|
+
const isAssistant = !isUser && RE_TYPE_ASSISTANT.test(line);
|
|
212
|
+
if (!isUser && !isAssistant)
|
|
213
|
+
continue;
|
|
214
|
+
hasAnyMessage = true;
|
|
215
|
+
// Get the last modified timestamp
|
|
216
|
+
if (!modified || true) {
|
|
217
|
+
// Always update modified from tail (tail is later in file)
|
|
218
|
+
const tsMatch = line.match(RE_TIMESTAMP);
|
|
219
|
+
if (tsMatch) {
|
|
220
|
+
modified = tsMatch[1];
|
|
221
|
+
// We found the last message — we're done with timestamps
|
|
222
|
+
if (isUser && !lastUserLine)
|
|
223
|
+
lastUserLine = line;
|
|
224
|
+
break;
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
// Also find last user line if not found in reverse timestamp scan
|
|
229
|
+
if (!lastUserLine) {
|
|
230
|
+
for (let i = tailLines.length - 1; i >= 0; i--) {
|
|
231
|
+
const line = tailLines[i];
|
|
232
|
+
if (!line.trim())
|
|
233
|
+
continue;
|
|
234
|
+
if (RE_TYPE_USER.test(line)) {
|
|
235
|
+
lastUserLine = line;
|
|
236
|
+
break;
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
// JSON.parse only the last user line for lastPrompt
|
|
241
|
+
if (lastUserLine) {
|
|
242
|
+
try {
|
|
243
|
+
const entry = JSON.parse(lastUserLine);
|
|
244
|
+
const text = extractUserPromptText(entry);
|
|
245
|
+
if (text && !isSystemInjectedText(text))
|
|
246
|
+
lastPrompt = text;
|
|
247
|
+
}
|
|
248
|
+
catch { /* skip */ }
|
|
249
|
+
}
|
|
250
|
+
// Fill in metadata from tail if head didn't have it
|
|
251
|
+
if (!gitBranch || !projectPath) {
|
|
252
|
+
for (const line of tailLines) {
|
|
253
|
+
if (!line.trim())
|
|
254
|
+
continue;
|
|
255
|
+
if (!RE_TYPE_USER.test(line) && !RE_TYPE_ASSISTANT.test(line))
|
|
256
|
+
continue;
|
|
257
|
+
if (!gitBranch) {
|
|
258
|
+
const gbMatch = line.match(RE_GIT_BRANCH);
|
|
259
|
+
if (gbMatch)
|
|
260
|
+
gitBranch = gbMatch[1];
|
|
261
|
+
}
|
|
262
|
+
if (!projectPath) {
|
|
263
|
+
const cwdMatch = line.match(RE_CWD);
|
|
264
|
+
if (cwdMatch)
|
|
265
|
+
projectPath = normalizeWorktreePath(cwdMatch[1]);
|
|
266
|
+
}
|
|
267
|
+
if (gitBranch && projectPath)
|
|
268
|
+
break;
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
if (!hasAnyMessage) {
|
|
273
|
+
return {
|
|
274
|
+
entry: null,
|
|
275
|
+
headFoundFirstPrompt,
|
|
276
|
+
headFoundProjectPath,
|
|
277
|
+
headFoundGitBranch,
|
|
278
|
+
};
|
|
279
|
+
}
|
|
280
|
+
return {
|
|
281
|
+
entry: {
|
|
282
|
+
sessionId,
|
|
283
|
+
provider: "claude",
|
|
284
|
+
firstPrompt,
|
|
285
|
+
...(lastPrompt && lastPrompt !== firstPrompt ? { lastPrompt } : {}),
|
|
286
|
+
...(customTitle ? { name: customTitle } : {}),
|
|
287
|
+
created,
|
|
288
|
+
modified,
|
|
289
|
+
gitBranch,
|
|
290
|
+
projectPath,
|
|
291
|
+
isSidechain,
|
|
292
|
+
},
|
|
293
|
+
headFoundFirstPrompt,
|
|
294
|
+
headFoundProjectPath,
|
|
295
|
+
headFoundGitBranch,
|
|
296
|
+
};
|
|
297
|
+
}
|
|
298
|
+
/**
|
|
299
|
+
* Fast parse a Claude JSONL file using partial (head+tail) reads.
|
|
300
|
+
* Only reads the first 16KB and last 8KB of the file, avoiding full I/O.
|
|
301
|
+
* JSON.parse is called at most twice (first + last user lines).
|
|
302
|
+
*/
|
|
303
|
+
async function parseClaudeJsonlFileFast(sessionId, filePath) {
|
|
304
|
+
let fh;
|
|
305
|
+
try {
|
|
306
|
+
fh = await open(filePath, "r");
|
|
307
|
+
}
|
|
308
|
+
catch {
|
|
309
|
+
return null;
|
|
310
|
+
}
|
|
311
|
+
let parsedChunks;
|
|
312
|
+
try {
|
|
313
|
+
const fileStat = await fh.stat();
|
|
314
|
+
const fileSize = fileStat.size;
|
|
315
|
+
if (fileSize === 0)
|
|
316
|
+
return null;
|
|
317
|
+
// Small files: read entirely (no benefit from partial reads)
|
|
318
|
+
if (fileSize <= HEAD_BYTES + TAIL_BYTES) {
|
|
319
|
+
const buf = Buffer.alloc(fileSize);
|
|
320
|
+
await fh.read(buf, 0, fileSize, 0);
|
|
321
|
+
return parseFromChunks(sessionId, buf.toString("utf-8"), null).entry;
|
|
322
|
+
}
|
|
323
|
+
// Head read
|
|
324
|
+
const headBuf = Buffer.alloc(HEAD_BYTES);
|
|
325
|
+
await fh.read(headBuf, 0, HEAD_BYTES, 0);
|
|
326
|
+
const headStr = headBuf.toString("utf-8");
|
|
327
|
+
// Tail read — discard the first partial line
|
|
328
|
+
const tailBuf = Buffer.alloc(TAIL_BYTES);
|
|
329
|
+
await fh.read(tailBuf, 0, TAIL_BYTES, fileSize - TAIL_BYTES);
|
|
330
|
+
const tailRaw = tailBuf.toString("utf-8");
|
|
331
|
+
const firstNewline = tailRaw.indexOf("\n");
|
|
332
|
+
const cleanTail = firstNewline >= 0 ? tailRaw.slice(firstNewline + 1) : "";
|
|
333
|
+
parsedChunks = parseFromChunks(sessionId, headStr, cleanTail);
|
|
334
|
+
}
|
|
335
|
+
finally {
|
|
336
|
+
await fh.close();
|
|
337
|
+
}
|
|
338
|
+
const result = parsedChunks.entry;
|
|
339
|
+
// If the first large JSONL line pushed early metadata outside HEAD_BYTES,
|
|
340
|
+
// the tail supplement may incorrectly pick a later cwd/gitBranch. Stream
|
|
341
|
+
// from the start whenever head parsing missed these fields so resume uses
|
|
342
|
+
// the original session cwd rather than a later in-session directory.
|
|
343
|
+
if (result
|
|
344
|
+
&& (!result.firstPrompt
|
|
345
|
+
|| !parsedChunks.headFoundProjectPath
|
|
346
|
+
|| !parsedChunks.headFoundGitBranch)) {
|
|
347
|
+
const missing = await extractMissingFieldsStreaming(filePath, !result.firstPrompt, !parsedChunks.headFoundProjectPath, !parsedChunks.headFoundGitBranch);
|
|
348
|
+
if (!result.firstPrompt && missing.firstPrompt) {
|
|
349
|
+
result.firstPrompt = missing.firstPrompt;
|
|
350
|
+
}
|
|
351
|
+
if (missing.projectPath) {
|
|
352
|
+
result.projectPath = missing.projectPath;
|
|
353
|
+
}
|
|
354
|
+
if (missing.gitBranch) {
|
|
355
|
+
result.gitBranch = missing.gitBranch;
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
return result;
|
|
359
|
+
}
|
|
360
|
+
async function hydrateClaudeIndexedEntry(dirPath, entry) {
|
|
361
|
+
const base = {
|
|
362
|
+
sessionId: entry.sessionId,
|
|
363
|
+
provider: "claude",
|
|
364
|
+
...(entry.customTitle ? { name: entry.customTitle } : {}),
|
|
365
|
+
...(entry.summary ? { summary: entry.summary } : {}),
|
|
366
|
+
firstPrompt: entry.firstPrompt ?? "",
|
|
367
|
+
created: entry.created ?? "",
|
|
368
|
+
modified: entry.modified ?? "",
|
|
369
|
+
gitBranch: entry.gitBranch ?? "",
|
|
370
|
+
projectPath: normalizeWorktreePath(entry.projectPath ?? ""),
|
|
371
|
+
isSidechain: entry.isSidechain ?? false,
|
|
372
|
+
};
|
|
373
|
+
const needsJsonlRepair = !base.firstPrompt ||
|
|
374
|
+
!base.projectPath ||
|
|
375
|
+
!base.gitBranch ||
|
|
376
|
+
!base.created ||
|
|
377
|
+
!base.modified;
|
|
378
|
+
if (!needsJsonlRepair)
|
|
379
|
+
return base;
|
|
380
|
+
const fallbackPath = entry.fullPath || join(dirPath, `${entry.sessionId}.jsonl`);
|
|
381
|
+
const parsed = await parseClaudeJsonlFileFast(entry.sessionId, fallbackPath);
|
|
382
|
+
if (!parsed)
|
|
383
|
+
return base;
|
|
384
|
+
return {
|
|
385
|
+
...base,
|
|
386
|
+
firstPrompt: base.firstPrompt || parsed.firstPrompt,
|
|
387
|
+
created: base.created || parsed.created,
|
|
388
|
+
modified: base.modified || parsed.modified,
|
|
389
|
+
gitBranch: base.gitBranch || parsed.gitBranch,
|
|
390
|
+
projectPath: base.projectPath || parsed.projectPath,
|
|
391
|
+
isSidechain: base.isSidechain || parsed.isSidechain,
|
|
392
|
+
...(base.lastPrompt || !parsed.lastPrompt ? {} : { lastPrompt: parsed.lastPrompt }),
|
|
393
|
+
};
|
|
394
|
+
}
|
|
395
|
+
/**
|
|
396
|
+
* Fallback: stream a JSONL file line-by-line to find missing fields.
|
|
397
|
+
* Called when the fast head-read could not extract firstPrompt/projectPath
|
|
398
|
+
* (e.g. the first user message line is very large due to embedded images
|
|
399
|
+
* and got truncated within HEAD_BYTES).
|
|
400
|
+
* Reads only until all needed fields are found, then stops.
|
|
401
|
+
*/
|
|
402
|
+
async function extractMissingFieldsStreaming(filePath, needFirstPrompt, needProjectPath, needGitBranch) {
|
|
403
|
+
return new Promise((resolve) => {
|
|
404
|
+
const stream = createReadStream(filePath, { encoding: "utf-8" });
|
|
405
|
+
const rl = createInterface({ input: stream, crlfDelay: Infinity });
|
|
406
|
+
let firstPrompt = "";
|
|
407
|
+
let projectPath = "";
|
|
408
|
+
let gitBranch = "";
|
|
409
|
+
let done = false;
|
|
410
|
+
function checkDone() {
|
|
411
|
+
const promptDone = !needFirstPrompt || firstPrompt !== "";
|
|
412
|
+
const pathDone = !needProjectPath || projectPath !== "";
|
|
413
|
+
const branchDone = !needGitBranch || gitBranch !== "";
|
|
414
|
+
if (promptDone && pathDone && branchDone) {
|
|
415
|
+
done = true;
|
|
416
|
+
rl.close();
|
|
417
|
+
stream.destroy();
|
|
418
|
+
resolve({ firstPrompt, projectPath, gitBranch });
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
rl.on("line", (line) => {
|
|
422
|
+
if (done)
|
|
423
|
+
return;
|
|
424
|
+
const isUser = RE_TYPE_USER.test(line);
|
|
425
|
+
const isAssistant = !isUser && RE_TYPE_ASSISTANT.test(line);
|
|
426
|
+
if (!isUser && !isAssistant)
|
|
427
|
+
return;
|
|
428
|
+
// Extract projectPath/gitBranch from cwd field (available on any user/assistant line)
|
|
429
|
+
if (needProjectPath && !projectPath) {
|
|
430
|
+
const cwdMatch = line.match(RE_CWD);
|
|
431
|
+
if (cwdMatch) {
|
|
432
|
+
projectPath = normalizeWorktreePath(cwdMatch[1]);
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
if (needGitBranch && !gitBranch) {
|
|
436
|
+
const gbMatch = line.match(RE_GIT_BRANCH);
|
|
437
|
+
if (gbMatch)
|
|
438
|
+
gitBranch = gbMatch[1];
|
|
439
|
+
}
|
|
440
|
+
// Extract firstPrompt from user lines
|
|
441
|
+
if (needFirstPrompt && isUser && !firstPrompt) {
|
|
442
|
+
try {
|
|
443
|
+
const entry = JSON.parse(line);
|
|
444
|
+
const text = extractUserPromptText(entry);
|
|
445
|
+
if (text && !isSystemInjectedText(text)) {
|
|
446
|
+
firstPrompt = text;
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
catch {
|
|
450
|
+
// Line might be malformed — skip
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
checkDone();
|
|
454
|
+
});
|
|
455
|
+
rl.on("close", () => {
|
|
456
|
+
if (!done)
|
|
457
|
+
resolve({ firstPrompt, projectPath, gitBranch });
|
|
458
|
+
});
|
|
459
|
+
stream.on("error", () => {
|
|
460
|
+
if (!done)
|
|
461
|
+
resolve({ firstPrompt, projectPath, gitBranch });
|
|
462
|
+
});
|
|
463
|
+
});
|
|
464
|
+
}
|
|
465
|
+
/**
|
|
466
|
+
* Maximum bytes to read from file tail when searching for lastPrompt.
|
|
467
|
+
* Claude sessions often have large tool-result lines (diffs, etc.) near the
|
|
468
|
+
* end, so 8KB is rarely enough. We grow the read window in steps up to this
|
|
469
|
+
* cap to balance speed and coverage.
|
|
470
|
+
*/
|
|
471
|
+
const LAST_PROMPT_MAX_TAIL = 131072; // 128KB
|
|
472
|
+
/**
|
|
473
|
+
* Fast tail-read to extract the last user prompt from a JSONL file.
|
|
474
|
+
* Starts at TAIL_BYTES and doubles up to LAST_PROMPT_MAX_TAIL until a real
|
|
475
|
+
* user text prompt is found. No full-file scan is ever performed.
|
|
476
|
+
* Used to supplement sessions-index.json entries that lack lastPrompt.
|
|
477
|
+
*/
|
|
478
|
+
async function extractLastPromptFromTail(filePath) {
|
|
479
|
+
let fh;
|
|
480
|
+
try {
|
|
481
|
+
fh = await open(filePath, "r");
|
|
482
|
+
}
|
|
483
|
+
catch {
|
|
484
|
+
return "";
|
|
485
|
+
}
|
|
486
|
+
try {
|
|
487
|
+
const fileSize = (await fh.stat()).size;
|
|
488
|
+
if (fileSize === 0)
|
|
489
|
+
return "";
|
|
490
|
+
// Grow tail window: 8KB → 16KB → 32KB → 64KB → 128KB
|
|
491
|
+
for (let tailSize = TAIL_BYTES; tailSize <= LAST_PROMPT_MAX_TAIL; tailSize *= 2) {
|
|
492
|
+
const readSize = Math.min(fileSize, tailSize);
|
|
493
|
+
const readOffset = fileSize - readSize;
|
|
494
|
+
const buf = Buffer.alloc(readSize);
|
|
495
|
+
await fh.read(buf, 0, readSize, readOffset);
|
|
496
|
+
let raw = buf.toString("utf-8");
|
|
497
|
+
// Discard the first partial line if reading from middle of file
|
|
498
|
+
if (readOffset > 0) {
|
|
499
|
+
const nl = raw.indexOf("\n");
|
|
500
|
+
if (nl >= 0)
|
|
501
|
+
raw = raw.slice(nl + 1);
|
|
502
|
+
}
|
|
503
|
+
// Scan in reverse to find the last user line with real text
|
|
504
|
+
const lines = raw.split("\n");
|
|
505
|
+
for (let i = lines.length - 1; i >= 0; i--) {
|
|
506
|
+
const line = lines[i];
|
|
507
|
+
if (!line.trim())
|
|
508
|
+
continue;
|
|
509
|
+
if (!RE_TYPE_USER.test(line))
|
|
510
|
+
continue;
|
|
511
|
+
try {
|
|
512
|
+
const entry = JSON.parse(line);
|
|
513
|
+
const text = extractUserPromptText(entry);
|
|
514
|
+
if (text && !isSystemInjectedText(text))
|
|
515
|
+
return text;
|
|
516
|
+
}
|
|
517
|
+
catch {
|
|
518
|
+
// Truncated line — skip
|
|
519
|
+
}
|
|
520
|
+
}
|
|
521
|
+
// If we already read the entire file, stop
|
|
522
|
+
if (readSize >= fileSize)
|
|
523
|
+
break;
|
|
524
|
+
}
|
|
525
|
+
return "";
|
|
526
|
+
}
|
|
527
|
+
finally {
|
|
528
|
+
await fh.close();
|
|
529
|
+
}
|
|
530
|
+
}
|
|
531
|
+
/**
|
|
532
|
+
* Scan a directory for JSONL session files and create SessionIndexEntry objects.
|
|
533
|
+
* Used as a fallback when sessions-index.json is missing (common for worktree sessions).
|
|
534
|
+
* File reads are parallelized and use head+tail partial reads for performance.
|
|
535
|
+
*/
|
|
536
|
+
export async function scanJsonlDir(dirPath, options = {}) {
|
|
537
|
+
const scanStats = options.stats;
|
|
538
|
+
let files;
|
|
539
|
+
try {
|
|
540
|
+
files = await readdir(dirPath);
|
|
541
|
+
}
|
|
542
|
+
catch {
|
|
543
|
+
return [];
|
|
544
|
+
}
|
|
545
|
+
// Filter to JSONL files and apply exclusions
|
|
546
|
+
const targets = [];
|
|
547
|
+
for (const file of files) {
|
|
548
|
+
if (!file.endsWith(".jsonl"))
|
|
549
|
+
continue;
|
|
550
|
+
scanStats && (scanStats.filesTotal += 1);
|
|
551
|
+
const sessionId = basename(file, ".jsonl");
|
|
552
|
+
if (options.excludeSessionIds?.has(sessionId)) {
|
|
553
|
+
scanStats && (scanStats.filesExcluded += 1);
|
|
554
|
+
continue;
|
|
555
|
+
}
|
|
556
|
+
targets.push({ sessionId, filePath: join(dirPath, file) });
|
|
557
|
+
}
|
|
558
|
+
// Read and parse files in parallel using fast head+tail reads
|
|
559
|
+
const results = await parallelMap(targets, PARALLEL_FILE_READ_LIMIT, async ({ sessionId, filePath }) => {
|
|
560
|
+
const entry = await parseClaudeJsonlFileFast(sessionId, filePath);
|
|
561
|
+
if (entry) {
|
|
562
|
+
scanStats && (scanStats.filesRead += 1);
|
|
563
|
+
scanStats && (scanStats.entriesReturned += 1);
|
|
564
|
+
}
|
|
565
|
+
else {
|
|
566
|
+
scanStats && (scanStats.filesRead += 1);
|
|
567
|
+
}
|
|
568
|
+
return entry;
|
|
569
|
+
});
|
|
570
|
+
return results.filter((e) => e !== null);
|
|
571
|
+
}
|
|
572
|
+
export async function getAllRecentSessions(options = {}) {
|
|
573
|
+
const totalStartedAt = process.hrtime.bigint();
|
|
574
|
+
const durations = {};
|
|
575
|
+
const perfStats = createRecentSessionsPerfStats();
|
|
576
|
+
const limit = options.limit ?? 20;
|
|
577
|
+
const offset = options.offset ?? 0;
|
|
578
|
+
const filterProjectPath = options.projectPath;
|
|
579
|
+
const shouldLoadClaude = options.provider !== "codex";
|
|
580
|
+
const shouldLoadCodex = options.provider !== "claude";
|
|
581
|
+
const includeOnlyNamedClaude = options.namedOnly === true;
|
|
582
|
+
const projectsDir = join(homedir(), ".claude", "projects");
|
|
583
|
+
const entries = [];
|
|
584
|
+
let projectDirs;
|
|
585
|
+
const loadProjectDirsStartedAt = process.hrtime.bigint();
|
|
586
|
+
try {
|
|
587
|
+
projectDirs = await readdir(projectsDir);
|
|
588
|
+
}
|
|
589
|
+
catch {
|
|
590
|
+
// ~/.claude/projects doesn't exist
|
|
591
|
+
projectDirs = [];
|
|
592
|
+
}
|
|
593
|
+
markDuration(durations, "loadClaudeProjectDirs", loadProjectDirsStartedAt);
|
|
594
|
+
// Compute worktree slug prefix for projectPath filtering
|
|
595
|
+
const projectSlug = filterProjectPath
|
|
596
|
+
? pathToSlug(filterProjectPath)
|
|
597
|
+
: null;
|
|
598
|
+
// --- Load Claude and Codex sessions in parallel ---
|
|
599
|
+
const loadClaudeStartedAt = process.hrtime.bigint();
|
|
600
|
+
const claudeEntriesPromise = (async () => {
|
|
601
|
+
if (!shouldLoadClaude)
|
|
602
|
+
return [];
|
|
603
|
+
// Filter directories first (sync), then process in parallel
|
|
604
|
+
const relevantDirs = [];
|
|
605
|
+
for (const dirName of projectDirs) {
|
|
606
|
+
if (dirName.startsWith("."))
|
|
607
|
+
continue;
|
|
608
|
+
const isProjectDir = projectSlug ? dirName === projectSlug : false;
|
|
609
|
+
const isWorktreeDir = projectSlug
|
|
610
|
+
? isWorktreeSlug(dirName, projectSlug)
|
|
611
|
+
: false;
|
|
612
|
+
if (filterProjectPath && !isProjectDir && !isWorktreeDir)
|
|
613
|
+
continue;
|
|
614
|
+
relevantDirs.push(dirName);
|
|
615
|
+
}
|
|
616
|
+
perfStats.claudeProjectDirs = relevantDirs.length;
|
|
617
|
+
// Process directories in parallel
|
|
618
|
+
const dirResults = await parallelMap(relevantDirs, PARALLEL_FILE_READ_LIMIT, async (dirName) => {
|
|
619
|
+
const dirPath = join(projectsDir, dirName);
|
|
620
|
+
const indexPath = join(dirPath, "sessions-index.json");
|
|
621
|
+
let raw = null;
|
|
622
|
+
try {
|
|
623
|
+
raw = await readFile(indexPath, "utf-8");
|
|
624
|
+
}
|
|
625
|
+
catch {
|
|
626
|
+
// No sessions-index.json — will try JSONL scan for worktree dirs
|
|
627
|
+
}
|
|
628
|
+
const result = {
|
|
629
|
+
entries: [],
|
|
630
|
+
indexDirs: 0,
|
|
631
|
+
indexEntries: 0,
|
|
632
|
+
jsonlOnlyDirs: 0,
|
|
633
|
+
jsonlStats: { filesTotal: 0, filesExcluded: 0, filesRead: 0, entriesReturned: 0 },
|
|
634
|
+
};
|
|
635
|
+
if (raw !== null) {
|
|
636
|
+
result.indexDirs = 1;
|
|
637
|
+
let index;
|
|
638
|
+
try {
|
|
639
|
+
index = JSON.parse(raw);
|
|
640
|
+
}
|
|
641
|
+
catch {
|
|
642
|
+
console.error(`[sessions-index] Failed to parse ${indexPath}`);
|
|
643
|
+
return result;
|
|
644
|
+
}
|
|
645
|
+
if (!Array.isArray(index.entries))
|
|
646
|
+
return result;
|
|
647
|
+
const indexedIds = new Set();
|
|
648
|
+
for (const entry of index.entries) {
|
|
649
|
+
const name = entry.customTitle || undefined;
|
|
650
|
+
if (includeOnlyNamedClaude && (!name || name === "")) {
|
|
651
|
+
continue;
|
|
652
|
+
}
|
|
653
|
+
indexedIds.add(entry.sessionId);
|
|
654
|
+
result.entries.push(await hydrateClaudeIndexedEntry(dirPath, entry));
|
|
655
|
+
result.indexEntries += 1;
|
|
656
|
+
}
|
|
657
|
+
if (!includeOnlyNamedClaude) {
|
|
658
|
+
const scanned = await scanJsonlDir(dirPath, {
|
|
659
|
+
excludeSessionIds: indexedIds,
|
|
660
|
+
stats: result.jsonlStats,
|
|
661
|
+
});
|
|
662
|
+
result.entries.push(...scanned);
|
|
663
|
+
}
|
|
664
|
+
else {
|
|
665
|
+
perfStats.claudeNamedOnlyFastPathUsed = true;
|
|
666
|
+
}
|
|
667
|
+
}
|
|
668
|
+
else {
|
|
669
|
+
if (includeOnlyNamedClaude) {
|
|
670
|
+
perfStats.claudeNamedOnlyFastPathUsed = true;
|
|
671
|
+
return result;
|
|
672
|
+
}
|
|
673
|
+
result.jsonlOnlyDirs = 1;
|
|
674
|
+
const scanned = await scanJsonlDir(dirPath, { stats: result.jsonlStats });
|
|
675
|
+
result.entries.push(...scanned);
|
|
676
|
+
}
|
|
677
|
+
return result;
|
|
678
|
+
});
|
|
679
|
+
// Aggregate stats and entries
|
|
680
|
+
const allEntries = [];
|
|
681
|
+
for (const r of dirResults) {
|
|
682
|
+
allEntries.push(...r.entries);
|
|
683
|
+
perfStats.claudeIndexDirs += r.indexDirs;
|
|
684
|
+
perfStats.claudeIndexEntries += r.indexEntries;
|
|
685
|
+
perfStats.claudeJsonlOnlyDirs += r.jsonlOnlyDirs;
|
|
686
|
+
perfStats.claudeJsonlFilesTotal += r.jsonlStats.filesTotal;
|
|
687
|
+
perfStats.claudeJsonlFilesExcluded += r.jsonlStats.filesExcluded;
|
|
688
|
+
perfStats.claudeJsonlFilesRead += r.jsonlStats.filesRead;
|
|
689
|
+
perfStats.claudeJsonlEntries += r.jsonlStats.entriesReturned;
|
|
690
|
+
}
|
|
691
|
+
return allEntries;
|
|
692
|
+
})();
|
|
693
|
+
const loadCodexStartedAt = process.hrtime.bigint();
|
|
694
|
+
const codexEntriesPromise = (async () => {
|
|
695
|
+
if (!shouldLoadCodex)
|
|
696
|
+
return [];
|
|
697
|
+
const codexPerf = {
|
|
698
|
+
filesTotal: 0,
|
|
699
|
+
filesRead: 0,
|
|
700
|
+
entriesReturned: 0,
|
|
701
|
+
};
|
|
702
|
+
const codexEntries = await getAllRecentCodexSessions({
|
|
703
|
+
projectPath: filterProjectPath,
|
|
704
|
+
perfStats: codexPerf,
|
|
705
|
+
});
|
|
706
|
+
perfStats.codexFilesTotal = codexPerf.filesTotal;
|
|
707
|
+
perfStats.codexFilesRead = codexPerf.filesRead;
|
|
708
|
+
perfStats.codexEntries = codexPerf.entriesReturned;
|
|
709
|
+
return codexEntries;
|
|
710
|
+
})();
|
|
711
|
+
// Wait for both Claude and Codex loading to complete in parallel
|
|
712
|
+
const [claudeEntries, codexEntries] = await Promise.all([
|
|
713
|
+
claudeEntriesPromise,
|
|
714
|
+
codexEntriesPromise,
|
|
715
|
+
]);
|
|
716
|
+
markDuration(durations, "loadClaudeSessions", loadClaudeStartedAt);
|
|
717
|
+
markDuration(durations, "loadCodexSessions", loadCodexStartedAt);
|
|
718
|
+
// Combine results
|
|
719
|
+
entries.push(...claudeEntries, ...codexEntries);
|
|
720
|
+
// Filter out archived sessions
|
|
721
|
+
const archivedIds = options.archivedSessionIds;
|
|
722
|
+
let filtered = archivedIds
|
|
723
|
+
? entries.filter((e) => !archivedIds.has(e.sessionId))
|
|
724
|
+
: [...entries];
|
|
725
|
+
perfStats.counts.beforeArchive = entries.length;
|
|
726
|
+
perfStats.counts.afterArchive = filtered.length;
|
|
727
|
+
// Filter by provider
|
|
728
|
+
if (options.provider) {
|
|
729
|
+
filtered = filtered.filter((e) => e.provider === options.provider);
|
|
730
|
+
}
|
|
731
|
+
perfStats.counts.afterProvider = filtered.length;
|
|
732
|
+
// Filter named only
|
|
733
|
+
if (options.namedOnly) {
|
|
734
|
+
filtered = filtered.filter((e) => e.name != null && e.name !== "");
|
|
735
|
+
}
|
|
736
|
+
perfStats.counts.afterNamedOnly = filtered.length;
|
|
737
|
+
// Filter by search query (name, firstPrompt, lastPrompt, summary)
|
|
738
|
+
if (options.searchQuery) {
|
|
739
|
+
const q = options.searchQuery.toLowerCase();
|
|
740
|
+
filtered = filtered.filter((e) => e.name?.toLowerCase().includes(q) ||
|
|
741
|
+
e.firstPrompt?.toLowerCase().includes(q) ||
|
|
742
|
+
e.lastPrompt?.toLowerCase().includes(q) ||
|
|
743
|
+
e.summary?.toLowerCase().includes(q));
|
|
744
|
+
}
|
|
745
|
+
perfStats.counts.afterSearch = filtered.length;
|
|
746
|
+
// Sort by modified descending
|
|
747
|
+
const sortStartedAt = process.hrtime.bigint();
|
|
748
|
+
filtered.sort((a, b) => {
|
|
749
|
+
const ta = new Date(a.modified).getTime();
|
|
750
|
+
const tb = new Date(b.modified).getTime();
|
|
751
|
+
return tb - ta;
|
|
752
|
+
});
|
|
753
|
+
markDuration(durations, "sortSessions", sortStartedAt);
|
|
754
|
+
const paginateStartedAt = process.hrtime.bigint();
|
|
755
|
+
const sliced = filtered.slice(offset, offset + limit);
|
|
756
|
+
const hasMore = offset + limit < filtered.length;
|
|
757
|
+
perfStats.counts.returned = sliced.length;
|
|
758
|
+
markDuration(durations, "paginate", paginateStartedAt);
|
|
759
|
+
// Supplement missing lastPrompt for Claude sessions (sessions-index.json
|
|
760
|
+
// doesn't include lastPrompt). Only the paginated page is processed so at
|
|
761
|
+
// most `limit` tail reads are needed — lightweight enough to keep inline.
|
|
762
|
+
const supplementStartedAt = process.hrtime.bigint();
|
|
763
|
+
const needLastPrompt = sliced.filter((e) => e.provider === "claude" && !e.lastPrompt && e.projectPath);
|
|
764
|
+
if (needLastPrompt.length > 0) {
|
|
765
|
+
const projectsDir = join(homedir(), ".claude", "projects");
|
|
766
|
+
await parallelMap(needLastPrompt, PARALLEL_FILE_READ_LIMIT, async (entry) => {
|
|
767
|
+
const slug = pathToSlug(entry.projectPath);
|
|
768
|
+
const jsonlPath = join(projectsDir, slug, `${entry.sessionId}.jsonl`);
|
|
769
|
+
const lp = await extractLastPromptFromTail(jsonlPath);
|
|
770
|
+
if (lp && lp !== entry.firstPrompt) {
|
|
771
|
+
entry.lastPrompt = lp;
|
|
772
|
+
}
|
|
773
|
+
});
|
|
774
|
+
}
|
|
775
|
+
markDuration(durations, "supplementLastPrompt", supplementStartedAt);
|
|
776
|
+
markDuration(durations, "total", totalStartedAt);
|
|
777
|
+
logRecentSessionsPerf(options, durations, perfStats);
|
|
778
|
+
return { sessions: sliced, hasMore };
|
|
779
|
+
}
|
|
780
|
+
async function listCodexSessionFiles() {
|
|
781
|
+
const root = join(homedir(), ".codex", "sessions");
|
|
782
|
+
const files = [];
|
|
783
|
+
const stack = [root];
|
|
784
|
+
while (stack.length > 0) {
|
|
785
|
+
const dir = stack.pop();
|
|
786
|
+
let children;
|
|
787
|
+
try {
|
|
788
|
+
children = await readdir(dir, { withFileTypes: true });
|
|
789
|
+
}
|
|
790
|
+
catch {
|
|
791
|
+
continue;
|
|
792
|
+
}
|
|
793
|
+
for (const child of children) {
|
|
794
|
+
const p = join(dir, child.name);
|
|
795
|
+
if (child.isDirectory()) {
|
|
796
|
+
stack.push(p);
|
|
797
|
+
}
|
|
798
|
+
else if (child.isFile() && p.endsWith(".jsonl")) {
|
|
799
|
+
files.push(p);
|
|
800
|
+
}
|
|
801
|
+
}
|
|
802
|
+
}
|
|
803
|
+
return files;
|
|
804
|
+
}
|
|
805
|
+
function parseCodexSessionJsonl(raw, fallbackSessionId) {
|
|
806
|
+
const lines = raw.split("\n");
|
|
807
|
+
let threadId = fallbackSessionId;
|
|
808
|
+
let projectPath = "";
|
|
809
|
+
let resumeCwd = "";
|
|
810
|
+
let gitBranch = "";
|
|
811
|
+
let created = "";
|
|
812
|
+
let modified = "";
|
|
813
|
+
let firstPrompt = "";
|
|
814
|
+
let lastPrompt = "";
|
|
815
|
+
let summary = "";
|
|
816
|
+
let hasMessages = false;
|
|
817
|
+
let lastAssistantText = "";
|
|
818
|
+
let agentNickname;
|
|
819
|
+
let agentRole;
|
|
820
|
+
// Settings extracted from the first turn_context entry
|
|
821
|
+
let approvalPolicy;
|
|
822
|
+
let sandboxMode;
|
|
823
|
+
let model;
|
|
824
|
+
let modelReasoningEffort;
|
|
825
|
+
let networkAccessEnabled;
|
|
826
|
+
let webSearchMode;
|
|
827
|
+
for (const line of lines) {
|
|
828
|
+
if (!line.trim())
|
|
829
|
+
continue;
|
|
830
|
+
let entry;
|
|
831
|
+
try {
|
|
832
|
+
entry = JSON.parse(line);
|
|
833
|
+
}
|
|
834
|
+
catch {
|
|
835
|
+
continue;
|
|
836
|
+
}
|
|
837
|
+
const timestamp = entry.timestamp;
|
|
838
|
+
if (timestamp) {
|
|
839
|
+
if (!created)
|
|
840
|
+
created = timestamp;
|
|
841
|
+
modified = timestamp;
|
|
842
|
+
}
|
|
843
|
+
if (entry.type === "session_meta") {
|
|
844
|
+
const payload = entry.payload;
|
|
845
|
+
if (payload) {
|
|
846
|
+
if (typeof payload.id === "string" && payload.id.length > 0) {
|
|
847
|
+
threadId = payload.id;
|
|
848
|
+
}
|
|
849
|
+
if (typeof payload.cwd === "string" && payload.cwd.length > 0) {
|
|
850
|
+
resumeCwd = payload.cwd;
|
|
851
|
+
projectPath = normalizeWorktreePath(payload.cwd);
|
|
852
|
+
}
|
|
853
|
+
const git = payload.git;
|
|
854
|
+
if (git && typeof git.branch === "string") {
|
|
855
|
+
gitBranch = git.branch;
|
|
856
|
+
}
|
|
857
|
+
if (typeof payload.agent_nickname === "string" && payload.agent_nickname.length > 0) {
|
|
858
|
+
agentNickname = payload.agent_nickname;
|
|
859
|
+
}
|
|
860
|
+
if (typeof payload.agent_role === "string" && payload.agent_role.length > 0) {
|
|
861
|
+
agentRole = payload.agent_role;
|
|
862
|
+
}
|
|
863
|
+
}
|
|
864
|
+
continue;
|
|
865
|
+
}
|
|
866
|
+
// Extract codex settings from turn_context.
|
|
867
|
+
// Always update (no guard) so the **last** turn_context wins — this is
|
|
868
|
+
// important when sandbox mode or other settings change mid-session.
|
|
869
|
+
if (entry.type === "turn_context") {
|
|
870
|
+
const payload = entry.payload;
|
|
871
|
+
if (payload) {
|
|
872
|
+
if (typeof payload.approval_policy === "string") {
|
|
873
|
+
approvalPolicy = payload.approval_policy;
|
|
874
|
+
}
|
|
875
|
+
const sp = payload.sandbox_policy;
|
|
876
|
+
if (sp && typeof sp.type === "string") {
|
|
877
|
+
sandboxMode = sp.type;
|
|
878
|
+
}
|
|
879
|
+
if (typeof payload.model === "string") {
|
|
880
|
+
model = payload.model;
|
|
881
|
+
}
|
|
882
|
+
const collaborationMode = payload.collaboration_mode;
|
|
883
|
+
const collaborationSettings = collaborationMode?.settings;
|
|
884
|
+
if (typeof collaborationSettings?.reasoning_effort === "string") {
|
|
885
|
+
modelReasoningEffort = collaborationSettings.reasoning_effort;
|
|
886
|
+
}
|
|
887
|
+
if (typeof sp?.network_access === "boolean") {
|
|
888
|
+
networkAccessEnabled = sp.network_access;
|
|
889
|
+
}
|
|
890
|
+
if (typeof payload.web_search === "string") {
|
|
891
|
+
webSearchMode = payload.web_search;
|
|
892
|
+
}
|
|
893
|
+
}
|
|
894
|
+
continue;
|
|
895
|
+
}
|
|
896
|
+
if (entry.type === "event_msg") {
|
|
897
|
+
const payload = entry.payload;
|
|
898
|
+
if (payload?.type === "user_message" && typeof payload.message === "string") {
|
|
899
|
+
hasMessages = true;
|
|
900
|
+
if (!firstPrompt)
|
|
901
|
+
firstPrompt = payload.message;
|
|
902
|
+
lastPrompt = payload.message;
|
|
903
|
+
}
|
|
904
|
+
continue;
|
|
905
|
+
}
|
|
906
|
+
if (entry.type === "response_item") {
|
|
907
|
+
const payload = entry.payload;
|
|
908
|
+
if (!payload || payload.type !== "message" || payload.role !== "assistant") {
|
|
909
|
+
continue;
|
|
910
|
+
}
|
|
911
|
+
const content = payload.content;
|
|
912
|
+
if (!Array.isArray(content))
|
|
913
|
+
continue;
|
|
914
|
+
const text = content
|
|
915
|
+
.filter((item) => item.type === "output_text" && typeof item.text === "string")
|
|
916
|
+
.map((item) => item.text)
|
|
917
|
+
.join("\n")
|
|
918
|
+
.trim();
|
|
919
|
+
if (text.length > 0) {
|
|
920
|
+
hasMessages = true;
|
|
921
|
+
lastAssistantText = text;
|
|
922
|
+
}
|
|
923
|
+
}
|
|
924
|
+
}
|
|
925
|
+
if (!projectPath || !hasMessages)
|
|
926
|
+
return null;
|
|
927
|
+
summary = lastAssistantText || summary;
|
|
928
|
+
const codexSettings = (approvalPolicy
|
|
929
|
+
|| sandboxMode
|
|
930
|
+
|| model
|
|
931
|
+
|| modelReasoningEffort
|
|
932
|
+
|| networkAccessEnabled !== undefined
|
|
933
|
+
|| webSearchMode)
|
|
934
|
+
? {
|
|
935
|
+
approvalPolicy,
|
|
936
|
+
sandboxMode,
|
|
937
|
+
model,
|
|
938
|
+
modelReasoningEffort,
|
|
939
|
+
networkAccessEnabled,
|
|
940
|
+
webSearchMode,
|
|
941
|
+
}
|
|
942
|
+
: undefined;
|
|
943
|
+
return {
|
|
944
|
+
threadId,
|
|
945
|
+
entry: {
|
|
946
|
+
sessionId: threadId,
|
|
947
|
+
provider: "codex",
|
|
948
|
+
...(agentNickname ? { agentNickname } : {}),
|
|
949
|
+
...(agentRole ? { agentRole } : {}),
|
|
950
|
+
summary: summary || undefined,
|
|
951
|
+
firstPrompt,
|
|
952
|
+
...(lastPrompt && lastPrompt !== firstPrompt ? { lastPrompt } : {}),
|
|
953
|
+
created,
|
|
954
|
+
modified,
|
|
955
|
+
gitBranch,
|
|
956
|
+
projectPath,
|
|
957
|
+
...(resumeCwd && resumeCwd !== projectPath ? { resumeCwd } : {}),
|
|
958
|
+
isSidechain: false,
|
|
959
|
+
codexSettings,
|
|
960
|
+
},
|
|
961
|
+
};
|
|
962
|
+
}
|
|
963
|
+
/**
|
|
964
|
+
* Look up the saved name (customTitle) for a Claude Code session.
|
|
965
|
+
* Returns the name if found, or undefined.
|
|
966
|
+
*/
|
|
967
|
+
export async function getClaudeSessionName(projectPath, claudeSessionId) {
|
|
968
|
+
const slug = pathToSlug(projectPath);
|
|
969
|
+
const indexPath = join(homedir(), ".claude", "projects", slug, "sessions-index.json");
|
|
970
|
+
let raw;
|
|
971
|
+
try {
|
|
972
|
+
raw = await readFile(indexPath, "utf-8");
|
|
973
|
+
}
|
|
974
|
+
catch {
|
|
975
|
+
return undefined;
|
|
976
|
+
}
|
|
977
|
+
let index;
|
|
978
|
+
try {
|
|
979
|
+
index = JSON.parse(raw);
|
|
980
|
+
}
|
|
981
|
+
catch {
|
|
982
|
+
return undefined;
|
|
983
|
+
}
|
|
984
|
+
if (!Array.isArray(index.entries))
|
|
985
|
+
return undefined;
|
|
986
|
+
const entry = index.entries.find((e) => e.sessionId === claudeSessionId);
|
|
987
|
+
return entry?.customTitle || undefined;
|
|
988
|
+
}
|
|
989
|
+
/**
|
|
990
|
+
* Rename a Claude Code session by writing customTitle to sessions-index.json.
|
|
991
|
+
* This is the same mechanism the CLI uses for /rename.
|
|
992
|
+
*/
|
|
993
|
+
export async function renameClaudeSession(projectPath, claudeSessionId, name) {
|
|
994
|
+
const slug = pathToSlug(projectPath);
|
|
995
|
+
const dirPath = join(homedir(), ".claude", "projects", slug);
|
|
996
|
+
const indexPath = join(dirPath, "sessions-index.json");
|
|
997
|
+
let index = null;
|
|
998
|
+
try {
|
|
999
|
+
const raw = await readFile(indexPath, "utf-8");
|
|
1000
|
+
index = JSON.parse(raw);
|
|
1001
|
+
}
|
|
1002
|
+
catch {
|
|
1003
|
+
// File doesn't exist or is invalid — will create below if needed
|
|
1004
|
+
}
|
|
1005
|
+
if (index && Array.isArray(index.entries)) {
|
|
1006
|
+
const entry = index.entries.find((e) => e.sessionId === claudeSessionId);
|
|
1007
|
+
if (entry) {
|
|
1008
|
+
if (name) {
|
|
1009
|
+
entry.customTitle = name;
|
|
1010
|
+
}
|
|
1011
|
+
else {
|
|
1012
|
+
delete entry.customTitle;
|
|
1013
|
+
}
|
|
1014
|
+
await writeFile(indexPath, JSON.stringify(index, null, 2), "utf-8");
|
|
1015
|
+
return true;
|
|
1016
|
+
}
|
|
1017
|
+
}
|
|
1018
|
+
// Entry not found in index (or index doesn't exist yet).
|
|
1019
|
+
// The CLI may not have created the index entry for short-lived or new sessions.
|
|
1020
|
+
// Create a minimal entry so customTitle is persisted and picked up by
|
|
1021
|
+
// getAllRecentSessions() on next read.
|
|
1022
|
+
if (!name)
|
|
1023
|
+
return false; // Nothing to persist when clearing name
|
|
1024
|
+
if (!index || !Array.isArray(index.entries)) {
|
|
1025
|
+
index = { version: 1, entries: [] };
|
|
1026
|
+
}
|
|
1027
|
+
// Build a minimal entry from the JSONL file if available
|
|
1028
|
+
const jsonlPath = join(dirPath, `${claudeSessionId}.jsonl`);
|
|
1029
|
+
let firstPrompt = "";
|
|
1030
|
+
let created = new Date().toISOString();
|
|
1031
|
+
let modified = created;
|
|
1032
|
+
let gitBranch = "";
|
|
1033
|
+
try {
|
|
1034
|
+
const raw = await readFile(jsonlPath, "utf-8");
|
|
1035
|
+
for (const line of raw.split("\n")) {
|
|
1036
|
+
if (!line.trim())
|
|
1037
|
+
continue;
|
|
1038
|
+
try {
|
|
1039
|
+
const entry = JSON.parse(line);
|
|
1040
|
+
const type = entry.type;
|
|
1041
|
+
if (type !== "user" && type !== "assistant")
|
|
1042
|
+
continue;
|
|
1043
|
+
const ts = entry.timestamp;
|
|
1044
|
+
if (ts) {
|
|
1045
|
+
if (!firstPrompt)
|
|
1046
|
+
created = ts;
|
|
1047
|
+
modified = ts;
|
|
1048
|
+
}
|
|
1049
|
+
if (!gitBranch && entry.gitBranch)
|
|
1050
|
+
gitBranch = entry.gitBranch;
|
|
1051
|
+
if (type === "user" && !firstPrompt) {
|
|
1052
|
+
const msg = entry.message;
|
|
1053
|
+
if (msg?.content) {
|
|
1054
|
+
if (typeof msg.content === "string")
|
|
1055
|
+
firstPrompt = msg.content;
|
|
1056
|
+
else if (Array.isArray(msg.content)) {
|
|
1057
|
+
const tb = msg.content
|
|
1058
|
+
.find((c) => c.type === "text" && c.text);
|
|
1059
|
+
if (tb?.text)
|
|
1060
|
+
firstPrompt = tb.text;
|
|
1061
|
+
}
|
|
1062
|
+
}
|
|
1063
|
+
}
|
|
1064
|
+
}
|
|
1065
|
+
catch { /* skip malformed lines */ }
|
|
1066
|
+
}
|
|
1067
|
+
}
|
|
1068
|
+
catch { /* JSONL not available */ }
|
|
1069
|
+
index.entries.push({
|
|
1070
|
+
sessionId: claudeSessionId,
|
|
1071
|
+
fullPath: jsonlPath,
|
|
1072
|
+
fileMtime: Date.now(),
|
|
1073
|
+
firstPrompt,
|
|
1074
|
+
customTitle: name,
|
|
1075
|
+
messageCount: 0,
|
|
1076
|
+
created,
|
|
1077
|
+
modified,
|
|
1078
|
+
gitBranch,
|
|
1079
|
+
projectPath,
|
|
1080
|
+
isSidechain: false,
|
|
1081
|
+
});
|
|
1082
|
+
// Ensure directory exists (may not for brand-new projects)
|
|
1083
|
+
const { mkdir } = await import("node:fs/promises");
|
|
1084
|
+
await mkdir(dirPath, { recursive: true });
|
|
1085
|
+
await writeFile(indexPath, JSON.stringify(index, null, 2), "utf-8");
|
|
1086
|
+
return true;
|
|
1087
|
+
}
|
|
1088
|
+
/**
|
|
1089
|
+
* Read the Codex session_index.jsonl and build a threadId → name map.
|
|
1090
|
+
*/
|
|
1091
|
+
export async function loadCodexSessionNames() {
|
|
1092
|
+
const indexPath = join(homedir(), ".codex", "session_index.jsonl");
|
|
1093
|
+
const names = new Map();
|
|
1094
|
+
let raw;
|
|
1095
|
+
try {
|
|
1096
|
+
raw = await readFile(indexPath, "utf-8");
|
|
1097
|
+
}
|
|
1098
|
+
catch {
|
|
1099
|
+
return names;
|
|
1100
|
+
}
|
|
1101
|
+
// Append-only: later entries override earlier ones for the same id
|
|
1102
|
+
for (const line of raw.split("\n")) {
|
|
1103
|
+
if (!line.trim())
|
|
1104
|
+
continue;
|
|
1105
|
+
try {
|
|
1106
|
+
const entry = JSON.parse(line);
|
|
1107
|
+
if (entry.id && entry.thread_name) {
|
|
1108
|
+
names.set(entry.id, entry.thread_name);
|
|
1109
|
+
}
|
|
1110
|
+
}
|
|
1111
|
+
catch {
|
|
1112
|
+
// skip malformed
|
|
1113
|
+
}
|
|
1114
|
+
}
|
|
1115
|
+
return names;
|
|
1116
|
+
}
|
|
1117
|
+
/**
|
|
1118
|
+
* Rename a Codex session by appending to ~/.codex/session_index.jsonl.
|
|
1119
|
+
* Passing `null` or empty name writes an empty thread_name to effectively clear it.
|
|
1120
|
+
*/
|
|
1121
|
+
export async function renameCodexSession(threadId, name) {
|
|
1122
|
+
try {
|
|
1123
|
+
const indexPath = join(homedir(), ".codex", "session_index.jsonl");
|
|
1124
|
+
const entry = JSON.stringify({
|
|
1125
|
+
id: threadId,
|
|
1126
|
+
thread_name: name ?? "",
|
|
1127
|
+
updated_at: new Date().toISOString(),
|
|
1128
|
+
});
|
|
1129
|
+
await appendFile(indexPath, entry + "\n");
|
|
1130
|
+
return true;
|
|
1131
|
+
}
|
|
1132
|
+
catch {
|
|
1133
|
+
return false;
|
|
1134
|
+
}
|
|
1135
|
+
}
|
|
1136
|
+
async function getAllRecentCodexSessions(options = {}) {
|
|
1137
|
+
const files = await listCodexSessionFiles();
|
|
1138
|
+
const entries = [];
|
|
1139
|
+
options.perfStats && (options.perfStats.filesTotal = files.length);
|
|
1140
|
+
const normalizedProjectPath = options.projectPath
|
|
1141
|
+
? normalizeWorktreePath(options.projectPath)
|
|
1142
|
+
: null;
|
|
1143
|
+
// Load thread names from session_index.jsonl
|
|
1144
|
+
const threadNames = await loadCodexSessionNames();
|
|
1145
|
+
for (const filePath of files) {
|
|
1146
|
+
let raw;
|
|
1147
|
+
try {
|
|
1148
|
+
raw = await readFile(filePath, "utf-8");
|
|
1149
|
+
}
|
|
1150
|
+
catch {
|
|
1151
|
+
continue;
|
|
1152
|
+
}
|
|
1153
|
+
options.perfStats && (options.perfStats.filesRead += 1);
|
|
1154
|
+
const fallbackSessionId = basename(filePath, ".jsonl");
|
|
1155
|
+
const parsed = parseCodexSessionJsonl(raw, fallbackSessionId);
|
|
1156
|
+
if (!parsed)
|
|
1157
|
+
continue;
|
|
1158
|
+
if (normalizedProjectPath && parsed.entry.projectPath !== normalizedProjectPath) {
|
|
1159
|
+
continue;
|
|
1160
|
+
}
|
|
1161
|
+
// Attach thread name if available
|
|
1162
|
+
const threadName = threadNames.get(parsed.threadId);
|
|
1163
|
+
if (threadName) {
|
|
1164
|
+
parsed.entry.name = threadName;
|
|
1165
|
+
}
|
|
1166
|
+
entries.push(parsed.entry);
|
|
1167
|
+
options.perfStats && (options.perfStats.entriesReturned += 1);
|
|
1168
|
+
}
|
|
1169
|
+
return entries;
|
|
1170
|
+
}
|
|
1171
|
+
function asObject(value) {
|
|
1172
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
1173
|
+
return null;
|
|
1174
|
+
}
|
|
1175
|
+
return value;
|
|
1176
|
+
}
|
|
1177
|
+
function parseObjectLike(value) {
|
|
1178
|
+
if (typeof value === "string") {
|
|
1179
|
+
try {
|
|
1180
|
+
const parsed = JSON.parse(value);
|
|
1181
|
+
return asObject(parsed) ?? { value: parsed };
|
|
1182
|
+
}
|
|
1183
|
+
catch {
|
|
1184
|
+
return { value };
|
|
1185
|
+
}
|
|
1186
|
+
}
|
|
1187
|
+
return asObject(value) ?? {};
|
|
1188
|
+
}
|
|
1189
|
+
function appendTextMessage(messages, role, text, timestamp) {
|
|
1190
|
+
const normalized = text.trim();
|
|
1191
|
+
if (!normalized)
|
|
1192
|
+
return;
|
|
1193
|
+
const last = messages.at(-1);
|
|
1194
|
+
if (last
|
|
1195
|
+
&& last.role === role
|
|
1196
|
+
&& last.content.length === 1
|
|
1197
|
+
&& last.content[0].type === "text"
|
|
1198
|
+
&& typeof last.content[0].text === "string"
|
|
1199
|
+
&& last.content[0].text.trim() === normalized) {
|
|
1200
|
+
return;
|
|
1201
|
+
}
|
|
1202
|
+
messages.push({
|
|
1203
|
+
role,
|
|
1204
|
+
content: [{ type: "text", text }],
|
|
1205
|
+
...(timestamp ? { timestamp } : {}),
|
|
1206
|
+
});
|
|
1207
|
+
}
|
|
1208
|
+
function appendToolUseMessage(messages, id, name, input) {
|
|
1209
|
+
const normalizedName = name.trim();
|
|
1210
|
+
if (!normalizedName)
|
|
1211
|
+
return;
|
|
1212
|
+
const last = messages.at(-1);
|
|
1213
|
+
if (last
|
|
1214
|
+
&& last.role === "assistant"
|
|
1215
|
+
&& last.content.length === 1
|
|
1216
|
+
&& last.content[0].type === "tool_use"
|
|
1217
|
+
&& last.content[0].id === id
|
|
1218
|
+
&& last.content[0].name === normalizedName) {
|
|
1219
|
+
return;
|
|
1220
|
+
}
|
|
1221
|
+
messages.push({
|
|
1222
|
+
role: "assistant",
|
|
1223
|
+
content: [
|
|
1224
|
+
{
|
|
1225
|
+
type: "tool_use",
|
|
1226
|
+
id,
|
|
1227
|
+
name: normalizedName,
|
|
1228
|
+
input,
|
|
1229
|
+
},
|
|
1230
|
+
],
|
|
1231
|
+
});
|
|
1232
|
+
}
|
|
1233
|
+
function normalizeCodexToolName(name) {
|
|
1234
|
+
if (name === "exec_command" || name === "write_stdin") {
|
|
1235
|
+
return "Bash";
|
|
1236
|
+
}
|
|
1237
|
+
// Codex function names for MCP tools look like: mcp__server__tool_name
|
|
1238
|
+
if (name.startsWith("mcp__")) {
|
|
1239
|
+
const [server, ...toolParts] = name.slice("mcp__".length).split("__");
|
|
1240
|
+
if (server && toolParts.length > 0) {
|
|
1241
|
+
return `mcp:${server}/${toolParts.join("__")}`;
|
|
1242
|
+
}
|
|
1243
|
+
}
|
|
1244
|
+
return name;
|
|
1245
|
+
}
|
|
1246
|
+
function isCodexInjectedUserContext(text) {
|
|
1247
|
+
const normalized = text.trimStart();
|
|
1248
|
+
return (normalized.startsWith("# AGENTS.md instructions for ")
|
|
1249
|
+
|| normalized.startsWith("<environment_context>")
|
|
1250
|
+
|| normalized.startsWith("<permissions instructions>"));
|
|
1251
|
+
}
|
|
1252
|
+
function getCodexSearchInput(payload) {
|
|
1253
|
+
const action = asObject(payload.action);
|
|
1254
|
+
const input = {};
|
|
1255
|
+
if (typeof action?.query === "string") {
|
|
1256
|
+
input.query = action.query;
|
|
1257
|
+
}
|
|
1258
|
+
if (Array.isArray(action?.queries)) {
|
|
1259
|
+
const queries = action.queries.filter((q) => typeof q === "string" && q.length > 0);
|
|
1260
|
+
if (queries.length > 0) {
|
|
1261
|
+
input.queries = queries;
|
|
1262
|
+
}
|
|
1263
|
+
}
|
|
1264
|
+
return input;
|
|
1265
|
+
}
|
|
1266
|
+
/**
|
|
1267
|
+
* Find the JSONL file path for a given sessionId by searching sessions-index.json files,
|
|
1268
|
+
* then falling back to scanning directories for the JSONL file directly.
|
|
1269
|
+
*/
|
|
1270
|
+
async function findSessionJsonlPath(sessionId) {
|
|
1271
|
+
const projectsDir = join(homedir(), ".claude", "projects");
|
|
1272
|
+
let projectDirs;
|
|
1273
|
+
try {
|
|
1274
|
+
projectDirs = await readdir(projectsDir);
|
|
1275
|
+
}
|
|
1276
|
+
catch {
|
|
1277
|
+
return null;
|
|
1278
|
+
}
|
|
1279
|
+
// First pass: check sessions-index.json files
|
|
1280
|
+
for (const dirName of projectDirs) {
|
|
1281
|
+
if (dirName.startsWith("."))
|
|
1282
|
+
continue;
|
|
1283
|
+
const indexPath = join(projectsDir, dirName, "sessions-index.json");
|
|
1284
|
+
let raw;
|
|
1285
|
+
try {
|
|
1286
|
+
raw = await readFile(indexPath, "utf-8");
|
|
1287
|
+
}
|
|
1288
|
+
catch {
|
|
1289
|
+
continue;
|
|
1290
|
+
}
|
|
1291
|
+
let index;
|
|
1292
|
+
try {
|
|
1293
|
+
index = JSON.parse(raw);
|
|
1294
|
+
}
|
|
1295
|
+
catch {
|
|
1296
|
+
continue;
|
|
1297
|
+
}
|
|
1298
|
+
if (!Array.isArray(index.entries))
|
|
1299
|
+
continue;
|
|
1300
|
+
const entry = index.entries.find((e) => e.sessionId === sessionId);
|
|
1301
|
+
if (entry?.fullPath) {
|
|
1302
|
+
return entry.fullPath;
|
|
1303
|
+
}
|
|
1304
|
+
}
|
|
1305
|
+
// Fallback: scan directories for the JSONL file directly
|
|
1306
|
+
// This handles worktree sessions without sessions-index.json
|
|
1307
|
+
const jsonlFileName = `${sessionId}.jsonl`;
|
|
1308
|
+
for (const dirName of projectDirs) {
|
|
1309
|
+
if (dirName.startsWith("."))
|
|
1310
|
+
continue;
|
|
1311
|
+
const candidatePath = join(projectsDir, dirName, jsonlFileName);
|
|
1312
|
+
try {
|
|
1313
|
+
await stat(candidatePath);
|
|
1314
|
+
return candidatePath;
|
|
1315
|
+
}
|
|
1316
|
+
catch {
|
|
1317
|
+
continue;
|
|
1318
|
+
}
|
|
1319
|
+
}
|
|
1320
|
+
return null;
|
|
1321
|
+
}
|
|
1322
|
+
async function findCodexSessionJsonlPath(threadId) {
|
|
1323
|
+
const files = await listCodexSessionFiles();
|
|
1324
|
+
for (const filePath of files) {
|
|
1325
|
+
const fallbackSessionId = basename(filePath, ".jsonl");
|
|
1326
|
+
if (fallbackSessionId === threadId) {
|
|
1327
|
+
return filePath;
|
|
1328
|
+
}
|
|
1329
|
+
let raw;
|
|
1330
|
+
try {
|
|
1331
|
+
raw = await readFile(filePath, "utf-8");
|
|
1332
|
+
}
|
|
1333
|
+
catch {
|
|
1334
|
+
continue;
|
|
1335
|
+
}
|
|
1336
|
+
const parsed = parseCodexSessionJsonl(raw, fallbackSessionId);
|
|
1337
|
+
if (parsed?.threadId === threadId) {
|
|
1338
|
+
return filePath;
|
|
1339
|
+
}
|
|
1340
|
+
}
|
|
1341
|
+
return null;
|
|
1342
|
+
}
|
|
1343
|
+
/**
|
|
1344
|
+
* Read past conversation messages from a session's JSONL file.
|
|
1345
|
+
* Returns user and assistant messages suitable for display.
|
|
1346
|
+
*/
|
|
1347
|
+
export async function getSessionHistory(sessionId) {
|
|
1348
|
+
const jsonlPath = await findSessionJsonlPath(sessionId);
|
|
1349
|
+
if (!jsonlPath)
|
|
1350
|
+
return [];
|
|
1351
|
+
let raw;
|
|
1352
|
+
try {
|
|
1353
|
+
raw = await readFile(jsonlPath, "utf-8");
|
|
1354
|
+
}
|
|
1355
|
+
catch {
|
|
1356
|
+
return [];
|
|
1357
|
+
}
|
|
1358
|
+
const messages = [];
|
|
1359
|
+
const lines = raw.split("\n");
|
|
1360
|
+
for (const line of lines) {
|
|
1361
|
+
if (!line.trim())
|
|
1362
|
+
continue;
|
|
1363
|
+
let entry;
|
|
1364
|
+
try {
|
|
1365
|
+
entry = JSON.parse(line);
|
|
1366
|
+
}
|
|
1367
|
+
catch {
|
|
1368
|
+
continue;
|
|
1369
|
+
}
|
|
1370
|
+
const type = entry.type;
|
|
1371
|
+
if (type !== "user" && type !== "assistant")
|
|
1372
|
+
continue;
|
|
1373
|
+
// Skip context compaction and transcript-only messages (not real user input)
|
|
1374
|
+
if (type === "user") {
|
|
1375
|
+
if (entry.isCompactSummary === true || entry.isVisibleInTranscriptOnly === true) {
|
|
1376
|
+
continue;
|
|
1377
|
+
}
|
|
1378
|
+
}
|
|
1379
|
+
const message = entry.message;
|
|
1380
|
+
if (!message?.content)
|
|
1381
|
+
continue;
|
|
1382
|
+
const role = message.role;
|
|
1383
|
+
const isMeta = role === "user" && entry.isMeta === true ? true : undefined;
|
|
1384
|
+
// Handle string content (e.g. user message after interrupt)
|
|
1385
|
+
if (typeof message.content === "string") {
|
|
1386
|
+
if (message.content) {
|
|
1387
|
+
const uuid = entry.uuid;
|
|
1388
|
+
const ts = entry.timestamp;
|
|
1389
|
+
messages.push({
|
|
1390
|
+
role,
|
|
1391
|
+
content: [{ type: "text", text: message.content }],
|
|
1392
|
+
...(uuid ? { uuid } : {}),
|
|
1393
|
+
...(ts ? { timestamp: ts } : {}),
|
|
1394
|
+
...(isMeta ? { isMeta } : {}),
|
|
1395
|
+
});
|
|
1396
|
+
}
|
|
1397
|
+
continue;
|
|
1398
|
+
}
|
|
1399
|
+
if (!Array.isArray(message.content))
|
|
1400
|
+
continue;
|
|
1401
|
+
// Filter content to only text and tool_use (skip tool_result for cleaner display)
|
|
1402
|
+
const content = [];
|
|
1403
|
+
let imageCount = 0;
|
|
1404
|
+
for (const c of message.content) {
|
|
1405
|
+
if (typeof c !== "object" || c === null)
|
|
1406
|
+
continue;
|
|
1407
|
+
const item = c;
|
|
1408
|
+
const contentType = item.type;
|
|
1409
|
+
if (contentType === "text" && item.text) {
|
|
1410
|
+
content.push({ type: "text", text: item.text });
|
|
1411
|
+
}
|
|
1412
|
+
else if (contentType === "tool_use") {
|
|
1413
|
+
content.push({
|
|
1414
|
+
type: "tool_use",
|
|
1415
|
+
id: item.id,
|
|
1416
|
+
name: item.name,
|
|
1417
|
+
input: item.input ?? {},
|
|
1418
|
+
});
|
|
1419
|
+
}
|
|
1420
|
+
else if (contentType === "image") {
|
|
1421
|
+
imageCount++;
|
|
1422
|
+
}
|
|
1423
|
+
}
|
|
1424
|
+
if (content.length > 0 || imageCount > 0) {
|
|
1425
|
+
const uuid = entry.uuid;
|
|
1426
|
+
const ts = entry.timestamp;
|
|
1427
|
+
// If there are only images and no text, add a placeholder
|
|
1428
|
+
if (content.length === 0 && imageCount > 0) {
|
|
1429
|
+
content.push({
|
|
1430
|
+
type: "text",
|
|
1431
|
+
text: `[Image attached${imageCount > 1 ? ` x${imageCount}` : ""}]`,
|
|
1432
|
+
});
|
|
1433
|
+
}
|
|
1434
|
+
messages.push({
|
|
1435
|
+
role,
|
|
1436
|
+
content,
|
|
1437
|
+
...(uuid ? { uuid } : {}),
|
|
1438
|
+
...(ts ? { timestamp: ts } : {}),
|
|
1439
|
+
...(isMeta ? { isMeta } : {}),
|
|
1440
|
+
...(imageCount > 0 ? { imageCount } : {}),
|
|
1441
|
+
});
|
|
1442
|
+
}
|
|
1443
|
+
}
|
|
1444
|
+
return messages;
|
|
1445
|
+
}
|
|
1446
|
+
/**
|
|
1447
|
+
* Extract image base64 data from a Claude Code session JSONL for a specific message UUID.
|
|
1448
|
+
*/
|
|
1449
|
+
export async function extractMessageImages(sessionId, messageUuid) {
|
|
1450
|
+
// Try Claude Code first, then Codex
|
|
1451
|
+
const claudeImages = await extractClaudeMessageImages(sessionId, messageUuid);
|
|
1452
|
+
if (claudeImages.length > 0)
|
|
1453
|
+
return claudeImages;
|
|
1454
|
+
return extractCodexMessageImages(sessionId, messageUuid);
|
|
1455
|
+
}
|
|
1456
|
+
async function extractClaudeMessageImages(sessionId, messageUuid) {
|
|
1457
|
+
const jsonlPath = await findSessionJsonlPath(sessionId);
|
|
1458
|
+
if (!jsonlPath)
|
|
1459
|
+
return [];
|
|
1460
|
+
let raw;
|
|
1461
|
+
try {
|
|
1462
|
+
raw = await readFile(jsonlPath, "utf-8");
|
|
1463
|
+
}
|
|
1464
|
+
catch {
|
|
1465
|
+
return [];
|
|
1466
|
+
}
|
|
1467
|
+
const lines = raw.split("\n");
|
|
1468
|
+
for (const line of lines) {
|
|
1469
|
+
if (!line.trim())
|
|
1470
|
+
continue;
|
|
1471
|
+
let entry;
|
|
1472
|
+
try {
|
|
1473
|
+
entry = JSON.parse(line);
|
|
1474
|
+
}
|
|
1475
|
+
catch {
|
|
1476
|
+
continue;
|
|
1477
|
+
}
|
|
1478
|
+
if (entry.type !== "user")
|
|
1479
|
+
continue;
|
|
1480
|
+
if (entry.uuid !== messageUuid)
|
|
1481
|
+
continue;
|
|
1482
|
+
const message = entry.message;
|
|
1483
|
+
if (!message?.content || !Array.isArray(message.content))
|
|
1484
|
+
continue;
|
|
1485
|
+
const images = [];
|
|
1486
|
+
for (const c of message.content) {
|
|
1487
|
+
if (typeof c !== "object" || c === null)
|
|
1488
|
+
continue;
|
|
1489
|
+
const item = c;
|
|
1490
|
+
if (item.type !== "image")
|
|
1491
|
+
continue;
|
|
1492
|
+
const source = item.source;
|
|
1493
|
+
if (!source || source.type !== "base64")
|
|
1494
|
+
continue;
|
|
1495
|
+
const data = source.data;
|
|
1496
|
+
const mediaType = source.media_type;
|
|
1497
|
+
if (data && mediaType) {
|
|
1498
|
+
images.push({ base64: data, mimeType: mediaType });
|
|
1499
|
+
}
|
|
1500
|
+
}
|
|
1501
|
+
return images;
|
|
1502
|
+
}
|
|
1503
|
+
return [];
|
|
1504
|
+
}
|
|
1505
|
+
async function extractCodexMessageImages(sessionId, messageUuid) {
|
|
1506
|
+
const jsonlPath = await findCodexSessionJsonlPath(sessionId);
|
|
1507
|
+
if (!jsonlPath)
|
|
1508
|
+
return [];
|
|
1509
|
+
let raw;
|
|
1510
|
+
try {
|
|
1511
|
+
raw = await readFile(jsonlPath, "utf-8");
|
|
1512
|
+
}
|
|
1513
|
+
catch {
|
|
1514
|
+
return [];
|
|
1515
|
+
}
|
|
1516
|
+
// Codex doesn't have per-message UUIDs in the same way.
|
|
1517
|
+
// We scan for event_msg with user_message that has images and match by line index
|
|
1518
|
+
// encoded in the UUID (format: "codex-line-{index}").
|
|
1519
|
+
const lineIndex = messageUuid.startsWith("codex-line-")
|
|
1520
|
+
? parseInt(messageUuid.slice("codex-line-".length), 10)
|
|
1521
|
+
: -1;
|
|
1522
|
+
if (lineIndex < 0)
|
|
1523
|
+
return [];
|
|
1524
|
+
const lines = raw.split("\n");
|
|
1525
|
+
if (lineIndex >= lines.length)
|
|
1526
|
+
return [];
|
|
1527
|
+
const line = lines[lineIndex];
|
|
1528
|
+
if (!line?.trim())
|
|
1529
|
+
return [];
|
|
1530
|
+
let entry;
|
|
1531
|
+
try {
|
|
1532
|
+
entry = JSON.parse(line);
|
|
1533
|
+
}
|
|
1534
|
+
catch {
|
|
1535
|
+
return [];
|
|
1536
|
+
}
|
|
1537
|
+
if (entry.type !== "event_msg")
|
|
1538
|
+
return [];
|
|
1539
|
+
const payload = asObject(entry.payload);
|
|
1540
|
+
if (!payload || payload.type !== "user_message")
|
|
1541
|
+
return [];
|
|
1542
|
+
const images = [];
|
|
1543
|
+
// Parse payload.images (Data URI format: "data:image/png;base64,...")
|
|
1544
|
+
if (Array.isArray(payload.images)) {
|
|
1545
|
+
for (const img of payload.images) {
|
|
1546
|
+
if (typeof img !== "string")
|
|
1547
|
+
continue;
|
|
1548
|
+
const match = img.match(/^data:(image\/[^;]+);base64,(.+)$/);
|
|
1549
|
+
if (match) {
|
|
1550
|
+
images.push({ base64: match[2], mimeType: match[1] });
|
|
1551
|
+
}
|
|
1552
|
+
}
|
|
1553
|
+
}
|
|
1554
|
+
return images;
|
|
1555
|
+
}
|
|
1556
|
+
export async function getCodexSessionHistory(threadId) {
|
|
1557
|
+
const jsonlPath = await findCodexSessionJsonlPath(threadId);
|
|
1558
|
+
if (!jsonlPath)
|
|
1559
|
+
return [];
|
|
1560
|
+
let raw;
|
|
1561
|
+
try {
|
|
1562
|
+
raw = await readFile(jsonlPath, "utf-8");
|
|
1563
|
+
}
|
|
1564
|
+
catch {
|
|
1565
|
+
return [];
|
|
1566
|
+
}
|
|
1567
|
+
const messages = [];
|
|
1568
|
+
const lines = raw.split("\n");
|
|
1569
|
+
for (const [index, line] of lines.entries()) {
|
|
1570
|
+
if (!line.trim())
|
|
1571
|
+
continue;
|
|
1572
|
+
let entry;
|
|
1573
|
+
try {
|
|
1574
|
+
entry = JSON.parse(line);
|
|
1575
|
+
}
|
|
1576
|
+
catch {
|
|
1577
|
+
continue;
|
|
1578
|
+
}
|
|
1579
|
+
const entryTimestamp = entry.timestamp;
|
|
1580
|
+
if (entry.type === "event_msg") {
|
|
1581
|
+
const payload = asObject(entry.payload);
|
|
1582
|
+
if (!payload)
|
|
1583
|
+
continue;
|
|
1584
|
+
if (payload.type === "user_message") {
|
|
1585
|
+
const rawMessage = typeof payload.message === "string" ? payload.message : "";
|
|
1586
|
+
const images = Array.isArray(payload.images) ? payload.images.length : 0;
|
|
1587
|
+
const localImages = Array.isArray(payload.local_images)
|
|
1588
|
+
? payload.local_images.length
|
|
1589
|
+
: 0;
|
|
1590
|
+
const imageCount = images + localImages;
|
|
1591
|
+
const text = rawMessage.trim().length > 0
|
|
1592
|
+
? rawMessage
|
|
1593
|
+
: imageCount > 0
|
|
1594
|
+
? `[Image attached${imageCount > 1 ? ` x${imageCount}` : ""}]`
|
|
1595
|
+
: "";
|
|
1596
|
+
if (imageCount > 0) {
|
|
1597
|
+
// Push directly to include imageCount metadata
|
|
1598
|
+
const normalized = text.trim();
|
|
1599
|
+
if (normalized) {
|
|
1600
|
+
messages.push({
|
|
1601
|
+
role: "user",
|
|
1602
|
+
content: [{ type: "text", text }],
|
|
1603
|
+
imageCount,
|
|
1604
|
+
...(entryTimestamp ? { timestamp: entryTimestamp } : {}),
|
|
1605
|
+
});
|
|
1606
|
+
}
|
|
1607
|
+
}
|
|
1608
|
+
else {
|
|
1609
|
+
appendTextMessage(messages, "user", text, entryTimestamp);
|
|
1610
|
+
}
|
|
1611
|
+
continue;
|
|
1612
|
+
}
|
|
1613
|
+
if (payload.type === "agent_message" && typeof payload.message === "string") {
|
|
1614
|
+
appendTextMessage(messages, "assistant", payload.message, entryTimestamp);
|
|
1615
|
+
}
|
|
1616
|
+
continue;
|
|
1617
|
+
}
|
|
1618
|
+
if (entry.type === "response_item") {
|
|
1619
|
+
const payload = asObject(entry.payload);
|
|
1620
|
+
if (!payload)
|
|
1621
|
+
continue;
|
|
1622
|
+
if (payload.type === "message") {
|
|
1623
|
+
const content = Array.isArray(payload.content)
|
|
1624
|
+
? payload.content
|
|
1625
|
+
: [];
|
|
1626
|
+
if (payload.role === "assistant") {
|
|
1627
|
+
const text = content
|
|
1628
|
+
.filter((item) => item.type === "output_text" && typeof item.text === "string")
|
|
1629
|
+
.map((item) => item.text)
|
|
1630
|
+
.join("\n");
|
|
1631
|
+
appendTextMessage(messages, "assistant", text, entryTimestamp);
|
|
1632
|
+
continue;
|
|
1633
|
+
}
|
|
1634
|
+
if (payload.role === "user") {
|
|
1635
|
+
const text = content
|
|
1636
|
+
.filter((item) => item.type === "input_text" && typeof item.text === "string")
|
|
1637
|
+
.map((item) => item.text)
|
|
1638
|
+
.join("\n");
|
|
1639
|
+
if (!isCodexInjectedUserContext(text)) {
|
|
1640
|
+
appendTextMessage(messages, "user", text, entryTimestamp);
|
|
1641
|
+
}
|
|
1642
|
+
continue;
|
|
1643
|
+
}
|
|
1644
|
+
}
|
|
1645
|
+
if (payload.type === "function_call") {
|
|
1646
|
+
const id = typeof payload.call_id === "string" ? payload.call_id : `tool-${index}`;
|
|
1647
|
+
const rawName = typeof payload.name === "string" ? payload.name : "tool";
|
|
1648
|
+
appendToolUseMessage(messages, id, normalizeCodexToolName(rawName), parseObjectLike(payload.arguments));
|
|
1649
|
+
continue;
|
|
1650
|
+
}
|
|
1651
|
+
if (payload.type === "custom_tool_call") {
|
|
1652
|
+
const id = typeof payload.call_id === "string" ? payload.call_id : `tool-${index}`;
|
|
1653
|
+
const rawName = typeof payload.name === "string" ? payload.name : "custom_tool";
|
|
1654
|
+
appendToolUseMessage(messages, id, normalizeCodexToolName(rawName), parseObjectLike(payload.input));
|
|
1655
|
+
continue;
|
|
1656
|
+
}
|
|
1657
|
+
if (payload.type === "web_search_call") {
|
|
1658
|
+
appendToolUseMessage(messages, typeof payload.call_id === "string" ? payload.call_id : `web-search-${index}`, "WebSearch", getCodexSearchInput(payload));
|
|
1659
|
+
continue;
|
|
1660
|
+
}
|
|
1661
|
+
// Backward/forward compatibility with older/newer Codex JSONL schemas.
|
|
1662
|
+
if (payload.type === "command_execution") {
|
|
1663
|
+
const id = typeof payload.id === "string"
|
|
1664
|
+
? payload.id
|
|
1665
|
+
: typeof payload.call_id === "string"
|
|
1666
|
+
? payload.call_id
|
|
1667
|
+
: `cmd-${index}`;
|
|
1668
|
+
const input = typeof payload.command === "string"
|
|
1669
|
+
? { command: payload.command }
|
|
1670
|
+
: parseObjectLike(payload);
|
|
1671
|
+
appendToolUseMessage(messages, id, "Bash", input);
|
|
1672
|
+
continue;
|
|
1673
|
+
}
|
|
1674
|
+
if (payload.type === "mcp_tool_call") {
|
|
1675
|
+
const id = typeof payload.id === "string"
|
|
1676
|
+
? payload.id
|
|
1677
|
+
: typeof payload.call_id === "string"
|
|
1678
|
+
? payload.call_id
|
|
1679
|
+
: `mcp-${index}`;
|
|
1680
|
+
const server = typeof payload.server === "string" ? payload.server : "unknown";
|
|
1681
|
+
const tool = typeof payload.tool === "string" ? payload.tool : "tool";
|
|
1682
|
+
appendToolUseMessage(messages, id, `mcp:${server}/${tool}`, parseObjectLike(payload.arguments));
|
|
1683
|
+
continue;
|
|
1684
|
+
}
|
|
1685
|
+
if (payload.type === "file_change") {
|
|
1686
|
+
const id = typeof payload.id === "string"
|
|
1687
|
+
? payload.id
|
|
1688
|
+
: typeof payload.call_id === "string"
|
|
1689
|
+
? payload.call_id
|
|
1690
|
+
: `file-change-${index}`;
|
|
1691
|
+
const input = Array.isArray(payload.changes)
|
|
1692
|
+
? { changes: payload.changes }
|
|
1693
|
+
: parseObjectLike(payload.changes);
|
|
1694
|
+
appendToolUseMessage(messages, id, "FileChange", input);
|
|
1695
|
+
continue;
|
|
1696
|
+
}
|
|
1697
|
+
if (payload.type === "web_search") {
|
|
1698
|
+
const id = typeof payload.id === "string"
|
|
1699
|
+
? payload.id
|
|
1700
|
+
: typeof payload.call_id === "string"
|
|
1701
|
+
? payload.call_id
|
|
1702
|
+
: `web-search-${index}`;
|
|
1703
|
+
const input = typeof payload.query === "string"
|
|
1704
|
+
? { query: payload.query }
|
|
1705
|
+
: getCodexSearchInput(payload);
|
|
1706
|
+
appendToolUseMessage(messages, id, "WebSearch", input);
|
|
1707
|
+
}
|
|
1708
|
+
}
|
|
1709
|
+
}
|
|
1710
|
+
return messages;
|
|
1711
|
+
}
|
|
1712
|
+
/**
|
|
1713
|
+
* Look up session metadata for a set of Claude CLI sessionIds.
|
|
1714
|
+
* Returns a map from sessionId to a subset of session metadata.
|
|
1715
|
+
* More efficient than getAllRecentSessions when you only need a few entries.
|
|
1716
|
+
*/
|
|
1717
|
+
export async function findSessionsByClaudeIds(ids) {
|
|
1718
|
+
if (ids.size === 0)
|
|
1719
|
+
return new Map();
|
|
1720
|
+
const result = new Map();
|
|
1721
|
+
const remaining = new Set(ids);
|
|
1722
|
+
const projectsDir = join(homedir(), ".claude", "projects");
|
|
1723
|
+
let projectDirs;
|
|
1724
|
+
try {
|
|
1725
|
+
projectDirs = await readdir(projectsDir);
|
|
1726
|
+
}
|
|
1727
|
+
catch {
|
|
1728
|
+
return result;
|
|
1729
|
+
}
|
|
1730
|
+
for (const dirName of projectDirs) {
|
|
1731
|
+
if (remaining.size === 0)
|
|
1732
|
+
break;
|
|
1733
|
+
if (dirName.startsWith("."))
|
|
1734
|
+
continue;
|
|
1735
|
+
const indexPath = join(projectsDir, dirName, "sessions-index.json");
|
|
1736
|
+
let raw;
|
|
1737
|
+
try {
|
|
1738
|
+
raw = await readFile(indexPath, "utf-8");
|
|
1739
|
+
}
|
|
1740
|
+
catch {
|
|
1741
|
+
continue;
|
|
1742
|
+
}
|
|
1743
|
+
let index;
|
|
1744
|
+
try {
|
|
1745
|
+
index = JSON.parse(raw);
|
|
1746
|
+
}
|
|
1747
|
+
catch {
|
|
1748
|
+
continue;
|
|
1749
|
+
}
|
|
1750
|
+
if (!Array.isArray(index.entries))
|
|
1751
|
+
continue;
|
|
1752
|
+
for (const entry of index.entries) {
|
|
1753
|
+
const sid = entry.sessionId;
|
|
1754
|
+
if (!sid || !remaining.has(sid))
|
|
1755
|
+
continue;
|
|
1756
|
+
result.set(sid, {
|
|
1757
|
+
summary: entry.summary,
|
|
1758
|
+
firstPrompt: entry.firstPrompt ?? "",
|
|
1759
|
+
lastPrompt: entry.lastPrompt,
|
|
1760
|
+
projectPath: normalizeWorktreePath(entry.projectPath ?? ""),
|
|
1761
|
+
});
|
|
1762
|
+
remaining.delete(sid);
|
|
1763
|
+
}
|
|
1764
|
+
}
|
|
1765
|
+
return result;
|
|
1766
|
+
}
|
|
1767
|
+
//# sourceMappingURL=sessions-index.js.map
|