git-reviewer 2.14.1 → 2.16.0

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.
@@ -0,0 +1,316 @@
1
+ 'use strict';
2
+
3
+ const { spawn: spawnProcess } = require('child_process');
4
+ const { EventEmitter } = require('events');
5
+
6
+ const { describeThread } = require('./threads');
7
+
8
+ /**
9
+ * A Claude Code session, resumed in the background to answer review comments.
10
+ *
11
+ * The session that wrote the code is the one worth asking about it, so this
12
+ * resumes it -- by default as a copy, `--fork-session`, so the original
13
+ * transcript is left exactly as it was and can still be opened in a terminal
14
+ * while reviewer is using the copy. One `claude -p` process is kept running
15
+ * with stream-json on both ends: each question is a user message written to
16
+ * its stdin, and the answer streams back on stdout as it is written, which is
17
+ * what lets the page show it arriving instead of a spinner and then a wall of
18
+ * text.
19
+ *
20
+ * The copy can read the repository and nothing else. `--tools` limits it to
21
+ * Read, Grep and Glob, `--strict-mcp-config` keeps every MCP server the user
22
+ * has configured out of it -- including ones that can send mail -- and
23
+ * `dontAsk` turns anything that would need permission into a refusal, since
24
+ * nobody is there to answer a prompt. Answering a question about code needs
25
+ * nothing more, and a background process holding someone's credentials
26
+ * should not be able to do more.
27
+ */
28
+
29
+ const TOOLS = 'Read,Grep,Glob';
30
+
31
+ // Generous: a question can send the session reading through several files.
32
+ const ANSWER_TIMEOUT_MS = 10 * 60 * 1000;
33
+
34
+ /**
35
+ * The message a thread is put to the session as.
36
+ *
37
+ * The instructions travel with every question rather than as a system
38
+ * prompt, because Claude Code records a session's system prompt once and
39
+ * reuses it on resume: anything appended at resume time would be dropped.
40
+ *
41
+ * @param {import('./comments').Comment} comment
42
+ * @returns {string}
43
+ */
44
+ function questionFor(comment) {
45
+ return [
46
+ 'A reviewer is going through the changes you made in this repository, in reviewer (a local code',
47
+ 'review tool), and has left a comment on your code. Here is the thread:',
48
+ '',
49
+ describeThread(comment),
50
+ '',
51
+ 'Reply to the reviewer\'s latest message. Your reply is posted under their comment exactly as you',
52
+ 'write it, so write only the reply: short, direct, Markdown is fine. Answer from what you know about',
53
+ 'why the code is the way it is, and read files if you need to check something. Do not change any',
54
+ 'files; if a change is warranted, say what you would change.'
55
+ ].join('\n');
56
+ }
57
+
58
+ class BackgroundSession extends EventEmitter {
59
+ /**
60
+ * @param {object} options
61
+ * @param {string} options.sessionId Claude Code session to resume
62
+ * @param {string} options.cwd the directory that session ran in
63
+ * @param {boolean} [options.fork] answer in a copy of the session (the default) rather than in it
64
+ * @param {string} [options.name] display name for the copy, shown in Claude Code's /resume
65
+ * @param {string} [options.claude] the Claude Code executable
66
+ * @param {typeof spawnProcess} [options.spawn]
67
+ * @param {number} [options.answerTimeoutMs]
68
+ */
69
+ constructor({ sessionId, cwd, fork = true, name = null, claude = 'claude', spawn = spawnProcess, answerTimeoutMs = ANSWER_TIMEOUT_MS }) {
70
+ super();
71
+ this.sessionId = sessionId;
72
+ this.cwd = cwd;
73
+ this.fork = fork;
74
+ this.name = name;
75
+ this.claude = claude;
76
+ this.spawnProcess = spawn;
77
+ this.answerTimeoutMs = answerTimeoutMs;
78
+
79
+ /** The session answering: the copy's id once Claude Code reports it. */
80
+ this.answeringSession = fork ? null : sessionId;
81
+ /** @type {Array<{key: string, prompt: string}>} */
82
+ this.queue = [];
83
+ /** @type {{key: string, text: string, timer: NodeJS.Timeout, restarted: boolean}|null} */
84
+ this.current = null;
85
+ this.child = null;
86
+ this.stopped = false;
87
+ this.stderr = '';
88
+ }
89
+
90
+ /** The arguments `claude` is run with, so they can be shown and tested. */
91
+ args() {
92
+ const resume = this.answeringSession ?? this.sessionId;
93
+ return [
94
+ '-p',
95
+ '--input-format', 'stream-json',
96
+ '--output-format', 'stream-json',
97
+ '--verbose',
98
+ '--include-partial-messages',
99
+ '--resume', resume,
100
+ // Fork only the first time. Once the copy exists, a restart resumes
101
+ // the copy, so the answers so far stay in one conversation.
102
+ ...(this.fork && !this.answeringSession ? ['--fork-session'] : []),
103
+ ...(this.name && this.fork && !this.answeringSession ? ['--name', this.name] : []),
104
+ '--strict-mcp-config',
105
+ '--tools', TOOLS,
106
+ '--permission-mode', 'dontAsk'
107
+ ];
108
+ }
109
+
110
+ start() {
111
+ if (this.child || this.stopped) return;
112
+ this.stderr = '';
113
+ let child;
114
+ try {
115
+ child = this.spawnProcess(this.claude, this.args(), {
116
+ cwd: this.cwd,
117
+ stdio: ['pipe', 'pipe', 'pipe'],
118
+ env: process.env,
119
+ windowsHide: true
120
+ });
121
+ } catch (error) {
122
+ // Some failures are thrown rather than emitted -- on Windows, a .cmd
123
+ // given without a shell is refused with EINVAL. Thrown from here, it
124
+ // would escape through `ask` into whatever called it; reported on the
125
+ // next tick it fails the same way an emitted one does.
126
+ process.nextTick(() => this.failToStart(error));
127
+ return;
128
+ }
129
+ this.child = child;
130
+
131
+ let buffered = '';
132
+ child.stdout.setEncoding('utf-8');
133
+ child.stdout.on('data', chunk => {
134
+ buffered += chunk;
135
+ let newline;
136
+ while ((newline = buffered.indexOf('\n')) >= 0) {
137
+ const line = buffered.slice(0, newline);
138
+ buffered = buffered.slice(newline + 1);
139
+ if (line.trim()) this.onLine(line);
140
+ }
141
+ });
142
+ child.stderr.setEncoding('utf-8');
143
+ child.stderr.on('data', chunk => {
144
+ // Kept for the error message if it exits; bounded, because a session
145
+ // can be chatty on stderr and this lives as long as the review.
146
+ this.stderr = (this.stderr + chunk).slice(-4000);
147
+ });
148
+ child.stdin.on('error', () => {}); // EPIPE when it has already gone; 'exit' reports why
149
+ child.on('error', error => this.onExit(null, null, error));
150
+ child.on('exit', (code, signal) => this.onExit(code, signal, null));
151
+ }
152
+
153
+ /** @param {string} line */
154
+ onLine(line) {
155
+ let event;
156
+ try {
157
+ event = JSON.parse(line);
158
+ } catch {
159
+ return; // not ours to understand
160
+ }
161
+
162
+ if (event.type === 'system' && event.subtype === 'init' && event.session_id) {
163
+ if (this.answeringSession !== event.session_id) {
164
+ this.answeringSession = event.session_id;
165
+ this.emit('session', { session: event.session_id, model: event.model ?? null });
166
+ }
167
+ return;
168
+ }
169
+
170
+ if (!this.current) return;
171
+
172
+ if (event.type === 'stream_event') {
173
+ const inner = event.event;
174
+ if (inner?.type === 'message_start') {
175
+ // A turn that reads a file first produces text, a tool call, then
176
+ // more text. What is shown is the message being written now.
177
+ this.current.text = '';
178
+ } else if (inner?.type === 'content_block_delta' && inner.delta?.type === 'text_delta') {
179
+ this.current.text += inner.delta.text;
180
+ this.emit('partial', { key: this.current.key, text: this.current.text });
181
+ }
182
+ return;
183
+ }
184
+
185
+ if (event.type === 'result') {
186
+ const { key, timer } = this.current;
187
+ clearTimeout(timer);
188
+ this.current = null;
189
+ const text = typeof event.result === 'string' ? event.result.trim() : '';
190
+ if (event.is_error || event.subtype !== 'success' || text === '') {
191
+ this.emit('failed', { key, message: text || `Claude Code could not answer (${event.subtype ?? 'error'}).` });
192
+ } else {
193
+ this.emit('answer', { key, text, session: event.session_id ?? this.answeringSession });
194
+ }
195
+ this.next();
196
+ }
197
+ }
198
+
199
+ /** @param {Error & {code?: string}} error */
200
+ describeSpawnError(error) {
201
+ const where = process.platform === 'win32' ? ' (the full path of claude.exe)' : '';
202
+ return ['ENOENT', 'EINVAL'].includes(error.code)
203
+ ? `Could not run "${this.claude}": is Claude Code installed and on your PATH? Set REVIEWER_CLAUDE to its path otherwise${where}.`
204
+ : `Could not run "${this.claude}": ${error.message}`;
205
+ }
206
+
207
+ /** It could not be started: everything waiting fails, once, with why. */
208
+ failToStart(error) {
209
+ const message = this.describeSpawnError(error);
210
+ for (const { key } of this.queue.splice(0)) this.emit('failed', { key, message });
211
+ this.emit('exit', { code: null, signal: null, stderr: '', message, fatal: true });
212
+ }
213
+
214
+ onExit(code, signal, error) {
215
+ if (!this.child) return;
216
+ this.child = null;
217
+ const current = this.current;
218
+ if (current) clearTimeout(current.timer);
219
+ this.current = null;
220
+
221
+ if (this.stopped) {
222
+ this.emit('exit', { code, signal, stderr: this.stderr });
223
+ return;
224
+ }
225
+
226
+ const message = error
227
+ ? this.describeSpawnError(error)
228
+ : (this.stderr.trim().split('\n').slice(-3).join('\n') || `Claude Code exited (${signal ?? `code ${code}`}).`);
229
+
230
+ // A process that dies mid-answer gets one more go with the same
231
+ // question; one that cannot even start reports why and stops trying.
232
+ if (current && !current.restarted && !error) {
233
+ this.queue.unshift({ key: current.key, prompt: current.prompt, restarted: true });
234
+ this.start();
235
+ this.next();
236
+ return;
237
+ }
238
+ if (current) this.emit('failed', { key: current.key, message });
239
+ // `fatal`: it could not be started at all, so asking again would only
240
+ // fail the same way. Whoever owns this session should give up on it.
241
+ if (error) {
242
+ for (const { key } of this.queue.splice(0)) this.emit('failed', { key, message });
243
+ }
244
+ this.emit('exit', { code, signal, stderr: this.stderr, message, fatal: Boolean(error) });
245
+ }
246
+
247
+ /**
248
+ * Put a thread to the session. Questions are answered one at a time, in
249
+ * order: a turn's answer is matched to its question by being the next to
250
+ * finish, so a second question is not written until the first is done.
251
+ *
252
+ * @param {string} key comment key the answer belongs to
253
+ * @param {string} prompt
254
+ */
255
+ ask(key, prompt) {
256
+ if (this.stopped) return;
257
+ this.queue.push({ key, prompt, restarted: false });
258
+ this.start();
259
+ this.next();
260
+ }
261
+
262
+ next() {
263
+ if (this.current || !this.child || this.queue.length === 0) return;
264
+ const { key, prompt, restarted } = this.queue.shift();
265
+ const timer = setTimeout(() => {
266
+ // Nothing came back. Stop this process; the question fails, and the
267
+ // next one starts a fresh process on the same conversation.
268
+ if (this.current?.key !== key) return;
269
+ this.current = null;
270
+ this.emit('failed', { key, message: 'No answer after ten minutes; gave up on this one.' });
271
+ this.child?.kill();
272
+ }, this.answerTimeoutMs);
273
+ timer.unref();
274
+ this.current = { key, prompt, text: '', timer, restarted };
275
+ this.emit('working', { key });
276
+ this.child.stdin.write(`${JSON.stringify({ type: 'user', message: { role: 'user', content: prompt } })}\n`);
277
+ }
278
+
279
+ /** Whether it is answering something or has questions waiting. */
280
+ get busy() {
281
+ return Boolean(this.current) || this.queue.length > 0;
282
+ }
283
+
284
+ /** Keys of the threads being answered or waiting, in order. */
285
+ get pending() {
286
+ return [...(this.current ? [this.current.key] : []), ...this.queue.map(item => item.key)];
287
+ }
288
+
289
+ /**
290
+ * Stop the process, now.
291
+ *
292
+ * Nothing it is in the middle of is wanted any more, and a turn can run
293
+ * for minutes -- left to finish, it would outlive the reviewer that started
294
+ * it. It is asked to stop straight away, and killed if it has not gone in
295
+ * three seconds.
296
+ *
297
+ * @returns {Promise<void>}
298
+ */
299
+ stop() {
300
+ this.stopped = true;
301
+ this.queue = [];
302
+ if (this.current) clearTimeout(this.current.timer);
303
+ const child = this.child;
304
+ if (!child) return Promise.resolve();
305
+ return new Promise(resolve => {
306
+ child.once('exit', () => resolve());
307
+ child.stdin.end();
308
+ child.kill('SIGTERM');
309
+ setTimeout(() => child.kill('SIGKILL'), 3000).unref();
310
+ // Never hold up a shutdown on a process that will not say it is gone.
311
+ setTimeout(resolve, 5000).unref();
312
+ });
313
+ }
314
+ }
315
+
316
+ module.exports = { BackgroundSession, questionFor, TOOLS };
@@ -0,0 +1,90 @@
1
+ 'use strict';
2
+
3
+ const fs = require('fs');
4
+ const path = require('path');
5
+
6
+ /**
7
+ * The Claude Code executable, as something `spawn` can start without a shell.
8
+ *
9
+ * Everywhere but Windows that is just the name: `spawn` searches the PATH.
10
+ * On Windows `spawn` runs only `.exe` and `.com` files without a shell, and
11
+ * refuses a `.cmd` outright (EINVAL, since Node 18.20.2 and 20.12.2). Claude
12
+ * Code installed through npm is reached through exactly that, a `claude.cmd`
13
+ * shim. The npm package carries the native `claude.exe` the shim starts,
14
+ * though, so that is found and run directly.
15
+ *
16
+ * A shell is never the answer here. The arguments include a session's title
17
+ * and an inline MCP config, and cmd.exe would read their quotes and
18
+ * metacharacters as its own.
19
+ *
20
+ * @param {string} command `claude`, or a path from REVIEWER_CLAUDE
21
+ * @param {object} [options] injectable for tests
22
+ * @param {string} [options.platform]
23
+ * @param {NodeJS.ProcessEnv} [options.env]
24
+ * @param {(file: string) => boolean} [options.exists]
25
+ * @param {(file: string) => string} [options.read]
26
+ * @returns {string}
27
+ */
28
+ function resolveClaudeCommand(command, {
29
+ platform = process.platform,
30
+ env = process.env,
31
+ exists = fs.existsSync,
32
+ read = file => fs.readFileSync(file, 'utf-8')
33
+ } = {}) {
34
+ if (platform !== 'win32') return command;
35
+ const win = path.win32;
36
+
37
+ const candidates = [];
38
+ if (/[\\/]/.test(command)) {
39
+ candidates.push(command);
40
+ } else {
41
+ const extensions = win.extname(command) ? [''] : ['.exe', '.com', '.cmd', '.bat'];
42
+ for (const dir of (env.PATH ?? env.Path ?? '').split(';').filter(Boolean)) {
43
+ for (const extension of extensions) candidates.push(win.join(dir, `${command}${extension}`));
44
+ }
45
+ }
46
+
47
+ for (const candidate of candidates) {
48
+ if (!exists(candidate)) continue;
49
+ const extension = win.extname(candidate).toLowerCase();
50
+ if (extension === '.exe' || extension === '.com') return candidate;
51
+ if (extension === '.cmd' || extension === '.bat') {
52
+ const target = shimTarget(candidate, { exists, read });
53
+ if (target) return target;
54
+ }
55
+ }
56
+
57
+ // Nothing better found: spawn fails, and the error says what to set.
58
+ return command;
59
+ }
60
+
61
+ /**
62
+ * The executable an npm `.cmd` shim starts.
63
+ *
64
+ * Where npm puts the package beside the shim, the binary is at a known
65
+ * place. Otherwise the shim names it: npm's shims start the target as
66
+ * `"%dp0%\path\to\target.exe"`, relative to the shim's own directory.
67
+ *
68
+ * @param {string} shim
69
+ * @param {{exists: (file: string) => boolean, read: (file: string) => string}} io
70
+ * @returns {string|null}
71
+ */
72
+ function shimTarget(shim, { exists, read }) {
73
+ const win = path.win32;
74
+ const dir = win.dirname(shim);
75
+ const packaged = win.join(dir, 'node_modules', '@anthropic-ai', 'claude-code', 'bin', 'claude.exe');
76
+ if (exists(packaged)) return packaged;
77
+
78
+ let text;
79
+ try {
80
+ text = read(shim);
81
+ } catch {
82
+ return null;
83
+ }
84
+ const named = text.match(/"%~?dp0%?\\([^"]+\.exe)"/i);
85
+ if (!named) return null;
86
+ const target = win.join(dir, named[1]);
87
+ return exists(target) ? target : null;
88
+ }
89
+
90
+ module.exports = { resolveClaudeCommand };
@@ -0,0 +1,229 @@
1
+ 'use strict';
2
+
3
+ const fsp = require('fs').promises;
4
+ const os = require('os');
5
+ const path = require('path');
6
+
7
+ const { canonicalRepoPath } = require('./review');
8
+
9
+ /**
10
+ * Finding the Claude Code sessions that worked in a repository.
11
+ *
12
+ * The session worth asking about a change is the one that made it: it read
13
+ * the code, chose the approach and remembers why. Claude Code keeps each
14
+ * session's transcript as a JSON-lines file under its config directory,
15
+ * filed by the working directory it ran in, and that is what is read here --
16
+ * only the first and last few kilobytes of each, for its working directory,
17
+ * its name and the first thing it was asked.
18
+ */
19
+
20
+ // Enough of the head for the first prompt and the working directory, and of
21
+ // the tail for a name given later in the session, without reading a
22
+ // transcript that can run to tens of megabytes.
23
+ const SLICE_BYTES = 64 * 1024;
24
+ const PROMPT_CHARS = 240;
25
+
26
+ /** Where Claude Code keeps its state: `CLAUDE_CONFIG_DIR`, or `~/.claude`. */
27
+ function claudeConfigDir(env = process.env) {
28
+ return env.CLAUDE_CONFIG_DIR ? path.resolve(env.CLAUDE_CONFIG_DIR) : path.join(os.homedir(), '.claude');
29
+ }
30
+
31
+ /**
32
+ * The directory name Claude Code files a working directory's sessions under:
33
+ * the path with every character that is not a letter or a digit turned into
34
+ * `-`. `/home/you/work/api` becomes `-home-you-work-api`.
35
+ *
36
+ * @param {string} dir
37
+ */
38
+ function projectDirName(dir) {
39
+ return dir.replace(/[^A-Za-z0-9]/g, '-');
40
+ }
41
+
42
+ /** A session id as Claude Code writes them: a UUID. */
43
+ const SESSION_ID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
44
+
45
+ /**
46
+ * @param {string} file
47
+ * @param {number} start
48
+ * @param {number} length
49
+ * @returns {Promise<string>}
50
+ */
51
+ async function readSlice(file, start, length) {
52
+ const handle = await fsp.open(file, 'r');
53
+ try {
54
+ const buffer = Buffer.alloc(length);
55
+ const { bytesRead } = await handle.read(buffer, 0, length, start);
56
+ return buffer.subarray(0, bytesRead).toString('utf-8');
57
+ } finally {
58
+ await handle.close();
59
+ }
60
+ }
61
+
62
+ /**
63
+ * The records in a slice of a transcript. A slice cut mid-line yields a
64
+ * fragment at either end, which does not parse and is skipped.
65
+ *
66
+ * @param {string} text
67
+ * @returns {object[]}
68
+ */
69
+ function records(text) {
70
+ const parsed = [];
71
+ for (const line of text.split('\n')) {
72
+ if (!line.startsWith('{')) continue;
73
+ try {
74
+ parsed.push(JSON.parse(line));
75
+ } catch {
76
+ /* a fragment */
77
+ }
78
+ }
79
+ return parsed;
80
+ }
81
+
82
+ /**
83
+ * What a person typed, out of a transcript's user message -- not a tool
84
+ * result, and not the wrappers Claude Code puts around slash commands and
85
+ * reminders, which all open with a tag.
86
+ *
87
+ * @param {unknown} content
88
+ * @returns {string|null}
89
+ */
90
+ function promptText(content) {
91
+ let text = null;
92
+ if (typeof content === 'string') {
93
+ text = content;
94
+ } else if (Array.isArray(content)) {
95
+ text = content.find(block => block?.type === 'text' && typeof block.text === 'string')?.text ?? null;
96
+ }
97
+ if (!text) return null;
98
+ text = text.trim();
99
+ if (text === '' || text.startsWith('<') || text.startsWith('Caveat:')) return null;
100
+ const flat = text.replace(/\s+/g, ' ');
101
+ return flat.length > PROMPT_CHARS ? `${flat.slice(0, PROMPT_CHARS - 1)}…` : flat;
102
+ }
103
+
104
+ /**
105
+ * @typedef {object} ClaudeSession
106
+ * @property {string} id
107
+ * @property {string} cwd the directory it ran in
108
+ * @property {string|null} title the name it was given, or Claude Code's summary of it
109
+ * @property {string|null} firstPrompt
110
+ * @property {string|null} branch git branch when it started
111
+ * @property {string} updatedAt when it last wrote anything
112
+ */
113
+
114
+ /**
115
+ * Read what a transcript says about itself.
116
+ *
117
+ * @param {string} file
118
+ * @returns {Promise<ClaudeSession|null>} null for a file that is not a session transcript
119
+ */
120
+ async function describeSession(file) {
121
+ const id = path.basename(file, '.jsonl');
122
+ if (!SESSION_ID_PATTERN.test(id)) return null;
123
+
124
+ const stat = await fsp.stat(file);
125
+ const head = await readSlice(file, 0, SLICE_BYTES);
126
+ const tail = stat.size > SLICE_BYTES ? await readSlice(file, stat.size - SLICE_BYTES, SLICE_BYTES) : '';
127
+
128
+ let cwd = null;
129
+ let branch = null;
130
+ let firstPrompt = null;
131
+ let title = null;
132
+ let summary = null;
133
+
134
+ for (const record of [...records(head), ...records(tail)]) {
135
+ if (typeof record.cwd === 'string') cwd ??= record.cwd;
136
+ if (typeof record.gitBranch === 'string' && record.gitBranch !== '') branch ??= record.gitBranch;
137
+ if (!firstPrompt && record.type === 'user' && !record.isMeta && !record.isSidechain) {
138
+ firstPrompt = promptText(record.message?.content);
139
+ }
140
+ // Later records win: a session renamed halfway through goes by its new name.
141
+ if (record.type === 'custom-title' && typeof record.customTitle === 'string') title = record.customTitle;
142
+ if (record.type === 'summary' && typeof record.summary === 'string') summary = record.summary;
143
+ }
144
+
145
+ if (!cwd) return null;
146
+ return { id, cwd, title: title ?? summary ?? null, firstPrompt, branch, updatedAt: stat.mtime.toISOString() };
147
+ }
148
+
149
+ /** @param {string} child @param {string} parent */
150
+ function isInside(child, parent) {
151
+ const relative = path.relative(parent, child);
152
+ return relative === '' || (!relative.startsWith('..') && !path.isAbsolute(relative));
153
+ }
154
+
155
+ /**
156
+ * Claude Code sessions that ran in a repository or any directory inside it,
157
+ * most recently active first.
158
+ *
159
+ * @param {string} repoPath
160
+ * @param {object} [options]
161
+ * @param {string} [options.configDir]
162
+ * @param {number} [options.limit]
163
+ * @returns {Promise<ClaudeSession[]>}
164
+ */
165
+ async function listClaudeSessions(repoPath, { configDir = claudeConfigDir(), limit = 20 } = {}) {
166
+ const projects = path.join(configDir, 'projects');
167
+ const root = canonicalRepoPath(repoPath);
168
+ // The encoding loses information -- `/a/b-c` and `/a/b/c` share a name --
169
+ // so a matching directory is only a candidate. Each transcript's own
170
+ // working directory is what decides.
171
+ const prefixes = [...new Set([projectDirName(root), projectDirName(path.resolve(repoPath))])];
172
+
173
+ let dirs;
174
+ try {
175
+ dirs = (await fsp.readdir(projects, { withFileTypes: true }))
176
+ .filter(entry => entry.isDirectory() && prefixes.some(prefix => entry.name.startsWith(prefix)))
177
+ .map(entry => path.join(projects, entry.name));
178
+ } catch (error) {
179
+ if (error.code === 'ENOENT') return [];
180
+ throw error;
181
+ }
182
+
183
+ const candidates = [];
184
+ for (const dir of dirs) {
185
+ for (const entry of await fsp.readdir(dir, { withFileTypes: true }).catch(() => [])) {
186
+ if (!entry.isFile() || !entry.name.endsWith('.jsonl')) continue;
187
+ const file = path.join(dir, entry.name);
188
+ const stat = await fsp.stat(file).catch(() => null);
189
+ if (stat) candidates.push({ file, mtimeMs: stat.mtimeMs });
190
+ }
191
+ }
192
+ candidates.sort((a, b) => b.mtimeMs - a.mtimeMs);
193
+
194
+ const sessions = [];
195
+ for (const { file } of candidates) {
196
+ if (sessions.length >= limit) break;
197
+ const session = await describeSession(file).catch(() => null);
198
+ if (!session || !(session.firstPrompt || session.title)) continue;
199
+ if (!isInside(canonicalRepoPath(session.cwd), root)) continue;
200
+ sessions.push(session);
201
+ }
202
+ return sessions;
203
+ }
204
+
205
+ /**
206
+ * One session of a repository, by id -- or null, which is the answer for an
207
+ * id that is malformed, unknown, or belongs to another repository.
208
+ *
209
+ * @param {string} repoPath
210
+ * @param {string} id
211
+ * @param {object} [options]
212
+ * @param {string} [options.configDir]
213
+ * @returns {Promise<ClaudeSession|null>}
214
+ */
215
+ async function findClaudeSession(repoPath, id, { configDir = claudeConfigDir() } = {}) {
216
+ if (typeof id !== 'string' || !SESSION_ID_PATTERN.test(id)) return null;
217
+ const sessions = await listClaudeSessions(repoPath, { configDir, limit: Infinity });
218
+ return sessions.find(session => session.id === id) ?? null;
219
+ }
220
+
221
+ module.exports = {
222
+ listClaudeSessions,
223
+ findClaudeSession,
224
+ describeSession,
225
+ claudeConfigDir,
226
+ projectDirName,
227
+ promptText,
228
+ SESSION_ID_PATTERN
229
+ };