@akiojin/gwt 2.11.1 → 2.12.1

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