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.
- package/README.md +122 -4
- package/bin/reviewer.js +286 -7
- package/lib/agent.js +25 -5
- package/lib/answerer.js +191 -0
- package/lib/changes.js +83 -4
- package/lib/channel.js +286 -0
- package/lib/claude-background.js +316 -0
- package/lib/claude-command.js +90 -0
- package/lib/claude-sessions.js +229 -0
- package/lib/cli.js +197 -20
- package/lib/comments.js +175 -10
- package/lib/export.js +2 -2
- package/lib/presence.js +223 -0
- package/lib/review.js +3 -1
- package/lib/sessions.js +8 -4
- package/lib/store.js +290 -7
- package/lib/threads.js +180 -0
- package/lib/watch.js +131 -0
- package/man/git-reviewer.1 +58 -0
- package/package.json +1 -1
- package/public/app.js +811 -119
- package/public/index.html +33 -0
- package/public/style.css +277 -0
- package/server.js +338 -43
|
@@ -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
|
+
};
|