@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.
Files changed (65) hide show
  1. package/README.ja.md +1 -1
  2. package/README.md +1 -1
  3. package/dist/cli/ui/components/App.d.ts.map +1 -1
  4. package/dist/cli/ui/components/App.js +8 -8
  5. package/dist/cli/ui/components/App.js.map +1 -1
  6. package/dist/cli/ui/components/screens/BranchListScreen.d.ts.map +1 -1
  7. package/dist/cli/ui/components/screens/BranchListScreen.js +9 -5
  8. package/dist/cli/ui/components/screens/BranchListScreen.js.map +1 -1
  9. package/dist/cli/ui/utils/branchFormatter.d.ts.map +1 -1
  10. package/dist/cli/ui/utils/branchFormatter.js +2 -2
  11. package/dist/cli/ui/utils/branchFormatter.js.map +1 -1
  12. package/dist/index.js +1 -1
  13. package/dist/index.js.map +1 -1
  14. package/dist/utils/session/common.d.ts +100 -0
  15. package/dist/utils/session/common.d.ts.map +1 -0
  16. package/dist/utils/session/common.js +417 -0
  17. package/dist/utils/session/common.js.map +1 -0
  18. package/dist/utils/session/index.d.ts +16 -0
  19. package/dist/utils/session/index.d.ts.map +1 -0
  20. package/dist/utils/session/index.js +20 -0
  21. package/dist/utils/session/index.js.map +1 -0
  22. package/dist/utils/session/parsers/claude.d.ts +56 -0
  23. package/dist/utils/session/parsers/claude.d.ts.map +1 -0
  24. package/dist/utils/session/parsers/claude.js +178 -0
  25. package/dist/utils/session/parsers/claude.js.map +1 -0
  26. package/dist/utils/session/parsers/codex.d.ts +37 -0
  27. package/dist/utils/session/parsers/codex.d.ts.map +1 -0
  28. package/dist/utils/session/parsers/codex.js +113 -0
  29. package/dist/utils/session/parsers/codex.js.map +1 -0
  30. package/dist/utils/session/parsers/gemini.d.ts +22 -0
  31. package/dist/utils/session/parsers/gemini.d.ts.map +1 -0
  32. package/dist/utils/session/parsers/gemini.js +81 -0
  33. package/dist/utils/session/parsers/gemini.js.map +1 -0
  34. package/dist/utils/session/parsers/index.d.ts +8 -0
  35. package/dist/utils/session/parsers/index.d.ts.map +1 -0
  36. package/dist/utils/session/parsers/index.js +12 -0
  37. package/dist/utils/session/parsers/index.js.map +1 -0
  38. package/dist/utils/session/parsers/qwen.d.ts +21 -0
  39. package/dist/utils/session/parsers/qwen.d.ts.map +1 -0
  40. package/dist/utils/session/parsers/qwen.js +36 -0
  41. package/dist/utils/session/parsers/qwen.js.map +1 -0
  42. package/dist/utils/session/types.d.ts +38 -0
  43. package/dist/utils/session/types.d.ts.map +1 -0
  44. package/dist/utils/session/types.js +5 -0
  45. package/dist/utils/session/types.js.map +1 -0
  46. package/dist/utils/session.d.ts +14 -79
  47. package/dist/utils/session.d.ts.map +1 -1
  48. package/dist/utils/session.js +14 -585
  49. package/dist/utils/session.js.map +1 -1
  50. package/package.json +1 -1
  51. package/src/cli/ui/__tests__/components/screens/BranchListScreen.test.tsx +40 -1
  52. package/src/cli/ui/__tests__/utils/branchFormatter.test.ts +1 -1
  53. package/src/cli/ui/components/App.tsx +10 -8
  54. package/src/cli/ui/components/screens/BranchListScreen.tsx +8 -6
  55. package/src/cli/ui/utils/branchFormatter.ts +2 -3
  56. package/src/index.ts +1 -1
  57. package/src/utils/session/common.ts +446 -0
  58. package/src/utils/session/index.ts +46 -0
  59. package/src/utils/session/parsers/claude.ts +233 -0
  60. package/src/utils/session/parsers/codex.ts +135 -0
  61. package/src/utils/session/parsers/gemini.ts +94 -0
  62. package/src/utils/session/parsers/index.ts +28 -0
  63. package/src/utils/session/parsers/qwen.ts +54 -0
  64. package/src/utils/session/types.ts +42 -0
  65. package/src/utils/session.ts +14 -755
@@ -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
- * Returns the list of possible Claude root directories.
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
- for (const root of roots) {
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";