forked from doctly/switchboard
-
Notifications
You must be signed in to change notification settings - Fork 3
Expand file tree
/
Copy pathderive-project-path.js
More file actions
154 lines (146 loc) · 5.79 KB
/
Copy pathderive-project-path.js
File metadata and controls
154 lines (146 loc) · 5.79 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
const fs = require('fs');
const path = require('path');
// Only the head of the file is scanned: every session/subagent transcript
// carries `cwd` on its first JSONL line. Reading the whole file here froze
// the main process — refreshFolder() derives the project path on every
// watcher flush, so a 338 MB host-session JSONL meant a multi-second
// readFileSync per flush, back to back (witnessed 2026-06-11: main thread
// pegged ~65% CPU re-reading the same file in a loop, UI freezes).
const CWD_SCAN_BYTES = 256 * 1024;
function extractCwdFromJsonl(filePath) {
let fd;
try {
fd = fs.openSync(filePath, 'r');
const buf = Buffer.alloc(CWD_SCAN_BYTES);
const bytesRead = fs.readSync(fd, buf, 0, CWD_SCAN_BYTES, 0);
const lines = buf.toString('utf8', 0, bytesRead).split('\n');
// The last line is truncated mid-entry when the file is bigger than the
// scan window — drop it instead of feeding garbage to JSON.parse.
if (bytesRead === CWD_SCAN_BYTES) lines.pop();
for (const line of lines) {
if (!line) continue;
try {
const parsed = JSON.parse(line);
if (parsed.cwd) return parsed.cwd;
} catch {}
}
} catch {} finally {
if (fd !== undefined) { try { fs.closeSync(fd); } catch {} }
}
return null;
}
function resolveWorktreePath(cwd) {
if (!cwd) return cwd;
// Detect worktree paths: <project>/.claude-worktrees/<name>, <project>/.worktrees/<name>, or <project>/.claude/worktrees/<name>
// Separators: accept both / and \ so Windows cwds collapse too.
const worktreeMatch = cwd.match(/^(.+?)[/\\]\.(?:claude[/\\]worktrees|claude-worktrees|worktrees)[/\\][^/\\]+[/\\]?$/);
if (worktreeMatch) {
const parent = worktreeMatch[1];
if (fs.existsSync(parent)) return parent;
}
return cwd;
}
function deriveProjectPath(folderPath) {
try {
const entries = fs.readdirSync(folderPath, { withFileTypes: true });
// Check direct .jsonl files first
for (const e of entries) {
if (e.isFile() && e.name.endsWith('.jsonl')) {
const cwd = extractCwdFromJsonl(path.join(folderPath, e.name));
if (cwd) return resolveWorktreePath(cwd);
}
}
// Check session subdirectories (UUID folders with subagent .jsonl files)
for (const e of entries) {
if (!e.isDirectory()) continue;
const subDir = path.join(folderPath, e.name);
try {
const subFiles = fs.readdirSync(subDir, { withFileTypes: true });
for (const sf of subFiles) {
let jsonlPath;
if (sf.isFile() && sf.name.endsWith('.jsonl')) {
jsonlPath = path.join(subDir, sf.name);
} else if (sf.isDirectory() && sf.name === 'subagents') {
const agentFiles = fs.readdirSync(path.join(subDir, 'subagents')).filter(f => f.endsWith('.jsonl'));
if (agentFiles.length > 0) jsonlPath = path.join(subDir, 'subagents', agentFiles[0]);
}
if (jsonlPath) {
const cwd = extractCwdFromJsonl(jsonlPath);
if (cwd) return resolveWorktreePath(cwd);
}
}
} catch {}
}
} catch {}
return null;
}
// Locate a session's transcript under any project folder and return its
// recorded cwd. Used on resume: a worktree session's cached projectPath is
// collapsed to the parent repo for sidebar grouping, but `claude --resume` is
// cwd-scoped — resumed from the parent it reports "No conversation found with
// session ID". Reads go through the bounded extractCwdFromJsonl scan (see
// CWD_SCAN_BYTES above) so a giant live-session JSONL can't peg the caller.
// `preferredFolder` (optional) is the encoded folder the caller expects the
// transcript to live in — checked first so the common non-worktree resume
// answers without scanning every project folder.
/**
* True when `dir` is inside a git working tree.
*
* Walks up looking for `.git` (a directory in a normal clone, a file in a
* worktree or submodule) rather than shelling out to `git rev-parse`: this runs
* on the launch path, and a spawn per session start is both slower and one more
* thing that can fail when git is missing from PATH.
*/
function isGitRepo(dir) {
if (!dir) return false;
let current;
try {
current = path.resolve(dir);
} catch {
return false;
}
for (;;) {
if (fs.existsSync(path.join(current, '.git'))) return true;
const parent = path.dirname(current);
if (parent === current) return false;
current = parent;
}
}
/**
* True when a transcript for `sessionId` exists in any project folder.
*
* A session Switchboard shows in the sidebar does not necessarily exist on
* disk: launchNewSession injects a placeholder card before claude starts, so a
* launch that fails immediately (bad flag, missing dir) leaves a card whose
* .jsonl was never written. Resuming that id makes claude report "No
* conversation found"; callers use this to relaunch it as a new session
* instead.
*/
function sessionTranscriptExists(projectsDir, sessionId) {
if (!sessionId) return false;
try {
for (const folder of fs.readdirSync(projectsDir)) {
if (fs.existsSync(path.join(projectsDir, folder, sessionId + '.jsonl'))) return true;
}
} catch {}
return false;
}
function resolveSessionRealCwd(projectsDir, sessionId, preferredFolder) {
try {
const folders = fs.readdirSync(projectsDir);
if (preferredFolder) {
const i = folders.indexOf(preferredFolder);
if (i > 0) {
folders.splice(i, 1);
folders.unshift(preferredFolder);
}
}
for (const folder of folders) {
const jsonl = path.join(projectsDir, folder, sessionId + '.jsonl');
if (!fs.existsSync(jsonl)) continue;
return extractCwdFromJsonl(jsonl);
}
} catch {}
return null;
}
module.exports = { deriveProjectPath, resolveWorktreePath, extractCwdFromJsonl, resolveSessionRealCwd, sessionTranscriptExists, isGitRepo };