git-reviewer 2.15.0 → 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/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 };