@akiojin/gwt 3.1.0 → 3.1.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.ja.md +1 -1
- package/README.md +1 -1
- package/dist/cli/ui/components/App.d.ts.map +1 -1
- package/dist/cli/ui/components/App.js +8 -8
- package/dist/cli/ui/components/App.js.map +1 -1
- package/dist/cli/ui/components/screens/BranchListScreen.d.ts.map +1 -1
- package/dist/cli/ui/components/screens/BranchListScreen.js +9 -5
- package/dist/cli/ui/components/screens/BranchListScreen.js.map +1 -1
- package/dist/cli/ui/utils/branchFormatter.d.ts.map +1 -1
- package/dist/cli/ui/utils/branchFormatter.js +2 -2
- package/dist/cli/ui/utils/branchFormatter.js.map +1 -1
- package/dist/index.js +1 -1
- package/dist/index.js.map +1 -1
- package/dist/utils/session/common.d.ts +100 -0
- package/dist/utils/session/common.d.ts.map +1 -0
- package/dist/utils/session/common.js +417 -0
- package/dist/utils/session/common.js.map +1 -0
- package/dist/utils/session/index.d.ts +16 -0
- package/dist/utils/session/index.d.ts.map +1 -0
- package/dist/utils/session/index.js +20 -0
- package/dist/utils/session/index.js.map +1 -0
- package/dist/utils/session/parsers/claude.d.ts +56 -0
- package/dist/utils/session/parsers/claude.d.ts.map +1 -0
- package/dist/utils/session/parsers/claude.js +178 -0
- package/dist/utils/session/parsers/claude.js.map +1 -0
- package/dist/utils/session/parsers/codex.d.ts +37 -0
- package/dist/utils/session/parsers/codex.d.ts.map +1 -0
- package/dist/utils/session/parsers/codex.js +113 -0
- package/dist/utils/session/parsers/codex.js.map +1 -0
- package/dist/utils/session/parsers/gemini.d.ts +22 -0
- package/dist/utils/session/parsers/gemini.d.ts.map +1 -0
- package/dist/utils/session/parsers/gemini.js +81 -0
- package/dist/utils/session/parsers/gemini.js.map +1 -0
- package/dist/utils/session/parsers/index.d.ts +8 -0
- package/dist/utils/session/parsers/index.d.ts.map +1 -0
- package/dist/utils/session/parsers/index.js +12 -0
- package/dist/utils/session/parsers/index.js.map +1 -0
- package/dist/utils/session/parsers/qwen.d.ts +21 -0
- package/dist/utils/session/parsers/qwen.d.ts.map +1 -0
- package/dist/utils/session/parsers/qwen.js +36 -0
- package/dist/utils/session/parsers/qwen.js.map +1 -0
- package/dist/utils/session/types.d.ts +38 -0
- package/dist/utils/session/types.d.ts.map +1 -0
- package/dist/utils/session/types.js +5 -0
- package/dist/utils/session/types.js.map +1 -0
- package/dist/utils/session.d.ts +14 -79
- package/dist/utils/session.d.ts.map +1 -1
- package/dist/utils/session.js +14 -585
- package/dist/utils/session.js.map +1 -1
- package/package.json +1 -1
- package/src/cli/ui/__tests__/components/screens/BranchListScreen.test.tsx +40 -1
- package/src/cli/ui/__tests__/utils/branchFormatter.test.ts +1 -1
- package/src/cli/ui/components/App.tsx +10 -8
- package/src/cli/ui/components/screens/BranchListScreen.tsx +8 -6
- package/src/cli/ui/utils/branchFormatter.ts +2 -3
- package/src/index.ts +1 -1
- package/src/utils/session/common.ts +446 -0
- package/src/utils/session/index.ts +46 -0
- package/src/utils/session/parsers/claude.ts +233 -0
- package/src/utils/session/parsers/codex.ts +135 -0
- package/src/utils/session/parsers/gemini.ts +94 -0
- package/src/utils/session/parsers/index.ts +28 -0
- package/src/utils/session/parsers/qwen.ts +54 -0
- package/src/utils/session/types.ts +42 -0
- package/src/utils/session.ts +14 -755
package/src/utils/session.ts
CHANGED
|
@@ -1,758 +1,17 @@
|
|
|
1
|
-
import path from "node:path";
|
|
2
|
-
import { homedir } from "node:os";
|
|
3
|
-
import { readdir, readFile, stat } from "node:fs/promises";
|
|
4
|
-
|
|
5
|
-
const UUID_REGEX =
|
|
6
|
-
/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/i;
|
|
7
|
-
|
|
8
|
-
/**
|
|
9
|
-
* Validates that a string is a properly formatted UUID session ID.
|
|
10
|
-
* @param id - The string to validate
|
|
11
|
-
* @returns true if the string is a valid UUID format
|
|
12
|
-
*/
|
|
13
|
-
export function isValidUuidSessionId(id: string): boolean {
|
|
14
|
-
return /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(
|
|
15
|
-
id,
|
|
16
|
-
);
|
|
17
|
-
}
|
|
18
|
-
|
|
19
|
-
function pickSessionIdFromObject(obj: unknown): string | null {
|
|
20
|
-
if (!obj || typeof obj !== "object") return null;
|
|
21
|
-
const candidate = obj as Record<string, unknown>;
|
|
22
|
-
const keys = ["sessionId", "session_id", "id", "conversation_id"];
|
|
23
|
-
for (const key of keys) {
|
|
24
|
-
const value = candidate[key];
|
|
25
|
-
if (typeof value === "string" && value.trim().length > 0) {
|
|
26
|
-
const trimmed = value.trim();
|
|
27
|
-
// Only accept values that are valid UUIDs to avoid picking up arbitrary strings
|
|
28
|
-
if (isValidUuidSessionId(trimmed)) {
|
|
29
|
-
return trimmed;
|
|
30
|
-
}
|
|
31
|
-
}
|
|
32
|
-
}
|
|
33
|
-
return null;
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
function pickCwdFromObject(obj: unknown): string | null {
|
|
37
|
-
if (!obj || typeof obj !== "object") return null;
|
|
38
|
-
const candidate = obj as Record<string, unknown>;
|
|
39
|
-
const keys = [
|
|
40
|
-
"cwd",
|
|
41
|
-
"workingDirectory",
|
|
42
|
-
"workdir",
|
|
43
|
-
"directory",
|
|
44
|
-
"projectPath",
|
|
45
|
-
];
|
|
46
|
-
for (const key of keys) {
|
|
47
|
-
const value = candidate[key];
|
|
48
|
-
if (typeof value === "string" && value.trim().length > 0) {
|
|
49
|
-
return value;
|
|
50
|
-
}
|
|
51
|
-
}
|
|
52
|
-
// Check nested payload object (for Codex session format)
|
|
53
|
-
const payload = candidate["payload"];
|
|
54
|
-
if (payload && typeof payload === "object") {
|
|
55
|
-
const nested = pickCwdFromObject(payload);
|
|
56
|
-
if (nested) return nested;
|
|
57
|
-
}
|
|
58
|
-
return null;
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
function pickSessionIdFromText(content: string): string | null {
|
|
62
|
-
// Try whole content as JSON
|
|
63
|
-
try {
|
|
64
|
-
const parsed = JSON.parse(content);
|
|
65
|
-
const fromObject = pickSessionIdFromObject(parsed);
|
|
66
|
-
if (fromObject) return fromObject;
|
|
67
|
-
} catch {
|
|
68
|
-
// ignore
|
|
69
|
-
}
|
|
70
|
-
|
|
71
|
-
// Try JSONL lines
|
|
72
|
-
const lines = content.split(/\r?\n/);
|
|
73
|
-
for (const line of lines) {
|
|
74
|
-
const trimmed = line.trim();
|
|
75
|
-
if (!trimmed) continue;
|
|
76
|
-
try {
|
|
77
|
-
const parsedLine = JSON.parse(trimmed);
|
|
78
|
-
const fromLine = pickSessionIdFromObject(parsedLine);
|
|
79
|
-
if (fromLine) return fromLine;
|
|
80
|
-
} catch {
|
|
81
|
-
// ignore
|
|
82
|
-
}
|
|
83
|
-
const match = trimmed.match(UUID_REGEX);
|
|
84
|
-
if (match) return match[0];
|
|
85
|
-
}
|
|
86
|
-
|
|
87
|
-
// Fallback: find any UUID in the whole text
|
|
88
|
-
const match = content.match(UUID_REGEX);
|
|
89
|
-
return match ? match[0] : null;
|
|
90
|
-
}
|
|
91
|
-
|
|
92
|
-
async function findLatestFile(
|
|
93
|
-
dir: string,
|
|
94
|
-
filter: (name: string) => boolean,
|
|
95
|
-
): Promise<string | null> {
|
|
96
|
-
try {
|
|
97
|
-
const entries = await readdir(dir, { withFileTypes: true });
|
|
98
|
-
const files = entries.filter((e) => e.isFile()).map((e) => e.name);
|
|
99
|
-
const filtered = files.filter(filter);
|
|
100
|
-
if (!filtered.length) return null;
|
|
101
|
-
|
|
102
|
-
const withStats = await Promise.all(
|
|
103
|
-
filtered.map(async (name) => {
|
|
104
|
-
const fullPath = path.join(dir, name);
|
|
105
|
-
try {
|
|
106
|
-
const info = await stat(fullPath);
|
|
107
|
-
return { fullPath, mtime: info.mtimeMs };
|
|
108
|
-
} catch {
|
|
109
|
-
return null;
|
|
110
|
-
}
|
|
111
|
-
}),
|
|
112
|
-
);
|
|
113
|
-
|
|
114
|
-
const valid = withStats.filter(
|
|
115
|
-
(entry): entry is { fullPath: string; mtime: number } => Boolean(entry),
|
|
116
|
-
);
|
|
117
|
-
if (!valid.length) return null;
|
|
118
|
-
|
|
119
|
-
valid.sort((a, b) => b.mtime - a.mtime);
|
|
120
|
-
return valid[0]?.fullPath ?? null;
|
|
121
|
-
} catch {
|
|
122
|
-
return null;
|
|
123
|
-
}
|
|
124
|
-
}
|
|
125
|
-
|
|
126
|
-
async function findNewestSessionIdFromDir(
|
|
127
|
-
dir: string,
|
|
128
|
-
recursive: boolean,
|
|
129
|
-
options: {
|
|
130
|
-
since?: number;
|
|
131
|
-
until?: number;
|
|
132
|
-
preferClosestTo?: number;
|
|
133
|
-
windowMs?: number;
|
|
134
|
-
} = {},
|
|
135
|
-
): Promise<{ id: string; mtime: number } | null> {
|
|
136
|
-
try {
|
|
137
|
-
const files: { fullPath: string; mtime: number }[] = [];
|
|
138
|
-
|
|
139
|
-
const processDir = async (currentDir: string) => {
|
|
140
|
-
const currentEntries = await readdir(currentDir, { withFileTypes: true });
|
|
141
|
-
for (const entry of currentEntries) {
|
|
142
|
-
const fullPath = path.join(currentDir, entry.name);
|
|
143
|
-
if (entry.isDirectory()) {
|
|
144
|
-
if (recursive) {
|
|
145
|
-
await processDir(fullPath);
|
|
146
|
-
}
|
|
147
|
-
continue;
|
|
148
|
-
}
|
|
149
|
-
if (!entry.isFile()) continue;
|
|
150
|
-
if (!entry.name.endsWith(".json") && !entry.name.endsWith(".jsonl"))
|
|
151
|
-
continue;
|
|
152
|
-
try {
|
|
153
|
-
const info = await stat(fullPath);
|
|
154
|
-
files.push({ fullPath, mtime: info.mtimeMs });
|
|
155
|
-
} catch {
|
|
156
|
-
// ignore unreadable
|
|
157
|
-
}
|
|
158
|
-
}
|
|
159
|
-
};
|
|
160
|
-
|
|
161
|
-
await processDir(dir);
|
|
162
|
-
|
|
163
|
-
// Apply since/until filters clearly
|
|
164
|
-
const filtered = files.filter((f) => {
|
|
165
|
-
if (options.since !== undefined && f.mtime < options.since) return false;
|
|
166
|
-
if (options.until !== undefined && f.mtime > options.until) return false;
|
|
167
|
-
return true;
|
|
168
|
-
});
|
|
169
|
-
|
|
170
|
-
if (!filtered.length) return null;
|
|
171
|
-
|
|
172
|
-
// Sort by mtime descending (newest first)
|
|
173
|
-
let pool = filtered.sort((a, b) => b.mtime - a.mtime);
|
|
174
|
-
|
|
175
|
-
// Apply preferClosestTo window if specified
|
|
176
|
-
const ref = options.preferClosestTo;
|
|
177
|
-
if (typeof ref === "number") {
|
|
178
|
-
const window = options.windowMs ?? 30 * 60 * 1000;
|
|
179
|
-
const withinWindow = pool.filter(
|
|
180
|
-
(f) => Math.abs(f.mtime - ref) <= window,
|
|
181
|
-
);
|
|
182
|
-
if (withinWindow.length) {
|
|
183
|
-
pool = withinWindow.sort((a, b) => b.mtime - a.mtime);
|
|
184
|
-
}
|
|
185
|
-
}
|
|
186
|
-
|
|
187
|
-
for (const file of pool) {
|
|
188
|
-
const id = await readSessionIdFromFile(file.fullPath);
|
|
189
|
-
if (id) return { id, mtime: file.mtime };
|
|
190
|
-
}
|
|
191
|
-
} catch {
|
|
192
|
-
// ignore
|
|
193
|
-
}
|
|
194
|
-
return null;
|
|
195
|
-
}
|
|
196
|
-
|
|
197
|
-
async function readSessionIdFromFile(filePath: string): Promise<string | null> {
|
|
198
|
-
try {
|
|
199
|
-
// Priority 1: Use filename UUID (most reliable for Claude session files)
|
|
200
|
-
// Claude session files are named with their session ID: {uuid}.jsonl
|
|
201
|
-
const basename = path.basename(filePath);
|
|
202
|
-
const filenameWithoutExt = basename.replace(/\.(json|jsonl)$/i, "");
|
|
203
|
-
if (isValidUuidSessionId(filenameWithoutExt)) {
|
|
204
|
-
return filenameWithoutExt;
|
|
205
|
-
}
|
|
206
|
-
|
|
207
|
-
// Priority 2: Extract from file content (for other formats)
|
|
208
|
-
const content = await readFile(filePath, "utf-8");
|
|
209
|
-
const fromContent = pickSessionIdFromText(content);
|
|
210
|
-
if (fromContent) return fromContent;
|
|
211
|
-
|
|
212
|
-
// Priority 3: Fallback to any UUID in filename
|
|
213
|
-
const filenameMatch = basename.match(UUID_REGEX);
|
|
214
|
-
return filenameMatch ? filenameMatch[0] : null;
|
|
215
|
-
} catch {
|
|
216
|
-
return null;
|
|
217
|
-
}
|
|
218
|
-
}
|
|
219
|
-
|
|
220
|
-
async function readSessionInfoFromFile(
|
|
221
|
-
filePath: string,
|
|
222
|
-
): Promise<{ id: string | null; cwd: string | null }> {
|
|
223
|
-
try {
|
|
224
|
-
const content = await readFile(filePath, "utf-8");
|
|
225
|
-
try {
|
|
226
|
-
const parsed = JSON.parse(content);
|
|
227
|
-
const id = pickSessionIdFromObject(parsed);
|
|
228
|
-
const cwd = pickCwdFromObject(parsed);
|
|
229
|
-
if (id || cwd) return { id, cwd };
|
|
230
|
-
} catch {
|
|
231
|
-
// ignore
|
|
232
|
-
}
|
|
233
|
-
|
|
234
|
-
const lines = content.split(/\r?\n/);
|
|
235
|
-
for (const line of lines) {
|
|
236
|
-
const trimmed = line.trim();
|
|
237
|
-
if (!trimmed) continue;
|
|
238
|
-
try {
|
|
239
|
-
const parsedLine = JSON.parse(trimmed);
|
|
240
|
-
const id = pickSessionIdFromObject(parsedLine);
|
|
241
|
-
const cwd = pickCwdFromObject(parsedLine);
|
|
242
|
-
if (id || cwd) return { id, cwd };
|
|
243
|
-
} catch {
|
|
244
|
-
// ignore
|
|
245
|
-
}
|
|
246
|
-
}
|
|
247
|
-
|
|
248
|
-
// Fallback: filename UUID
|
|
249
|
-
const filenameMatch = path.basename(filePath).match(UUID_REGEX);
|
|
250
|
-
if (filenameMatch) return { id: filenameMatch[0], cwd: null };
|
|
251
|
-
} catch {
|
|
252
|
-
// ignore unreadable
|
|
253
|
-
}
|
|
254
|
-
return { id: null, cwd: null };
|
|
255
|
-
}
|
|
256
|
-
|
|
257
|
-
export interface CodexSessionInfo {
|
|
258
|
-
id: string;
|
|
259
|
-
mtime: number;
|
|
260
|
-
}
|
|
261
|
-
|
|
262
|
-
export interface GeminiSessionInfo {
|
|
263
|
-
id: string;
|
|
264
|
-
mtime: number;
|
|
265
|
-
}
|
|
266
|
-
|
|
267
|
-
async function collectFilesRecursive(
|
|
268
|
-
dir: string,
|
|
269
|
-
filter: (name: string) => boolean,
|
|
270
|
-
): Promise<{ fullPath: string; mtime: number }[]> {
|
|
271
|
-
const results: { fullPath: string; mtime: number }[] = [];
|
|
272
|
-
try {
|
|
273
|
-
const entries = await readdir(dir, { withFileTypes: true });
|
|
274
|
-
for (const entry of entries) {
|
|
275
|
-
const fullPath = path.join(dir, entry.name);
|
|
276
|
-
if (entry.isDirectory()) {
|
|
277
|
-
const nested = await collectFilesRecursive(fullPath, filter);
|
|
278
|
-
results.push(...nested);
|
|
279
|
-
} else if (entry.isFile() && filter(entry.name)) {
|
|
280
|
-
try {
|
|
281
|
-
const info = await stat(fullPath);
|
|
282
|
-
results.push({ fullPath, mtime: info.mtimeMs });
|
|
283
|
-
} catch {
|
|
284
|
-
// ignore unreadable file
|
|
285
|
-
}
|
|
286
|
-
}
|
|
287
|
-
}
|
|
288
|
-
} catch {
|
|
289
|
-
// ignore unreadable directory
|
|
290
|
-
}
|
|
291
|
-
return results;
|
|
292
|
-
}
|
|
293
|
-
|
|
294
|
-
export async function findLatestCodexSession(
|
|
295
|
-
options: {
|
|
296
|
-
since?: number;
|
|
297
|
-
until?: number;
|
|
298
|
-
preferClosestTo?: number;
|
|
299
|
-
windowMs?: number;
|
|
300
|
-
cwd?: string | null;
|
|
301
|
-
} = {},
|
|
302
|
-
): Promise<CodexSessionInfo | null> {
|
|
303
|
-
// Codex CLI respects CODEX_HOME. Default is ~/.codex.
|
|
304
|
-
const codexHome = process.env.CODEX_HOME ?? path.join(homedir(), ".codex");
|
|
305
|
-
const baseDir = path.join(codexHome, "sessions");
|
|
306
|
-
const candidates = await collectFilesRecursive(
|
|
307
|
-
baseDir,
|
|
308
|
-
(name) => name.endsWith(".json") || name.endsWith(".jsonl"),
|
|
309
|
-
);
|
|
310
|
-
if (!candidates.length) return null;
|
|
311
|
-
|
|
312
|
-
const sinceFiltered = options.since
|
|
313
|
-
? candidates.filter((c) => c.mtime >= options.since!)
|
|
314
|
-
: candidates;
|
|
315
|
-
const bounded =
|
|
316
|
-
options.until !== undefined
|
|
317
|
-
? sinceFiltered.filter((c) => c.mtime <= options.until!)
|
|
318
|
-
: sinceFiltered;
|
|
319
|
-
const hasWindow = options.since !== undefined || options.until !== undefined;
|
|
320
|
-
const pool = bounded.length ? bounded : hasWindow ? [] : sinceFiltered;
|
|
321
|
-
|
|
322
|
-
if (!pool.length) return null;
|
|
323
|
-
|
|
324
|
-
const ref = options.preferClosestTo;
|
|
325
|
-
const window = options.windowMs ?? 30 * 60 * 1000; // 30 minutes default
|
|
326
|
-
const ordered = [...pool].sort((a, b) => {
|
|
327
|
-
if (typeof ref === "number") {
|
|
328
|
-
const da = Math.abs(a.mtime - ref);
|
|
329
|
-
const db = Math.abs(b.mtime - ref);
|
|
330
|
-
if (da === db) return b.mtime - a.mtime;
|
|
331
|
-
if (da <= window || db <= window) return da - db;
|
|
332
|
-
}
|
|
333
|
-
return b.mtime - a.mtime;
|
|
334
|
-
});
|
|
335
|
-
|
|
336
|
-
for (const file of ordered) {
|
|
337
|
-
// Priority 1: Extract session ID from filename (most reliable for Codex)
|
|
338
|
-
// Codex filenames follow pattern: rollout-YYYY-MM-DDTHH-MM-SS-{uuid}.jsonl
|
|
339
|
-
const filenameMatch = path.basename(file.fullPath).match(UUID_REGEX);
|
|
340
|
-
if (filenameMatch) {
|
|
341
|
-
const sessionId = filenameMatch[0];
|
|
342
|
-
// If cwd filtering is needed, read file content to check cwd
|
|
343
|
-
if (options.cwd) {
|
|
344
|
-
const info = await readSessionInfoFromFile(file.fullPath);
|
|
345
|
-
if (
|
|
346
|
-
info.cwd &&
|
|
347
|
-
// Match if: exact match, session cwd starts with options.cwd,
|
|
348
|
-
// or options.cwd starts with session cwd (for worktree subdirectories)
|
|
349
|
-
(info.cwd === options.cwd ||
|
|
350
|
-
info.cwd.startsWith(options.cwd) ||
|
|
351
|
-
options.cwd.startsWith(info.cwd))
|
|
352
|
-
) {
|
|
353
|
-
return { id: sessionId, mtime: file.mtime };
|
|
354
|
-
}
|
|
355
|
-
continue; // cwd doesn't match, try next file
|
|
356
|
-
}
|
|
357
|
-
return { id: sessionId, mtime: file.mtime };
|
|
358
|
-
}
|
|
359
|
-
|
|
360
|
-
// Priority 2: Fallback to reading file content if filename lacks UUID
|
|
361
|
-
const info = await readSessionInfoFromFile(file.fullPath);
|
|
362
|
-
if (!info.id) continue;
|
|
363
|
-
if (options.cwd) {
|
|
364
|
-
if (
|
|
365
|
-
info.cwd &&
|
|
366
|
-
// Match if: exact match, session cwd starts with options.cwd,
|
|
367
|
-
// or options.cwd starts with session cwd (for worktree subdirectories)
|
|
368
|
-
(info.cwd === options.cwd ||
|
|
369
|
-
info.cwd.startsWith(options.cwd) ||
|
|
370
|
-
options.cwd.startsWith(info.cwd))
|
|
371
|
-
) {
|
|
372
|
-
return { id: info.id, mtime: file.mtime };
|
|
373
|
-
}
|
|
374
|
-
continue;
|
|
375
|
-
}
|
|
376
|
-
return { id: info.id, mtime: file.mtime };
|
|
377
|
-
}
|
|
378
|
-
|
|
379
|
-
return null;
|
|
380
|
-
}
|
|
381
|
-
|
|
382
|
-
export async function findLatestCodexSessionId(
|
|
383
|
-
options: {
|
|
384
|
-
since?: number;
|
|
385
|
-
until?: number;
|
|
386
|
-
preferClosestTo?: number;
|
|
387
|
-
windowMs?: number;
|
|
388
|
-
cwd?: string | null;
|
|
389
|
-
} = {},
|
|
390
|
-
): Promise<string | null> {
|
|
391
|
-
const found = await findLatestCodexSession(options);
|
|
392
|
-
return found?.id ?? null;
|
|
393
|
-
}
|
|
394
|
-
|
|
395
|
-
export async function waitForCodexSessionId(options: {
|
|
396
|
-
startedAt: number;
|
|
397
|
-
timeoutMs?: number;
|
|
398
|
-
pollIntervalMs?: number;
|
|
399
|
-
cwd?: string | null;
|
|
400
|
-
}): Promise<string | null> {
|
|
401
|
-
const timeoutMs = options.timeoutMs ?? 120_000;
|
|
402
|
-
const pollIntervalMs = options.pollIntervalMs ?? 2_000;
|
|
403
|
-
const deadline = Date.now() + timeoutMs;
|
|
404
|
-
|
|
405
|
-
while (Date.now() < deadline) {
|
|
406
|
-
const found = await findLatestCodexSession({
|
|
407
|
-
since: options.startedAt,
|
|
408
|
-
preferClosestTo: options.startedAt,
|
|
409
|
-
windowMs: 10 * 60 * 1000,
|
|
410
|
-
cwd: options.cwd ?? null,
|
|
411
|
-
});
|
|
412
|
-
if (found?.id) return found.id;
|
|
413
|
-
await new Promise((resolve) => setTimeout(resolve, pollIntervalMs));
|
|
414
|
-
}
|
|
415
|
-
return null;
|
|
416
|
-
}
|
|
417
|
-
|
|
418
|
-
export function encodeClaudeProjectPath(cwd: string): string {
|
|
419
|
-
// Normalize to forward slashes, drop drive colon, replace / and _ with -
|
|
420
|
-
const normalized = cwd.replace(/\\/g, "/").replace(/:/g, "");
|
|
421
|
-
return normalized.replace(/_/g, "-").replace(/\//g, "-");
|
|
422
|
-
}
|
|
423
|
-
|
|
424
|
-
function generateClaudeProjectPathCandidates(cwd: string): string[] {
|
|
425
|
-
const base = encodeClaudeProjectPath(cwd);
|
|
426
|
-
const dotToDash = cwd
|
|
427
|
-
.replace(/\\/g, "/")
|
|
428
|
-
.replace(/:/g, "")
|
|
429
|
-
.replace(/\./g, "-")
|
|
430
|
-
.replace(/_/g, "-")
|
|
431
|
-
.replace(/\//g, "-");
|
|
432
|
-
const collapsed = dotToDash.replace(/-+/g, "-");
|
|
433
|
-
const candidates = [base, dotToDash, collapsed];
|
|
434
|
-
return Array.from(new Set(candidates));
|
|
435
|
-
}
|
|
436
|
-
|
|
437
|
-
export async function findLatestClaudeSessionId(
|
|
438
|
-
cwd: string,
|
|
439
|
-
options: {
|
|
440
|
-
since?: number;
|
|
441
|
-
until?: number;
|
|
442
|
-
preferClosestTo?: number;
|
|
443
|
-
windowMs?: number;
|
|
444
|
-
} = {},
|
|
445
|
-
): Promise<string | null> {
|
|
446
|
-
const found = await findLatestClaudeSession(cwd, options);
|
|
447
|
-
return found?.id ?? null;
|
|
448
|
-
}
|
|
449
|
-
|
|
450
|
-
export interface ClaudeSessionInfo {
|
|
451
|
-
id: string;
|
|
452
|
-
mtime: number;
|
|
453
|
-
}
|
|
454
|
-
|
|
455
|
-
export async function findLatestClaudeSession(
|
|
456
|
-
cwd: string,
|
|
457
|
-
options: {
|
|
458
|
-
since?: number;
|
|
459
|
-
until?: number;
|
|
460
|
-
preferClosestTo?: number;
|
|
461
|
-
windowMs?: number;
|
|
462
|
-
} = {},
|
|
463
|
-
): Promise<ClaudeSessionInfo | null> {
|
|
464
|
-
const rootCandidates: string[] = [];
|
|
465
|
-
if (process.env.CLAUDE_CONFIG_DIR) {
|
|
466
|
-
rootCandidates.push(process.env.CLAUDE_CONFIG_DIR);
|
|
467
|
-
}
|
|
468
|
-
rootCandidates.push(
|
|
469
|
-
path.join(homedir(), ".claude"),
|
|
470
|
-
path.join(homedir(), ".config", "claude"),
|
|
471
|
-
);
|
|
472
|
-
|
|
473
|
-
const encodedPaths = generateClaudeProjectPathCandidates(cwd);
|
|
474
|
-
|
|
475
|
-
for (const claudeRoot of rootCandidates) {
|
|
476
|
-
for (const encoded of encodedPaths) {
|
|
477
|
-
const projectDir = path.join(claudeRoot, "projects", encoded);
|
|
478
|
-
const sessionsDir = path.join(projectDir, "sessions");
|
|
479
|
-
|
|
480
|
-
// 1) Look under sessions/ (official location) - prefer newest file with valid ID
|
|
481
|
-
const session = await findNewestSessionIdFromDir(
|
|
482
|
-
sessionsDir,
|
|
483
|
-
false,
|
|
484
|
-
options,
|
|
485
|
-
);
|
|
486
|
-
if (session) return session;
|
|
487
|
-
|
|
488
|
-
// 2) Look directly under project dir and subdirs (some versions emit files at root)
|
|
489
|
-
const rootSession = await findNewestSessionIdFromDir(
|
|
490
|
-
projectDir,
|
|
491
|
-
true,
|
|
492
|
-
options,
|
|
493
|
-
);
|
|
494
|
-
if (rootSession) return rootSession;
|
|
495
|
-
}
|
|
496
|
-
}
|
|
497
|
-
|
|
498
|
-
// Fallback: parse ~/.claude/history.jsonl (Claude Code global history)
|
|
499
|
-
try {
|
|
500
|
-
const historyPath = path.join(homedir(), ".claude", "history.jsonl");
|
|
501
|
-
const content = await readFile(historyPath, "utf-8");
|
|
502
|
-
const lines = content.split(/\r?\n/).filter(Boolean);
|
|
503
|
-
for (let i = lines.length - 1; i >= 0; i -= 1) {
|
|
504
|
-
try {
|
|
505
|
-
const line = lines[i] ?? "";
|
|
506
|
-
const parsed = JSON.parse(line) as Record<string, unknown>;
|
|
507
|
-
const project =
|
|
508
|
-
typeof parsed.project === "string" ? parsed.project : null;
|
|
509
|
-
const sessionId =
|
|
510
|
-
typeof parsed.sessionId === "string" ? parsed.sessionId : null;
|
|
511
|
-
if (
|
|
512
|
-
project &&
|
|
513
|
-
sessionId &&
|
|
514
|
-
(project === cwd || cwd.startsWith(project))
|
|
515
|
-
) {
|
|
516
|
-
return { id: sessionId, mtime: Date.now() };
|
|
517
|
-
}
|
|
518
|
-
} catch {
|
|
519
|
-
// ignore malformed lines
|
|
520
|
-
}
|
|
521
|
-
}
|
|
522
|
-
} catch {
|
|
523
|
-
// ignore if history not present
|
|
524
|
-
}
|
|
525
|
-
|
|
526
|
-
return null;
|
|
527
|
-
}
|
|
528
|
-
|
|
529
|
-
export async function waitForClaudeSessionId(
|
|
530
|
-
cwd: string,
|
|
531
|
-
options: {
|
|
532
|
-
timeoutMs?: number;
|
|
533
|
-
pollIntervalMs?: number;
|
|
534
|
-
since?: number;
|
|
535
|
-
until?: number;
|
|
536
|
-
preferClosestTo?: number;
|
|
537
|
-
windowMs?: number;
|
|
538
|
-
} = {},
|
|
539
|
-
): Promise<string | null> {
|
|
540
|
-
const timeoutMs = options.timeoutMs ?? 120_000;
|
|
541
|
-
const pollIntervalMs = options.pollIntervalMs ?? 2_000;
|
|
542
|
-
const deadline = Date.now() + timeoutMs;
|
|
543
|
-
|
|
544
|
-
while (Date.now() < deadline) {
|
|
545
|
-
const opt: {
|
|
546
|
-
since?: number;
|
|
547
|
-
until?: number;
|
|
548
|
-
preferClosestTo?: number;
|
|
549
|
-
windowMs?: number;
|
|
550
|
-
} = {};
|
|
551
|
-
if (options.since !== undefined) opt.since = options.since;
|
|
552
|
-
if (options.until !== undefined) opt.until = options.until;
|
|
553
|
-
if (options.preferClosestTo !== undefined)
|
|
554
|
-
opt.preferClosestTo = options.preferClosestTo;
|
|
555
|
-
if (options.windowMs !== undefined) opt.windowMs = options.windowMs;
|
|
556
|
-
|
|
557
|
-
const found = await findLatestClaudeSession(cwd, opt);
|
|
558
|
-
if (found?.id) return found.id;
|
|
559
|
-
await new Promise((resolve) => setTimeout(resolve, pollIntervalMs));
|
|
560
|
-
}
|
|
561
|
-
return null;
|
|
562
|
-
}
|
|
563
|
-
|
|
564
|
-
async function findLatestNestedSessionFile(
|
|
565
|
-
baseDir: string,
|
|
566
|
-
subPath: string[],
|
|
567
|
-
predicate: (name: string) => boolean,
|
|
568
|
-
): Promise<string | null> {
|
|
569
|
-
try {
|
|
570
|
-
const entries = await readdir(baseDir);
|
|
571
|
-
if (!entries.length) return null;
|
|
572
|
-
|
|
573
|
-
const candidates: { fullPath: string; mtime: number }[] = [];
|
|
574
|
-
|
|
575
|
-
for (const entry of entries) {
|
|
576
|
-
const dirPath = path.join(baseDir, entry, ...subPath);
|
|
577
|
-
const latest = await findLatestFile(dirPath, predicate);
|
|
578
|
-
if (latest) {
|
|
579
|
-
try {
|
|
580
|
-
const info = await stat(latest);
|
|
581
|
-
candidates.push({ fullPath: latest, mtime: info.mtimeMs });
|
|
582
|
-
} catch {
|
|
583
|
-
// ignore
|
|
584
|
-
}
|
|
585
|
-
}
|
|
586
|
-
}
|
|
587
|
-
|
|
588
|
-
if (!candidates.length) return null;
|
|
589
|
-
candidates.sort((a, b) => b.mtime - a.mtime);
|
|
590
|
-
return candidates[0]?.fullPath ?? null;
|
|
591
|
-
} catch {
|
|
592
|
-
return null;
|
|
593
|
-
}
|
|
594
|
-
}
|
|
595
|
-
|
|
596
|
-
export async function findLatestGeminiSession(
|
|
597
|
-
_cwd: string,
|
|
598
|
-
options: {
|
|
599
|
-
since?: number;
|
|
600
|
-
until?: number;
|
|
601
|
-
preferClosestTo?: number;
|
|
602
|
-
windowMs?: number;
|
|
603
|
-
cwd?: string | null;
|
|
604
|
-
} = {},
|
|
605
|
-
): Promise<GeminiSessionInfo | null> {
|
|
606
|
-
// Gemini stores sessions/logs under ~/.gemini/tmp/<project_hash>/(chats|logs).json
|
|
607
|
-
const baseDir = path.join(homedir(), ".gemini", "tmp");
|
|
608
|
-
const files = await collectFilesRecursive(
|
|
609
|
-
baseDir,
|
|
610
|
-
(name) => name.endsWith(".json") || name.endsWith(".jsonl"),
|
|
611
|
-
);
|
|
612
|
-
if (!files.length) return null;
|
|
613
|
-
|
|
614
|
-
let pool = files;
|
|
615
|
-
if (options.since !== undefined) {
|
|
616
|
-
pool = pool.filter((f) => f.mtime >= options.since!);
|
|
617
|
-
}
|
|
618
|
-
if (options.until !== undefined) {
|
|
619
|
-
pool = pool.filter((f) => f.mtime <= options.until!);
|
|
620
|
-
}
|
|
621
|
-
const hasWindow = options.since !== undefined || options.until !== undefined;
|
|
622
|
-
if (!pool.length) {
|
|
623
|
-
if (!hasWindow) {
|
|
624
|
-
pool = files;
|
|
625
|
-
} else {
|
|
626
|
-
return null;
|
|
627
|
-
}
|
|
628
|
-
}
|
|
629
|
-
|
|
630
|
-
const ref = options.preferClosestTo;
|
|
631
|
-
const window = options.windowMs ?? 30 * 60 * 1000;
|
|
632
|
-
pool = pool.slice().sort((a, b) => {
|
|
633
|
-
if (typeof ref === "number") {
|
|
634
|
-
const da = Math.abs(a.mtime - ref);
|
|
635
|
-
const db = Math.abs(b.mtime - ref);
|
|
636
|
-
if (da === db) return b.mtime - a.mtime;
|
|
637
|
-
if (da <= window || db <= window) return da - db;
|
|
638
|
-
}
|
|
639
|
-
return b.mtime - a.mtime;
|
|
640
|
-
});
|
|
641
|
-
|
|
642
|
-
for (const file of pool) {
|
|
643
|
-
const info = await readSessionInfoFromFile(file.fullPath);
|
|
644
|
-
if (!info.id) continue;
|
|
645
|
-
if (options.cwd) {
|
|
646
|
-
if (
|
|
647
|
-
info.cwd &&
|
|
648
|
-
(info.cwd === options.cwd || info.cwd.startsWith(options.cwd))
|
|
649
|
-
) {
|
|
650
|
-
return { id: info.id, mtime: file.mtime };
|
|
651
|
-
}
|
|
652
|
-
continue;
|
|
653
|
-
}
|
|
654
|
-
return { id: info.id, mtime: file.mtime };
|
|
655
|
-
}
|
|
656
|
-
|
|
657
|
-
return null;
|
|
658
|
-
}
|
|
659
|
-
|
|
660
|
-
export async function findLatestGeminiSessionId(
|
|
661
|
-
cwd: string,
|
|
662
|
-
options: {
|
|
663
|
-
since?: number;
|
|
664
|
-
until?: number;
|
|
665
|
-
preferClosestTo?: number;
|
|
666
|
-
windowMs?: number;
|
|
667
|
-
cwd?: string | null;
|
|
668
|
-
} = {},
|
|
669
|
-
): Promise<string | null> {
|
|
670
|
-
const normalized: {
|
|
671
|
-
since?: number;
|
|
672
|
-
until?: number;
|
|
673
|
-
preferClosestTo?: number;
|
|
674
|
-
windowMs?: number;
|
|
675
|
-
} = {};
|
|
676
|
-
if (options.since !== undefined) normalized.since = options.since as number;
|
|
677
|
-
if (options.until !== undefined) normalized.until = options.until as number;
|
|
678
|
-
if (options.preferClosestTo !== undefined)
|
|
679
|
-
normalized.preferClosestTo = options.preferClosestTo as number;
|
|
680
|
-
if (options.windowMs !== undefined)
|
|
681
|
-
normalized.windowMs = options.windowMs as number;
|
|
682
|
-
|
|
683
|
-
const found = await findLatestGeminiSession(cwd, {
|
|
684
|
-
...normalized,
|
|
685
|
-
cwd: options.cwd ?? cwd,
|
|
686
|
-
});
|
|
687
|
-
return found?.id ?? null;
|
|
688
|
-
}
|
|
689
|
-
|
|
690
|
-
export async function findLatestQwenSessionId(
|
|
691
|
-
_cwd: string,
|
|
692
|
-
): Promise<string | null> {
|
|
693
|
-
// Qwen stores checkpoints/saves under ~/.qwen/tmp/<project_hash>/
|
|
694
|
-
const baseDir = path.join(homedir(), ".qwen", "tmp");
|
|
695
|
-
const latest =
|
|
696
|
-
(await findLatestNestedSessionFile(
|
|
697
|
-
baseDir,
|
|
698
|
-
[],
|
|
699
|
-
(name) => name.endsWith(".json") || name.endsWith(".jsonl"),
|
|
700
|
-
)) ??
|
|
701
|
-
(await findLatestNestedSessionFile(
|
|
702
|
-
baseDir,
|
|
703
|
-
["checkpoints"],
|
|
704
|
-
(name) => name.endsWith(".json") || name.endsWith(".ckpt"),
|
|
705
|
-
));
|
|
706
|
-
|
|
707
|
-
if (!latest) return null;
|
|
708
|
-
const fromContent = await readSessionIdFromFile(latest);
|
|
709
|
-
if (fromContent) return fromContent;
|
|
710
|
-
// Fallback: use filename (without extension) as tag
|
|
711
|
-
return path.basename(latest).replace(/\.[^.]+$/, "");
|
|
712
|
-
}
|
|
713
|
-
|
|
714
1
|
/**
|
|
715
|
-
*
|
|
2
|
+
* Session utilities - backward compatibility layer
|
|
3
|
+
*
|
|
4
|
+
* This file re-exports all session-related functions and types from the
|
|
5
|
+
* modularized session directory structure. Existing imports from this file
|
|
6
|
+
* will continue to work without modification.
|
|
7
|
+
*
|
|
8
|
+
* For new code, consider importing directly from:
|
|
9
|
+
* - ./session/types.js - Type definitions
|
|
10
|
+
* - ./session/common.js - Common utilities
|
|
11
|
+
* - ./session/parsers/claude.js - Claude-specific functions
|
|
12
|
+
* - ./session/parsers/codex.js - Codex-specific functions
|
|
13
|
+
* - ./session/parsers/gemini.js - Gemini-specific functions
|
|
14
|
+
* - ./session/parsers/qwen.js - Qwen-specific functions
|
|
716
15
|
*/
|
|
717
|
-
function getClaudeRootCandidates(): string[] {
|
|
718
|
-
const roots: string[] = [];
|
|
719
|
-
if (process.env.CLAUDE_CONFIG_DIR) {
|
|
720
|
-
roots.push(process.env.CLAUDE_CONFIG_DIR);
|
|
721
|
-
}
|
|
722
|
-
roots.push(
|
|
723
|
-
path.join(homedir(), ".claude"),
|
|
724
|
-
path.join(homedir(), ".config", "claude"),
|
|
725
|
-
);
|
|
726
|
-
return roots;
|
|
727
|
-
}
|
|
728
|
-
|
|
729
|
-
/**
|
|
730
|
-
* Checks if a Claude session file exists for the given session ID and worktree path.
|
|
731
|
-
* @param sessionId - The session ID to check
|
|
732
|
-
* @param worktreePath - The worktree path (used to determine project encoding)
|
|
733
|
-
* @returns true if a session file exists for this ID
|
|
734
|
-
*/
|
|
735
|
-
export async function claudeSessionFileExists(
|
|
736
|
-
sessionId: string,
|
|
737
|
-
worktreePath: string,
|
|
738
|
-
): Promise<boolean> {
|
|
739
|
-
if (!isValidUuidSessionId(sessionId)) {
|
|
740
|
-
return false;
|
|
741
|
-
}
|
|
742
|
-
|
|
743
|
-
const encodedPaths = generateClaudeProjectPathCandidates(worktreePath);
|
|
744
|
-
const roots = getClaudeRootCandidates();
|
|
745
16
|
|
|
746
|
-
|
|
747
|
-
for (const enc of encodedPaths) {
|
|
748
|
-
const candidate = path.join(root, "projects", enc, `${sessionId}.jsonl`);
|
|
749
|
-
try {
|
|
750
|
-
await stat(candidate);
|
|
751
|
-
return true;
|
|
752
|
-
} catch {
|
|
753
|
-
// continue to next candidate
|
|
754
|
-
}
|
|
755
|
-
}
|
|
756
|
-
}
|
|
757
|
-
return false;
|
|
758
|
-
}
|
|
17
|
+
export * from "./session/index.js";
|