@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
@@ -211,7 +211,46 @@ describe("BranchListScreen", () => {
211
211
  );
212
212
 
213
213
  const text = container.textContent ?? "";
214
- expect(text).toMatch(/\[ \]\s(🟢|⚪)\s(🛡|⚠)/); // state cluster with spacing
214
+ expect(text).toMatch(/\[ \]\s(🟢|🔴|⚪)\s(🛡|⚠)/); // state cluster with spacing
215
+ });
216
+
217
+ it("should display 🔴 for inaccessible worktree", async () => {
218
+ const onSelect = vi.fn();
219
+ const branches: BranchItem[] = [
220
+ {
221
+ ...formatBranchItem({
222
+ name: "feature/missing-worktree",
223
+ type: "local",
224
+ branchType: "feature",
225
+ isCurrent: false,
226
+ hasUnpushedCommits: false,
227
+ worktree: {
228
+ path: "/tmp/wt-missing",
229
+ locked: false,
230
+ prunable: false,
231
+ isAccessible: false,
232
+ },
233
+ }),
234
+ safeToCleanup: false,
235
+ },
236
+ ];
237
+
238
+ let renderResult: ReturnType<typeof inkRender>;
239
+ await act(async () => {
240
+ renderResult = inkRender(
241
+ <BranchListScreen
242
+ branches={branches}
243
+ stats={mockStats}
244
+ onSelect={onSelect}
245
+ />,
246
+ { stripAnsi: false },
247
+ );
248
+ });
249
+
250
+ const frame = stripControlSequences(
251
+ stripAnsi(renderResult.lastFrame() ?? ""),
252
+ );
253
+ expect(frame).toContain("[ ] 🔴 ⚠");
215
254
  });
216
255
 
