@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
|
@@ -211,7 +211,46 @@ describe("BranchListScreen", () => {
|
|
|
211
211
|
);
|
|
212
212
|
|
|
213
213
|
const text = container.textContent ?? "";
|
|
214
|
-
expect(text).toMatch(/\[ \]\s(
|
|
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("
|
|
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
|
|
373
|
-
|
|
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
|
-
"
|
|
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
|
|
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
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
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
|
-
"
|
|
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(
|
|
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";
|