git-reviewer 2.15.0 → 2.17.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 +93 -5
- package/bin/reviewer.js +256 -1
- package/lib/agent.js +12 -2
- package/lib/answerer.js +191 -0
- 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 +147 -7
- package/lib/comments.js +175 -10
- package/lib/presence.js +223 -0
- package/lib/search.js +319 -0
- package/lib/store.js +284 -9
- package/lib/threads.js +180 -0
- package/lib/watch.js +131 -0
- package/man/git-reviewer.1 +47 -0
- package/package.json +1 -1
- package/public/app.js +1118 -107
- package/public/index.html +80 -0
- package/public/style.css +383 -0
- package/server.js +431 -34
package/lib/channel.js
ADDED
|
@@ -0,0 +1,286 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { updateSavedComments, readSavedComments } = require('./store');
|
|
4
|
+
const { watchComments } = require('./watch');
|
|
5
|
+
const { announce } = require('./presence');
|
|
6
|
+
const { mintKey, isKey } = require('./comments');
|
|
7
|
+
const { awaitingAnswer, newReviewerMessages, appendReply, describeThread, lastAuthor } = require('./threads');
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* `reviewer channel`: a Claude Code channel for one repository's review.
|
|
11
|
+
*
|
|
12
|
+
* Claude Code runs this as an MCP server over stdio, and with channels
|
|
13
|
+
* enabled for it (`--dangerously-load-development-channels server:reviewer`
|
|
14
|
+
* during the research preview) it may push messages into the running
|
|
15
|
+
* session. Every comment the reviewer writes, and every reply they add in a
|
|
16
|
+
* thread, is pushed as one; the session answers by calling `reply`, which
|
|
17
|
+
* writes the answer into the thread, where the page shows it within a
|
|
18
|
+
* second. The session is the one the reviewer chose -- `reviewer connect`
|
|
19
|
+
* resumes the session that wrote the code -- so the answers come from the
|
|
20
|
+
* agent that knows why it wrote what it wrote.
|
|
21
|
+
*
|
|
22
|
+
* The protocol is spoken directly: MCP is JSON-RPC 2.0, one message per line,
|
|
23
|
+
* and this server needs four methods of it. The SDK would bring seventeen
|
|
24
|
+
* dependencies and a second major of express into a package that has three
|
|
25
|
+
* (#4).
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
const PROTOCOL_VERSION = '2025-11-25';
|
|
29
|
+
|
|
30
|
+
// At most this many waiting threads are pushed one by one when the session
|
|
31
|
+
// connects; the rest are summarised, so connecting to an old review with
|
|
32
|
+
// fifty open threads does not bury the session.
|
|
33
|
+
const BACKLOG_ONE_BY_ONE = 8;
|
|
34
|
+
|
|
35
|
+
/** @param {string} repoPath */
|
|
36
|
+
function instructionsFor(repoPath) {
|
|
37
|
+
return [
|
|
38
|
+
`reviewer is a local code review tool. While this server is connected, every comment a reviewer leaves on`,
|
|
39
|
+
`the changes in ${repoPath}, and every reply they add to a thread, arrives as a`,
|
|
40
|
+
'<channel source="reviewer" key="..." file="..." line="..."> message holding the thread so far.',
|
|
41
|
+
'',
|
|
42
|
+
'Answer each one by calling the reply tool with its key. Your reply is shown under the reviewer\'s',
|
|
43
|
+
'comment exactly as written: keep it short and direct; Markdown is fine. Answer from what you know about',
|
|
44
|
+
'why the code is the way it is, and read files when you need to check. Do not change files because of a',
|
|
45
|
+
'comment unless it explicitly asks you to make the change now; otherwise say what you would change. If',
|
|
46
|
+
'several arrive together, answer each one separately. list_comments shows what is still waiting.'
|
|
47
|
+
].join('\n');
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const TOOLS = [
|
|
51
|
+
{
|
|
52
|
+
name: 'reply',
|
|
53
|
+
description: 'Post a reply in a review comment thread. The reviewer sees it under their comment straight away.',
|
|
54
|
+
inputSchema: {
|
|
55
|
+
type: 'object',
|
|
56
|
+
properties: {
|
|
57
|
+
key: { type: 'string', description: 'The key of the comment, from the channel message or list_comments' },
|
|
58
|
+
text: { type: 'string', description: 'The reply, in Markdown' }
|
|
59
|
+
},
|
|
60
|
+
required: ['key', 'text']
|
|
61
|
+
}
|
|
62
|
+
},
|
|
63
|
+
{
|
|
64
|
+
name: 'list_comments',
|
|
65
|
+
description: 'List the review\'s comment threads: by default only those whose last word is the reviewer\'s.',
|
|
66
|
+
inputSchema: {
|
|
67
|
+
type: 'object',
|
|
68
|
+
properties: {
|
|
69
|
+
all: { type: 'boolean', description: 'Include threads that already have an answer' }
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
];
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* A thread as the session is told about it.
|
|
77
|
+
*
|
|
78
|
+
* @param {import('./comments').Comment} comment
|
|
79
|
+
* @param {boolean} followUp whether this is a new reply in a thread rather than a new comment
|
|
80
|
+
*/
|
|
81
|
+
function channelMessage(comment, followUp) {
|
|
82
|
+
return {
|
|
83
|
+
content: [
|
|
84
|
+
followUp ? 'The reviewer replied in a comment thread:' : 'New review comment:',
|
|
85
|
+
'',
|
|
86
|
+
describeThread(comment),
|
|
87
|
+
'',
|
|
88
|
+
`Answer with the reply tool, key "${comment.key}".`
|
|
89
|
+
].join('\n'),
|
|
90
|
+
meta: { key: comment.key, file: comment.file, line: String(comment.line) }
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Run the channel until its input closes.
|
|
96
|
+
*
|
|
97
|
+
* @param {object} options
|
|
98
|
+
* @param {string} options.repoPath repository whose review this serves
|
|
99
|
+
* @param {string} options.reviewsDir
|
|
100
|
+
* @param {NodeJS.ReadableStream} [options.input]
|
|
101
|
+
* @param {NodeJS.WritableStream} [options.output]
|
|
102
|
+
* @param {NodeJS.ProcessEnv} [options.env]
|
|
103
|
+
* @param {string} [options.version]
|
|
104
|
+
* @param {string|null} [options.title] what the connected session is about, for the page
|
|
105
|
+
* @param {(line: string) => void} [options.log] diagnostics; stdout belongs to the protocol
|
|
106
|
+
* @returns {Promise<void>} settles when the input ends
|
|
107
|
+
*/
|
|
108
|
+
function runChannel({
|
|
109
|
+
repoPath,
|
|
110
|
+
reviewsDir,
|
|
111
|
+
input = process.stdin,
|
|
112
|
+
output = process.stdout,
|
|
113
|
+
env = process.env,
|
|
114
|
+
version = '0.0.0',
|
|
115
|
+
title = null,
|
|
116
|
+
log = line => process.stderr.write(`reviewer channel: ${line}\n`)
|
|
117
|
+
}) {
|
|
118
|
+
const session = env.CLAUDE_CODE_SESSION_ID || null;
|
|
119
|
+
const send = message => output.write(`${JSON.stringify({ jsonrpc: '2.0', ...message })}\n`);
|
|
120
|
+
const notify = (method, params) => send({ method, params });
|
|
121
|
+
|
|
122
|
+
/** @type {import('./comments').Comment[]|null} */
|
|
123
|
+
let known = null;
|
|
124
|
+
let watcher = null;
|
|
125
|
+
let presence = null;
|
|
126
|
+
const working = new Set();
|
|
127
|
+
|
|
128
|
+
const setWorking = () => presence?.update({ working: [...working] });
|
|
129
|
+
|
|
130
|
+
const push = (comment, followUp) => {
|
|
131
|
+
notify('notifications/claude/channel', channelMessage(comment, followUp));
|
|
132
|
+
working.add(comment.key);
|
|
133
|
+
};
|
|
134
|
+
|
|
135
|
+
const start = () => {
|
|
136
|
+
if (watcher) return;
|
|
137
|
+
presence = announce(reviewsDir, repoPath, { kind: 'channel', agent: 'claude', session, title });
|
|
138
|
+
watcher = watchComments(reviewsDir, repoPath, state => {
|
|
139
|
+
const comments = state.comments;
|
|
140
|
+
if (known === null) {
|
|
141
|
+
const waiting = awaitingAnswer(comments);
|
|
142
|
+
waiting.slice(0, BACKLOG_ONE_BY_ONE).forEach(comment => push(comment, (comment.followUps ?? []).length > 0));
|
|
143
|
+
if (waiting.length > BACKLOG_ONE_BY_ONE) {
|
|
144
|
+
notify('notifications/claude/channel', {
|
|
145
|
+
content: `${waiting.length - BACKLOG_ONE_BY_ONE} more comment threads in this review are waiting for an answer. ` +
|
|
146
|
+
'Call list_comments to see them, and answer each with the reply tool.',
|
|
147
|
+
meta: { waiting: String(waiting.length - BACKLOG_ONE_BY_ONE) }
|
|
148
|
+
});
|
|
149
|
+
}
|
|
150
|
+
} else {
|
|
151
|
+
for (const { comment, followUp } of newReviewerMessages(known, comments)) push(comment, followUp !== null);
|
|
152
|
+
}
|
|
153
|
+
// A thread answered from somewhere else is no longer this session's to answer.
|
|
154
|
+
for (const key of working) {
|
|
155
|
+
const thread = comments.find(comment => comment.key === key);
|
|
156
|
+
if (!thread || lastAuthor(thread) !== null) working.delete(key);
|
|
157
|
+
}
|
|
158
|
+
known = comments;
|
|
159
|
+
setWorking();
|
|
160
|
+
}, { onError: error => log(`could not read the review: ${error.message}`) });
|
|
161
|
+
};
|
|
162
|
+
|
|
163
|
+
const tools = {
|
|
164
|
+
async reply({ key, text } = {}) {
|
|
165
|
+
if (!isKey(key)) return failure('key must be the key of a comment, from the channel message or list_comments.');
|
|
166
|
+
if (typeof text !== 'string' || text.trim() === '') return failure('text must be the reply to post.');
|
|
167
|
+
|
|
168
|
+
let found = false;
|
|
169
|
+
await updateSavedComments(reviewsDir, repoPath, current => {
|
|
170
|
+
const updated = appendReply(current.comments, key, {
|
|
171
|
+
key: mintKey(),
|
|
172
|
+
text: text.trim(),
|
|
173
|
+
timestamp: new Date().toISOString(),
|
|
174
|
+
by: 'claude',
|
|
175
|
+
...(session ? { session } : {})
|
|
176
|
+
});
|
|
177
|
+
found = updated !== null;
|
|
178
|
+
return updated ? { comments: updated } : null;
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
// An unknown key fails out loud: a reply that silently went nowhere
|
|
182
|
+
// would read, to the session, exactly like one the reviewer received.
|
|
183
|
+
if (!found) return failure(`No comment with key "${key}" in this review. Call list_comments for the current ones.`);
|
|
184
|
+
working.delete(key);
|
|
185
|
+
setWorking();
|
|
186
|
+
return success('Posted. The reviewer can see it under their comment now.');
|
|
187
|
+
},
|
|
188
|
+
|
|
189
|
+
async list_comments({ all = false } = {}) {
|
|
190
|
+
const { comments } = await readSavedComments(reviewsDir, repoPath);
|
|
191
|
+
const shown = all ? comments : awaitingAnswer(comments);
|
|
192
|
+
const threads = shown.map(comment => ({
|
|
193
|
+
key: comment.key,
|
|
194
|
+
file: comment.file,
|
|
195
|
+
line: comment.line,
|
|
196
|
+
waiting: lastAuthor(comment) === null,
|
|
197
|
+
thread: describeThread(comment)
|
|
198
|
+
}));
|
|
199
|
+
return success(threads.length === 0
|
|
200
|
+
? (all ? 'This review has no comments yet.' : 'Nothing is waiting for an answer.')
|
|
201
|
+
: JSON.stringify(threads, null, 2));
|
|
202
|
+
}
|
|
203
|
+
};
|
|
204
|
+
|
|
205
|
+
const handle = async message => {
|
|
206
|
+
const { id, method, params } = message;
|
|
207
|
+
const isRequest = id !== undefined && id !== null;
|
|
208
|
+
|
|
209
|
+
switch (method) {
|
|
210
|
+
case 'initialize':
|
|
211
|
+
return send({
|
|
212
|
+
id,
|
|
213
|
+
result: {
|
|
214
|
+
protocolVersion: PROTOCOL_VERSION,
|
|
215
|
+
capabilities: { tools: {}, experimental: { 'claude/channel': {} } },
|
|
216
|
+
serverInfo: { name: 'reviewer', title: 'reviewer', version },
|
|
217
|
+
instructions: instructionsFor(repoPath)
|
|
218
|
+
}
|
|
219
|
+
});
|
|
220
|
+
case 'notifications/initialized':
|
|
221
|
+
return start();
|
|
222
|
+
case 'ping':
|
|
223
|
+
return send({ id, result: {} });
|
|
224
|
+
case 'tools/list':
|
|
225
|
+
return send({ id, result: { tools: TOOLS } });
|
|
226
|
+
case 'tools/call': {
|
|
227
|
+
const tool = tools[params?.name];
|
|
228
|
+
if (!tool) return send({ id, error: { code: -32602, message: `Unknown tool: ${params?.name}` } });
|
|
229
|
+
try {
|
|
230
|
+
return send({ id, result: await tool(params.arguments ?? {}) });
|
|
231
|
+
} catch (error) {
|
|
232
|
+
return send({ id, result: failure(error.message) });
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
default:
|
|
236
|
+
// Including `server/discover`, the 2026-07-28 revision's replacement
|
|
237
|
+
// for `initialize`. Claude Code tries it first and, answered "method
|
|
238
|
+
// not found", falls back to `initialize` -- the handshake channels
|
|
239
|
+
// are documented against.
|
|
240
|
+
if (isRequest) send({ id, error: { code: -32601, message: `Method not found: ${method}` } });
|
|
241
|
+
return undefined;
|
|
242
|
+
}
|
|
243
|
+
};
|
|
244
|
+
|
|
245
|
+
return new Promise(resolve => {
|
|
246
|
+
let buffered = '';
|
|
247
|
+
let pending = Promise.resolve();
|
|
248
|
+
input.setEncoding?.('utf-8');
|
|
249
|
+
input.on('data', chunk => {
|
|
250
|
+
buffered += chunk;
|
|
251
|
+
let newline;
|
|
252
|
+
while ((newline = buffered.indexOf('\n')) >= 0) {
|
|
253
|
+
const line = buffered.slice(0, newline).trim();
|
|
254
|
+
buffered = buffered.slice(newline + 1);
|
|
255
|
+
if (!line) continue;
|
|
256
|
+
let message;
|
|
257
|
+
try {
|
|
258
|
+
message = JSON.parse(line);
|
|
259
|
+
} catch {
|
|
260
|
+
send({ id: null, error: { code: -32700, message: 'Parse error' } });
|
|
261
|
+
continue;
|
|
262
|
+
}
|
|
263
|
+
// In order: a reply must not overtake the list it was based on.
|
|
264
|
+
pending = pending.then(() => handle(message)).catch(error => log(error.message));
|
|
265
|
+
}
|
|
266
|
+
});
|
|
267
|
+
input.on('end', async () => {
|
|
268
|
+
await pending;
|
|
269
|
+
watcher?.close();
|
|
270
|
+
await presence?.close();
|
|
271
|
+
resolve();
|
|
272
|
+
});
|
|
273
|
+
});
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
/** @param {string} text */
|
|
277
|
+
function success(text) {
|
|
278
|
+
return { content: [{ type: 'text', text }] };
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
/** @param {string} text */
|
|
282
|
+
function failure(text) {
|
|
283
|
+
return { content: [{ type: 'text', text }], isError: true };
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
module.exports = { runChannel, channelMessage, instructionsFor, TOOLS, PROTOCOL_VERSION };
|
|
@@ -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 };
|