217
256
  it("should render last tool usage when available and Unknown when not", () => {
@@ -284,7 +284,7 @@ describe("branchFormatter", () => {
284
284
 
285
285
  const result = formatBranchItem(branchInfo);
286
286
 
287
- expect(result.icons).toContain("🟠"); // inaccessible worktree icon
287
+ expect(result.icons).toContain("🔴"); // inaccessible worktree icon
288
288
  expect(result.icons).toContain("⚠️"); // warning icon
289
289
  expect(result.worktreeStatus).toBe("inaccessible");
290
290
  });
@@ -369,15 +369,17 @@ export function App({ onExit, loadingIndicatorDelay = 300 }: AppProps) {
369
369
  // For Gemini, prefer newest session file (Gemini keeps per-project chats)
370
370
  if (!sessionId && entry.toolId === "gemini-cli") {
371
371
  try {
372
- const gemSession = await findLatestGeminiSession(worktree, {
373
- ...(entry.timestamp !== null && entry.timestamp !== undefined
374
- ? {
375
- since: entry.timestamp - 60_000,
376
- preferClosestTo: entry.timestamp,
377
- }
378
- : {}),
372
+ const gemOptions: Parameters<
373
+ typeof findLatestGeminiSession
374
+ >[0] = {
379
375
  windowMs: 60 * 60 * 1000,
380
- });
376
+ cwd: worktree,
377
+ };
378
+ if (entry.timestamp !== null && entry.timestamp !== undefined) {
379
+ gemOptions.since = entry.timestamp - 60_000;
380
+ gemOptions.preferClosestTo = entry.timestamp;
381
+ }
382
+ const gemSession = await findLatestGeminiSession(gemOptions);
381
383
  sessionId = gemSession?.id ?? null;
382
384
  } catch {
383
385
  // ignore
@@ -27,7 +27,7 @@ const WIDTH_OVERRIDES: Record<string, number> = {
27
27
  // Worktree status icons
28
28
  "🟢": 2,
29
29
  "⚪": 2,
30
- "🟠": 1,
30
+ "🔴": 2,
31
31
  // Change status icons
32
32
  "👉": 1,
33
33
  "💾": 1,
@@ -129,7 +129,7 @@ export function BranchListScreen({
129
129
  onToggleSelect,
130
130
  }: BranchListScreenProps) {
131
131
  const { rows } = useTerminalSize();
132
- const headerText = " Legend: [ ]/[ * ] select 🟢/⚪ worktree 🛡/⚠ safe";
132
+ const headerText = " Legend: [ ]/[ * ] select 🟢/🔴/⚪ worktree 🛡/⚠ safe";
133
133
  const selectedSet = useMemo(
134
134
  () => new Set(selectedBranches),
135
135
  [selectedBranches],
@@ -406,10 +406,12 @@ export function BranchListScreen({
406
406
 
407
407
  const isChecked = selectedSet.has(item.name);
408
408
  const selectionIcon = isChecked ? "[*]" : "[ ]";
409
- const hasWorktree =
410
- item.worktreeStatus === "active" ||
411
- item.worktreeStatus === "inaccessible";
412
- const worktreeIcon = hasWorktree ? chalk.green("🟢") : chalk.gray("⚪");
409
+ let worktreeIcon = chalk.gray("⚪");
410
+ if (item.worktreeStatus === "active") {
411
+ worktreeIcon = chalk.green("🟢");
412
+ } else if (item.worktreeStatus === "inaccessible") {
413
+ worktreeIcon = chalk.red("🔴");
414
+ }
413
415
  const safeIcon =
414
416
  item.safeToCleanup === true ? chalk.green("🛡") : chalk.yellow("⚠");
415
417
  const stateCluster = `${selectionIcon} ${worktreeIcon} ${safeIcon}`;
@@ -6,7 +6,6 @@ import type {
6
6
  WorktreeInfo,
7
7
  } from "../types.js";
8
8
  import stringWidth from "string-width";
9
- import chalk from "chalk";
10
9
 
11
10
  // Icon mappings
12
11
  const branchIcons: Record<BranchType, string> = {
@@ -21,7 +20,7 @@ const branchIcons: Record<BranchType, string> = {
21
20
 
22
21
  const worktreeIcons: Record<Exclude<WorktreeStatus, undefined>, string> = {
23
22
  active: "🟢",
24
- inaccessible: "🟠",
23
+ inaccessible: "🔴",
25
24
  };
26
25
 
27
26
  const changeIcons = {
@@ -67,7 +66,7 @@ const iconWidthOverrides: Record<string, number> = {
67
66
  // Worktree status icons
68
67
  "🟢": 2,
69
68
  "⚪": 2,
70
- "🟠": 1,
69
+ "🔴": 2,
71
70
  // Change status icons
72
71
  "👉": 1,
73
72
  "💾": 1,
package/src/index.ts CHANGED
@@ -743,7 +743,7 @@ export async function handleAIToolWorkflow(
743
743
  }
744
744
  } else if (!finalSessionId && tool === "gemini-cli") {
745
745
  try {
746
- const latestGemini = await findLatestGeminiSession(worktreePath, {
746
+ const latestGemini = await findLatestGeminiSession({
747
747
  since: launchStartedAt - 60_000,
748
748
  until: finishedAt + 60_000,
749
749
  preferClosestTo: finishedAt,
@@ -0,0 +1,446 @@
1
+ /**
2
+ * Session common utilities - shared helper functions for session parsers
3
+ */
4
+
5
+ import path from "node:path";
6
+ import { readdir, readFile, stat } from "node:fs/promises";
7
+
8
+ import type { SessionSearchOptions } from "./types.js";
9
+
10
+ /**
11
+ * Regular expression for UUID matching
12
+ */
13
+ export const UUID_REGEX =
14
+ /[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/i;
15
+
16
+ /**
17
+ * Validates that a string is a properly formatted UUID session ID.
18
+ * @param id - The string to validate
19
+ * @returns true if the string is a valid UUID format
20
+ */
21
+ export function isValidUuidSessionId(id: string): boolean {
22
+ return /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(
23
+ id,
24
+ );
25
+ }
26
+
27
+ /**
28
+ * Extracts session ID from an object by checking common key names.
29
+ * Only returns valid UUIDs.
30
+ */
31
+ export function pickSessionIdFromObject(obj: unknown): string | null {
32
+ if (!obj || typeof obj !== "object") return null;
33
+ const candidate = obj as Record<string, unknown>;
34
+ const keys = ["sessionId", "session_id", "id", "conversation_id"];
35
+ for (const key of keys) {
36
+ const value = candidate[key];
37
+ if (typeof value === "string" && value.trim().length > 0) {
38
+ const trimmed = value.trim();
39
+ if (isValidUuidSessionId(trimmed)) {
40
+ return trimmed;
41
+ }
42
+ }
43
+ }
44
+ return null;
45
+ }
46
+
47
+ /**
48
+ * Extracts working directory from an object by checking common key names.
49
+ * Also checks nested payload object (for Codex session format).
50
+ */
51
+ export function pickCwdFromObject(obj: unknown): string | null {
52
+ if (!obj || typeof obj !== "object") return null;
53
+ const candidate = obj as Record<string, unknown>;
54
+ const keys = [
55
+ "cwd",
56
+ "workingDirectory",
57
+ "workdir",
58
+ "directory",
59
+ "projectPath",
60
+ ];
61
+ for (const key of keys) {
62
+ const value = candidate[key];
63
+ if (typeof value === "string" && value.trim().length > 0) {
64
+ return value;
65
+ }
66
+ }
67
+ // Check nested payload object (for Codex session format)
68
+ const payload = candidate["payload"];
69
+ if (payload && typeof payload === "object") {
70
+ const nested = pickCwdFromObject(payload);
71
+ if (nested) return nested;
72
+ }
73
+ return null;
74
+ }
75
+
76
+ /**
77
+ * Extracts session ID from text content.
78
+ * Tries JSON parsing first, then JSONL lines, then regex fallback.
79
+ */
80
+ export function pickSessionIdFromText(content: string): string | null {
81
+ // Try whole content as JSON
82
+ try {
83
+ const parsed = JSON.parse(content);
84
+ const fromObject = pickSessionIdFromObject(parsed);
85
+ if (fromObject) return fromObject;
86
+ } catch {
87
+ // ignore
88
+ }
89
+
90
+ // Try JSONL lines
91
+ const lines = content.split(/\r?\n/);
92
+ for (const line of lines) {
93
+ const trimmed = line.trim();
94
+ if (!trimmed) continue;
95
+ try {
96
+ const parsedLine = JSON.parse(trimmed);
97
+ const fromLine = pickSessionIdFromObject(parsedLine);
98
+ if (fromLine) return fromLine;
99
+ } catch {
100
+ // ignore
101
+ }
102
+ const match = trimmed.match(UUID_REGEX);
103
+ if (match) return match[0];
104
+ }
105
+
106
+ // Fallback: find any UUID in the whole text
107
+ const match = content.match(UUID_REGEX);
108
+ return match ? match[0] : null;
109
+ }
110
+
111
+ /**
112
+ * Finds the latest file in a directory matching a filter.
113
+ */
114
+ export async function findLatestFile(
115
+ dir: string,
116
+ filter: (name: string) => boolean,
117
+ ): Promise<string | null> {
118
+ try {
119
+ const entries = await readdir(dir, { withFileTypes: true });
120
+ const files = entries.filter((e) => e.isFile()).map((e) => e.name);
121
+ const filtered = files.filter(filter);
122
+ if (!filtered.length) return null;
123
+
124
+ const withStats = await Promise.all(
125
+ filtered.map(async (name) => {
126
+ const fullPath = path.join(dir, name);
127
+ try {
128
+ const info = await stat(fullPath);
129
+ return { fullPath, mtime: info.mtimeMs };
130
+ } catch {
131
+ return null;
132
+ }
133
+ }),
134
+ );
135
+
136
+ const valid = withStats.filter(
137
+ (entry): entry is { fullPath: string; mtime: number } => Boolean(entry),
138
+ );
139
+ if (!valid.length) return null;
140
+
141
+ valid.sort((a, b) => b.mtime - a.mtime);
142
+ return valid[0]?.fullPath ?? null;
143
+ } catch {
144
+ return null;
145
+ }
146
+ }
147
+
148
+ /**
149
+ * Collects files iteratively from a directory matching a filter.
150
+ * Uses queue-based iteration to avoid stack overflow on deep directory structures.
151
+ * @param dir - The root directory to search
152
+ * @param filter - Function to filter files by name
153
+ * @returns Array of matching files with their paths and modification times
154
+ */
155
+ export async function collectFilesIterative(
156
+ dir: string,
157
+ filter: (name: string) => boolean,
158
+ ): Promise<{ fullPath: string; mtime: number }[]> {
159
+ const results: { fullPath: string; mtime: number }[] = [];
160
+ const queue: string[] = [dir];
161
+
162
+ while (queue.length > 0) {
163
+ const currentDir = queue.shift();
164
+ if (!currentDir) break;
165
+ try {
166
+ const entries = await readdir(currentDir, { withFileTypes: true });
167
+ for (const entry of entries) {
168
+ const fullPath = path.join(currentDir, entry.name);
169
+ if (entry.isDirectory()) {
170
+ queue.push(fullPath);
171
+ } else if (entry.isFile() && filter(entry.name)) {
172
+ try {
173
+ const info = await stat(fullPath);
174
+ results.push({ fullPath, mtime: info.mtimeMs });
175
+ } catch {
176
+ // ignore unreadable file
177
+ }
178
+ }
179
+ }
180
+ } catch {
181
+ // ignore unreadable directory
182
+ }
183
+ }
184
+ return results;
185
+ }
186
+
187
+ /**
188
+ * Reads session ID from a file.
189
+ * Priority: filename UUID > file content > filename UUID fallback
190
+ */
191
+ export async function readSessionIdFromFile(
192
+ filePath: string,
193
+ ): Promise<string | null> {
194
+ try {
195
+ // Priority 1: Use filename UUID (most reliable for Claude session files)
196
+ const basename = path.basename(filePath);
197
+ const filenameWithoutExt = basename.replace(/\.(json|jsonl)$/i, "");
198
+ if (isValidUuidSessionId(filenameWithoutExt)) {
199
+ return filenameWithoutExt;
200
+ }
201
+
202
+ // Priority 2: Extract from file content
203
+ const content = await readFile(filePath, "utf-8");
204
+ const fromContent = pickSessionIdFromText(content);
205
+ if (fromContent) return fromContent;
206
+
207
+ // Priority 3: Fallback to any UUID in filename
208
+ const filenameMatch = basename.match(UUID_REGEX);
209
+ return filenameMatch ? filenameMatch[0] : null;
210
+ } catch {
211
+ return null;
212
+ }
213
+ }
214
+
215
+ /**
216
+ * Reads session info (ID and cwd) from a file.
217
+ */
218
+ export async function readSessionInfoFromFile(
219
+ filePath: string,
220
+ ): Promise<{ id: string | null; cwd: string | null }> {
221
+ try {
222
+ const content = await readFile(filePath, "utf-8");
223
+ try {
224
+ const parsed = JSON.parse(content);
225
+ const id = pickSessionIdFromObject(parsed);
226
+ const cwd = pickCwdFromObject(parsed);
227
+ if (id || cwd) return { id, cwd };
228
+ } catch {
229
+ // ignore
230
+ }
231
+
232
+ const lines = content.split(/\r?\n/);
233
+ for (const line of lines) {
234
+ const trimmed = line.trim();
235
+ if (!trimmed) continue;
236
+ try {
237
+ const parsedLine = JSON.parse(trimmed);
238
+ const id = pickSessionIdFromObject(parsedLine);
239
+ const cwd = pickCwdFromObject(parsedLine);
240
+ if (id || cwd) return { id, cwd };
241
+ } catch {
242
+ // ignore
243
+ }
244
+ }
245
+
246
+ // Fallback: filename UUID
247
+ const filenameMatch = path.basename(filePath).match(UUID_REGEX);
248
+ if (filenameMatch) return { id: filenameMatch[0], cwd: null };
249
+ } catch {
250
+ // ignore unreadable
251
+ }
252
+ return { id: null, cwd: null };
253
+ }
254
+
255
+ /**
256
+ * Finds newest session ID from a directory with optional time filtering.
257
+ * Uses queue-based iteration to avoid stack overflow on deep directory structures.
258
+ * @param dir - The root directory to search
259
+ * @param recursive - Whether to search subdirectories
260
+ * @param options - Search options (since, until, preferClosestTo, windowMs)
261
+ * @returns Session info with ID and modification time, or null if not found
262
+ */
263
+ export async function findNewestSessionIdFromDir(
264
+ dir: string,
265
+ recursive: boolean,
266
+ options: Omit<SessionSearchOptions, "cwd"> = {},
267
+ ): Promise<{ id: string; mtime: number } | null> {
268
+ try {
269
+ const files: { fullPath: string; mtime: number }[] = [];
270
+ const queue: string[] = [dir];
271
+
272
+ // Queue-based directory traversal
273
+ while (queue.length > 0) {
274
+ const currentDir = queue.shift();
275
+ if (!currentDir) break;
276
+
277
+ try {
278
+ const entries = await readdir(currentDir, { withFileTypes: true });
279
+ for (const entry of entries) {
280
+ const fullPath = path.join(currentDir, entry.name);
281
+ if (entry.isDirectory()) {
282
+ if (recursive) {
283
+ queue.push(fullPath);
284
+ }
285
+ continue;
286
+ }
287
+ if (!entry.isFile()) continue;
288
+ if (!entry.name.endsWith(".json") && !entry.name.endsWith(".jsonl"))
289
+ continue;
290
+ try {
291
+ const info = await stat(fullPath);
292
+ files.push({ fullPath, mtime: info.mtimeMs });
293
+ } catch {
294
+ // ignore unreadable file
295
+ }
296
+ }
297
+ } catch {
298
+ // ignore unreadable directory
299
+ }
300
+ }
301
+
302
+ // Apply since/until filters
303
+ const filtered = files.filter((f) => {
304
+ if (options.since !== undefined && f.mtime < options.since) return false;
305
+ if (options.until !== undefined && f.mtime > options.until) return false;
306
+ return true;
307
+ });
308
+
309
+ if (!filtered.length) return null;
310
+
311
+ // Sort by mtime descending (newest first)
312
+ let pool = filtered.sort((a, b) => b.mtime - a.mtime);
313
+
314
+ // Apply preferClosestTo window if specified
315
+ const ref = options.preferClosestTo;
316
+ if (typeof ref === "number") {
317
+ const window = options.windowMs ?? 30 * 60 * 1000;
318
+ const withinWindow = pool.filter(
319
+ (f) => Math.abs(f.mtime - ref) <= window,
320
+ );
321
+ if (withinWindow.length) {
322
+ pool = withinWindow.sort((a, b) => b.mtime - a.mtime);
323
+ }
324
+ }
325
+
326
+ for (const file of pool) {
327
+ const id = await readSessionIdFromFile(file.fullPath);
328
+ if (id) return { id, mtime: file.mtime };
329
+ }
330
+ } catch {
331
+ // ignore
332
+ }
333
+ return null;
334
+ }
335
+
336
+ /**
337
+ * Finds the latest nested session file from subdirectories.
338
+ */
339
+ export async function findLatestNestedSessionFile(
340
+ baseDir: string,
341
+ subPath: string[],
342
+ predicate: (name: string) => boolean,
343
+ ): Promise<string | null> {
344
+ try {
345
+ const entries = await readdir(baseDir);
346
+ if (!entries.length) return null;
347
+
348
+ const candidates: { fullPath: string; mtime: number }[] = [];
349
+
350
+ for (const entry of entries) {
351
+ const dirPath = path.join(baseDir, entry, ...subPath);
352
+ const latest = await findLatestFile(dirPath, predicate);
353
+ if (latest) {
354
+ try {
355
+ const info = await stat(latest);
356
+ candidates.push({ fullPath: latest, mtime: info.mtimeMs });
357
+ } catch {
358
+ // ignore
359
+ }
360
+ }
361
+ }
362
+
363
+ if (!candidates.length) return null;
364
+ candidates.sort((a, b) => b.mtime - a.mtime);
365
+ return candidates[0]?.fullPath ?? null;
366
+ } catch {
367
+ return null;
368
+ }
369
+ }
370
+
371
+ /**
372
+ * Reads text content from a file.
373
+ * Wrapper for fs.readFile to centralize fs operations for testability.
374
+ */
375
+ export async function readFileContent(filePath: string): Promise<string> {
376
+ return readFile(filePath, "utf-8");
377
+ }
378
+
379
+ /**
380
+ * Checks if a file exists and returns stat info.
381
+ * Returns null if file does not exist.
382
+ */
383
+ export async function checkFileStat(
384
+ filePath: string,
385
+ ): Promise<{ mtimeMs: number } | null> {
386
+ try {
387
+ const info = await stat(filePath);
388
+ return { mtimeMs: info.mtimeMs };
389
+ } catch {
390
+ return null;
391
+ }
392
+ }
393
+
394
+ /**
395
+ * Normalizes a path for cross-platform comparison.
396
+ * Converts backslashes to forward slashes and resolves the path.
397
+ * @param p - The path to normalize
398
+ * @returns The normalized path string
399
+ */
400
+ function normalizePath(p: string): string {
401
+ // Normalize separators to forward slashes for consistent comparison
402
+ return path.normalize(p).replace(/\\/g, "/");
403
+ }
404
+
405
+ /**
406
+ * Checks if one path is a proper prefix of another (with path separator boundary).
407
+ * Ensures that the prefix ends at a directory boundary to avoid false matches
408
+ * like "/home/user/proj" matching "/home/user/project".
409
+ * @param prefix - The potential prefix path
410
+ * @param full - The full path to check against
411
+ * @returns true if prefix is a valid path prefix of full
412
+ */
413
+ function isPathPrefix(prefix: string, full: string): boolean {
414
+ if (!full.startsWith(prefix)) return false;
415
+ if (full.length === prefix.length) return true;
416
+ // Ensure the next character is a path separator
417
+ return full[prefix.length] === "/";
418
+ }
419
+
420
+ /**
421
+ * Checks if a session's cwd matches the target cwd.
422
+ * Matching rules:
423
+ * - Exact match
424
+ * - Session cwd starts with target cwd (session is in subdirectory)
425
+ * - Target cwd starts with session cwd (for worktree subdirectories)
426
+ *
427
+ * Paths are normalized before comparison to handle cross-platform differences.
428
+ * Path prefix matching ensures boundaries at directory separators.
429
+ *
430
+ * @param sessionCwd - The cwd from the session file
431
+ * @param targetCwd - The target cwd to match against
432
+ * @returns true if the cwd matches
433
+ */
434
+ export function matchesCwd(
435
+ sessionCwd: string | null,
436
+ targetCwd: string,
437
+ ): boolean {
438
+ if (!sessionCwd) return false;
439
+ const normalizedSession = normalizePath(sessionCwd);
440
+ const normalizedTarget = normalizePath(targetCwd);
441
+ return (
442
+ normalizedSession === normalizedTarget ||
443
+ isPathPrefix(normalizedTarget, normalizedSession) ||
444
+ isPathPrefix(normalizedSession, normalizedTarget)
445
+ );
446
+ }
@@ -0,0 +1,46 @@
1
+ /**
2
+ * Session module - unified session management for AI tools
3
+ *
4
+ * This module provides session detection and management for various AI CLI tools:
5
+ * - Claude Code
6
+ * - Codex CLI
7
+ * - Gemini CLI
8
+ * - Qwen CLI
9
+ */
10
+
11
+ // Type exports
12
+ export type {
13
+ SessionSearchOptions,
14
+ SessionInfo,
15
+ ClaudeSessionInfo,
16
+ CodexSessionInfo,
17
+ GeminiSessionInfo,
18
+ } from "./types.js";
19
+
20
+ // Common utilities
21
+ export { isValidUuidSessionId } from "./common.js";
22
+
23
+ // Claude Code parser
24
+ export {
25
+ encodeClaudeProjectPath,
26
+ findLatestClaudeSession,
27
+ findLatestClaudeSessionId,
28
+ waitForClaudeSessionId,
29
+ claudeSessionFileExists,
30
+ } from "./parsers/claude.js";
31
+
32
+ // Codex CLI parser
33
+ export {
34
+ findLatestCodexSession,
35
+ findLatestCodexSessionId,
36
+ waitForCodexSessionId,
37
+ } from "./parsers/codex.js";
38
+
39
+ // Gemini CLI parser
40
+ export {
41
+ findLatestGeminiSession,
42
+ findLatestGeminiSessionId,
43
+ } from "./parsers/gemini.js";
44
+
45
+ // Qwen CLI parser
46
+ export { findLatestQwenSessionId } from "./parsers/qwen.js";