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/README.md CHANGED
@@ -3,7 +3,7 @@
3
3
  [![CI](https://github.com/dheerajjha/reviewer/actions/workflows/ci.yml/badge.svg)](https://github.com/dheerajjha/reviewer/actions/workflows/ci.yml)
4
4
  [![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE)
5
5
  [![Node](https://img.shields.io/badge/node-%3E%3D18-brightgreen.svg)](package.json)
6
- [![Tests](https://img.shields.io/badge/tests-415-brightgreen.svg)](test/)
6
+ [![Tests](https://img.shields.io/badge/tests-500-brightgreen.svg)](test/)
7
7
  [![Dependencies](https://img.shields.io/badge/dependencies-3-brightgreen.svg)](package.json)
8
8
 
9
9
  **English** · [简体中文](docs/README.zh-CN.md) · [日本語](docs/README.ja.md)
@@ -48,6 +48,10 @@ Nothing is installed into the project, there is nothing to sign in to, and
48
48
  nothing leaves your machine. If you would rather not install it,
49
49
  `npx git-reviewer` works the same way.
50
50
 
51
+ You can also ask while you read. Connect the Claude Code session that wrote the
52
+ code, and it answers each comment in its thread as you write it; see
53
+ [Ask the session that wrote the code](#ask-the-session-that-wrote-the-code).
54
+
51
55
  When the review and the hand-off happen at different times, the two-step form
52
56
  still works and is the one to reach for:
53
57
 
@@ -345,6 +349,7 @@ rather than guess when an anchor has vanished.
345
349
  "comments": [
346
350
  {
347
351
  "id": "src/auth.js:10",
352
+ "key": "Xq3vB9aK",
348
353
  "file": "src/auth.js",
349
354
  "line": 10,
350
355
  "anchor": " if (scheme !== SCHEME || !token) return null;",
@@ -384,6 +389,54 @@ comment, finding the code a comment quotes rather than trusting its line
384
389
  number. Where an agent cannot leave a command running, it asks you to start
385
390
  `git reviewer` yourself and reads the result with `git reviewer export`.
386
391
 
392
+ ## Ask the session that wrote the code
393
+
394
+ The agent that wrote a change knows why it is the way it is: it read the code
395
+ around it, weighed the alternatives and chose one. reviewer can put your
396
+ comments to that session, and its answers appear in the thread, under the
397
+ comment, as they are written. You review in the browser and ask as you go:
398
+ "why throw here instead of repricing?" on one file, "which edge cases are not
399
+ tested?" on another, and each thread gets its answer from the agent that
400
+ wrote the code, in its own words.
401
+
402
+ **From the page.** Click **Ask Claude Code**, pick the session. Claude Code
403
+ keeps every session's transcript, and reviewer lists the ones that ran in
404
+ this repository. From then on every comment you write, and every reply you
405
+ add, goes to that session; threads that were already waiting go first. The
406
+ session is resumed in the background as a copy, so the original transcript
407
+ is left exactly as it was. The copy can read the repository and nothing else:
408
+ no edits, no shell, none of your MCP servers.
409
+
410
+ **Live, in your terminal.** To have the session itself answer, the one you
411
+ can watch and step into, connect it:
412
+
413
+ ```bash
414
+ git reviewer connect # the most recent session that worked here
415
+ git reviewer connect --list # choose one
416
+ git reviewer connect --session 9fef2341
417
+ ```
418
+
419
+ This resumes the session with reviewer attached as a Claude Code
420
+ [channel](https://code.claude.com/docs/en/channels). Every comment arrives in
421
+ the session as it is written, and it answers in the thread with the `reply`
422
+ tool, with your own permissions. Channels are a research preview, so Claude
423
+ Code asks you to confirm the development channel when it starts.
424
+
425
+ **Any agent.** Two commands are the whole protocol, so anything that can run a
426
+ command can take part:
427
+
428
+ ```bash
429
+ git reviewer watch # one JSON line per comment waiting for an answer
430
+ git reviewer reply <key> "Because the spec says inclusive."
431
+ git reviewer reply <key> - < answer.md # or the answer on stdin
432
+ ```
433
+
434
+ Every comment has a stable `key`. It is in the export, and in each line `watch`
435
+ prints. Answers are stored as follow-ups with a `by` field naming who wrote
436
+ them, so an agent's reply is never mistaken for your own note. They export
437
+ labelled as such too, so a review handed over afterwards carries the whole
438
+ conversation.
439
+
387
440
  ## HTTP API
388
441
 
389
442
  The UI is a client of this; nothing is hidden from you.
@@ -398,9 +451,13 @@ The UI is a client of this; nothing is hidden from you.
398
451
  | `GET /api/alive` | Held open by the page; when the last one closes, a terminal run stops |
399
452
  | `GET /api/file/:repoId/:path` | The file's diff, as structured lines |
400
453
  | `GET /api/file-full/:repoId/:path` | Every line of the file, for context |
401
- | `POST /api/save-comments` | Persist the current comments |
402
- | `GET /api/load-comments/:repoId` | Restore saved comments |
454
+ | `POST /api/save-comments` | Persist the current comments. Send back the `lastUpdated` you loaded and the save is conditional: if the file has moved on since, it answers `409` with the current `comments` and `lastUpdated` instead of overwriting them |
455
+ | `GET /api/load-comments/:repoId` | Restore saved comments, with `lastUpdated`, the version token a conditional save sends back |
403
456
  | `POST /api/submit-review` | Render the saved comments to a review file |
457
+ | `GET /api/events/:repoId` | Server-sent events for an open review: `comments` whenever the saved comments change, whoever changed them; `agents` as agents connect, leave or start on a thread; `partial` with an answer still being written |
458
+ | `GET /api/agent/sessions/:repoId` | The Claude Code sessions that ran in this repository, newest first |
459
+ | `POST /api/agent/connect` | `{repoId, sessionId, fork}`: have that session answer the review's comments in the background. `fork` defaults to `true`, a copy of the session |
460
+ | `POST /api/agent/disconnect` | `{repoId}`: stop the background session |
404
461
  | `DELETE /api/cleanup/:repoId` | End the session; files on disk are untouched |
405
462
 
406
463
  ### POST request bodies
@@ -435,7 +492,11 @@ they have no file diff.
435
492
  }
436
493
  ```
437
494
 
438
- Each comment requires `file` (non-empty string), `line` (positive integer), and `text` (non-empty string). `lineContent`, `selectedText`, and `followUps` are optional.
495
+ Each comment requires `file` (non-empty string), `line` (positive integer), and `text` (non-empty string). `lineContent`, `selectedText`, `followUps` and `key` are optional. A comment sent without a `key` gets one derived from its content, so the same list posted twice keeps its identities. Follow-ups take `key`, `timestamp`, and `by` for a reply from someone other than the reviewer. Follow-ups already saved for a comment are kept even when a save leaves them out, so a stale save cannot erase an agent's answer.
496
+
497
+ Add `"lastUpdated"` with the value `load-comments` returned (`null` for a review
498
+ not saved yet) to make the save conditional, as the page does. Without it the
499
+ save overwrites whatever is on disk, as it always has.
439
500
 
440
501
  `POST /api/submit-review` renders the previously saved comments:
441
502
 
@@ -467,7 +528,7 @@ rather than a public issue.
467
528
 
468
529
  ```bash
469
530
  npm install # 3 dependencies, no build step, ~6MB
470
- npm test # 415 tests
531
+ npm test # 500 tests
471
532
  npm run test:watch
472
533
  npm run test:coverage
473
534
  ```
package/bin/reviewer.js CHANGED
@@ -3,6 +3,7 @@
3
3
 
4
4
  const fs = require('fs').promises;
5
5
  const path = require('path');
6
+ const { spawn } = require('child_process');
6
7
 
7
8
  const { parseArgs, buildUrl, UsageError, usage, invokedAs } = require('../lib/cli');
8
9
  const { homeRelative } = require('../lib/paths');
@@ -18,6 +19,14 @@ const {
18
19
  } = require('../lib/export');
19
20
  const { startServer, DEFAULT_PORT, DEFAULT_HOST } = require('../server');
20
21
  const { version } = require('../package.json');
22
+ const { updateSavedComments } = require('../lib/store');
23
+ const { watchComments } = require('../lib/watch');
24
+ const { announce } = require('../lib/presence');
25
+ const { mintKey } = require('../lib/comments');
26
+ const { appendReply, awaitingAnswer, newReviewerMessages, describeThread, lastAuthor } = require('../lib/threads');
27
+ const { listClaudeSessions } = require('../lib/claude-sessions');
28
+ const { runChannel } = require('../lib/channel');
29
+ const { resolveClaudeCommand } = require('../lib/claude-command');
21
30
 
22
31
  // What to call this command when it talks about itself: whatever the person
23
32
  // typed, so a hint can be pasted back as-is. See invokedAs in lib/cli.js.
@@ -107,6 +116,244 @@ async function exportReview(options) {
107
116
  );
108
117
  }
109
118
 
119
+ /**
120
+ * The root of the repository a command is about, or a usage error.
121
+ *
122
+ * @param {string|null} given the repository named on the command line
123
+ * @returns {Promise<string>}
124
+ */
125
+ async function requireRepository(given) {
126
+ const target = given ?? process.cwd();
127
+ const root = await repoRoot(target);
128
+ if (!root) throw new UsageError(`${target} is not inside a git repository.`);
129
+ return root;
130
+ }
131
+
132
+ /** How a session is shown in a list: its name, or what it was first asked. */
133
+ function sessionLabel(session) {
134
+ return session.title ?? session.firstPrompt ?? '(no prompt)';
135
+ }
136
+
137
+ /**
138
+ * `reviewer connect`: open the Claude Code session that worked here, with
139
+ * reviewer attached as a channel.
140
+ *
141
+ * Every comment the reviewer writes then arrives in that session as it is
142
+ * written, and the session answers in the thread with the `reply` tool. This
143
+ * is the session in a terminal, live: the person can watch it work and step
144
+ * in. During the channels research preview Claude Code asks once, at
145
+ * startup, to confirm a development channel; that is expected.
146
+ *
147
+ * @param {import('../lib/cli').CliOptions} options
148
+ */
149
+ async function connectCommand(options) {
150
+ const repoPath = await requireRepository(options.repoPath);
151
+ const sessions = await listClaudeSessions(repoPath, { limit: 50 });
152
+
153
+ if (options.list) {
154
+ if (sessions.length === 0) {
155
+ note(`No Claude Code session has worked in ${repoPath} yet.`);
156
+ return;
157
+ }
158
+ for (const session of sessions) {
159
+ process.stdout.write(`${session.id} ${session.updatedAt.slice(0, 16).replace('T', ' ')} ${sessionLabel(session)}\n`);
160
+ }
161
+ return;
162
+ }
163
+
164
+ let chosen = null;
165
+ if (options.session) {
166
+ const matches = sessions.filter(session => session.id.startsWith(options.session));
167
+ if (matches.length !== 1) {
168
+ throw new UsageError(matches.length === 0
169
+ ? `No Claude Code session ${options.session} has worked in ${repoPath}. ${NAME} connect --list shows them.`
170
+ : `${options.session} matches ${matches.length} sessions; give more of the id.`);
171
+ }
172
+ [chosen] = matches;
173
+ } else {
174
+ // The most recent session that is not one of reviewer's own copies:
175
+ // asking a copy made to answer an earlier review is asking second-hand.
176
+ chosen = sessions.find(session => !session.title?.startsWith('Review answers:')) ?? sessions[0] ?? null;
177
+ }
178
+
179
+ const title = chosen ? sessionLabel(chosen) : null;
180
+ const mcpConfig = {
181
+ mcpServers: {
182
+ reviewer: {
183
+ command: process.execPath,
184
+ args: [path.resolve(__filename), 'channel', repoPath],
185
+ ...(title ? { env: { REVIEWER_SESSION_TITLE: title } } : {})
186
+ }
187
+ }
188
+ };
189
+ const args = [
190
+ ...(chosen ? ['--resume', chosen.id] : []),
191
+ ...(chosen && options.fork ? ['--fork-session'] : []),
192
+ '--mcp-config', JSON.stringify(mcpConfig),
193
+ '--dangerously-load-development-channels', 'server:reviewer'
194
+ ];
195
+
196
+ note(chosen
197
+ ? `\n Resuming Claude Code session ${chosen.id}${options.fork ? ' as a copy' : ''}\n ${title}\n`
198
+ : `\n No Claude Code session has worked in ${repoPath} yet; starting a new one.\n`);
199
+ note(' Claude Code will ask you to confirm the reviewer channel (it is a research preview).');
200
+ note(` Then every comment you write in reviewer arrives here, and the answers appear under them.\n`);
201
+
202
+ const claude = process.env.REVIEWER_CLAUDE || 'claude';
203
+ // A session whose directory has since been removed is resumed from the
204
+ // repository root rather than failing to spawn at all.
205
+ const cwd = chosen ? await fs.access(chosen.cwd).then(() => chosen.cwd, () => repoPath) : repoPath;
206
+ const child = spawn(resolveClaudeCommand(claude), args, { cwd, stdio: 'inherit' });
207
+
208
+ // Ctrl+C belongs to Claude Code while it runs -- it interrupts a turn, it
209
+ // does not quit -- and the terminal delivers it to both processes. This one
210
+ // only waits, so it must not die of the same keypress and leave Claude Code
211
+ // running on a terminal the shell has already taken back. A request to
212
+ // stop that comes from outside is passed on instead.
213
+ const ignore = () => {};
214
+ const forward = signal => child.kill(signal);
215
+ process.on('SIGINT', ignore);
216
+ process.on('SIGQUIT', ignore);
217
+ process.on('SIGTERM', forward);
218
+ process.on('SIGHUP', forward);
219
+ try {
220
+ await new Promise((resolve, reject) => {
221
+ child.on('error', error => reject(['ENOENT', 'EINVAL'].includes(error.code)
222
+ ? new Error(`Could not run "${claude}". Is Claude Code installed and on your PATH? Set REVIEWER_CLAUDE to its path otherwise` +
223
+ `${process.platform === 'win32' ? ' (the full path of claude.exe)' : ''}.`)
224
+ : error));
225
+ child.on('exit', (code, signal) => {
226
+ process.exitCode = code ?? (signal ? 1 : 0);
227
+ resolve();
228
+ });
229
+ });
230
+ } finally {
231
+ process.off('SIGINT', ignore);
232
+ process.off('SIGQUIT', ignore);
233
+ process.off('SIGTERM', forward);
234
+ process.off('SIGHUP', forward);
235
+ }
236
+ }
237
+
238
+ /**
239
+ * `reviewer channel`: the MCP server `connect` asks Claude Code to run.
240
+ *
241
+ * @param {import('../lib/cli').CliOptions} options
242
+ */
243
+ async function channelCommand(options) {
244
+ const repoPath = await requireRepository(options.repoPath);
245
+ await runChannel({
246
+ repoPath,
247
+ reviewsDir: reviewsDir(),
248
+ version,
249
+ title: process.env.REVIEWER_SESSION_TITLE || null
250
+ });
251
+ }
252
+
253
+ /**
254
+ * `reviewer reply <key> <text>`: answer in a thread from the command line.
255
+ *
256
+ * For an agent following a review with `reviewer watch`, and anything else
257
+ * that can run a command. Inside Claude Code the reply is signed `claude`
258
+ * and carries the session's id; elsewhere it is from `agent` unless `--as`
259
+ * says who.
260
+ *
261
+ * @param {import('../lib/cli').CliOptions} options
262
+ */
263
+ async function replyCommand(options) {
264
+ const repoPath = await requireRepository(options.repoPath);
265
+ if (options.textFromStdin) {
266
+ const chunks = [];
267
+ for await (const chunk of process.stdin) chunks.push(chunk);
268
+ options.text = Buffer.concat(chunks).toString('utf-8').trim();
269
+ if (options.text === '') throw new UsageError('reply - read nothing from stdin');
270
+ }
271
+ const session = process.env.CLAUDE_CODE_SESSION_ID || null;
272
+ const by = options.as ?? (session ? 'claude' : 'agent');
273
+
274
+ let thread = null;
275
+ await updateSavedComments(reviewsDir(), repoPath, current => {
276
+ thread = current.comments.find(comment => comment.key === options.key) ?? null;
277
+ if (!thread) return null;
278
+ return {
279
+ comments: appendReply(current.comments, options.key, {
280
+ key: mintKey(),
281
+ text: options.text,
282
+ timestamp: new Date().toISOString(),
283
+ by,
284
+ ...(session ? { session } : {})
285
+ })
286
+ };
287
+ });
288
+
289
+ // Loudly: a reply that went nowhere and exited 0 would look, to whatever
290
+ // ran it, exactly like one the reviewer can read.
291
+ if (!thread) {
292
+ console.error(`error: no comment with key ${options.key} in the review of ${repoPath}.`);
293
+ console.error(` ${NAME} export ${homeRelative(repoPath)} lists the comments and their keys.`);
294
+ process.exitCode = 1;
295
+ return;
296
+ }
297
+ note(`Replied on ${thread.file}:${thread.line} as ${by}.`);
298
+ }
299
+
300
+ /**
301
+ * `reviewer watch`: one JSON line per thing the reviewer says, as they say it.
302
+ *
303
+ * Threads already waiting for an answer come first, then each new comment
304
+ * and each reply the reviewer adds in a thread. Made for an agent that can
305
+ * follow a command's output -- Claude Code's Monitor tool turns each line
306
+ * into a message -- and answer with `reviewer reply`.
307
+ *
308
+ * @param {import('../lib/cli').CliOptions} options
309
+ */
310
+ async function watchCommand(options) {
311
+ const repoPath = await requireRepository(options.repoPath);
312
+ const dir = reviewsDir();
313
+ const session = process.env.CLAUDE_CODE_SESSION_ID || null;
314
+ const presence = announce(dir, repoPath, { kind: 'watch', agent: session ? 'claude' : 'agent', session, title: null });
315
+ const working = new Set();
316
+
317
+ const emit = comment => {
318
+ const followUps = (comment.followUps ?? []).filter(followUp => !followUp.by);
319
+ const latest = followUps.length > 0 ? followUps[followUps.length - 1].text : comment.text;
320
+ process.stdout.write(`${JSON.stringify({
321
+ key: comment.key,
322
+ file: comment.file,
323
+ line: comment.line,
324
+ text: latest,
325
+ thread: describeThread(comment)
326
+ })}\n`);
327
+ working.add(comment.key);
328
+ };
329
+
330
+ let known = null;
331
+ const watcher = watchComments(dir, repoPath, state => {
332
+ if (known === null) {
333
+ awaitingAnswer(state.comments).forEach(emit);
334
+ } else {
335
+ for (const { comment } of newReviewerMessages(known, state.comments)) emit(comment);
336
+ }
337
+ for (const key of working) {
338
+ const thread = state.comments.find(comment => comment.key === key);
339
+ if (!thread || lastAuthor(thread) !== null) working.delete(key);
340
+ }
341
+ presence.update({ working: [...working] });
342
+ known = state.comments;
343
+ });
344
+
345
+ note(`Watching the review of ${repoPath}. Reply with: ${NAME} reply <key> "<text>"`);
346
+
347
+ await new Promise(resolve => {
348
+ const finish = () => resolve();
349
+ process.once('SIGINT', finish);
350
+ process.once('SIGTERM', finish);
351
+ process.stdout.on('error', finish); // whatever was reading has gone
352
+ });
353
+ watcher.close();
354
+ await presence.close();
355
+ }
356
+
110
357
  /**
111
358
  * Which repository the page should open.
112
359
  *
@@ -165,6 +412,10 @@ async function main() {
165
412
  if (options.command === 'export') {
166
413
  return exportReview(options);
167
414
  }
415
+ if (options.command === 'connect') return connectCommand(options);
416
+ if (options.command === 'channel') return channelCommand(options);
417
+ if (options.command === 'reply') return replyCommand(options);
418
+ if (options.command === 'watch') return watchCommand(options);
168
419
 
169
420
  const repoPath = await repositoryToOpen(options.repoPath);
170
421
 
@@ -200,7 +451,11 @@ async function main() {
200
451
 
201
452
  let handedOff = false;
202
453
  const stop = () => {
203
- server.close(() => process.exit(0));
454
+ // A background Claude Code session answering comments stops with us:
455
+ // asked to straight away, and waited for before the process exits.
456
+ Promise.resolve(server.stopAgents?.()).catch(() => {}).then(() => {
457
+ server.close(() => process.exit(0));
458
+ });
204
459
  // Don't wait forever on a browser holding the connection open.
205
460
  setTimeout(() => process.exit(0), 2000).unref();
206
461
  };
package/lib/agent.js CHANGED
@@ -31,12 +31,15 @@ const SCHEMA = 'code-review/v1';
31
31
  /**
32
32
  * @typedef {object} AgentComment
33
33
  * @property {string} id stable within a document
34
+ * @property {string} [key] stable across documents: the key the comment was
35
+ * saved with, which `reviewer reply` takes
34
36
  * @property {string} file repo-relative path
35
37
  * @property {number} line line number when the comment was written
36
38
  * @property {string|null} anchor the exact source line, for relocating the comment
37
39
  * @property {string} [selection] the snippet the reviewer highlighted
38
40
  * @property {string} body
39
- * @property {Array<{body: string, at?: string}>} [followUps]
41
+ * @property {Array<{body: string, at?: string, by?: string}>} [followUps]
42
+ * `by` names an agent that answered in the thread; absent means the reviewer
40
43
  */
41
44
 
42
45
  /**
@@ -166,6 +169,10 @@ function toAgentComment(comment, id) {
166
169
  // Derived from file and line so it stays stable across exports, with a
167
170
  // suffix where that is not enough on its own. See `assignIds`.
168
171
  id,
172
+ // The saved key survives the code moving and the comment being edited,
173
+ // which `id` does not. Added beside it rather than replacing it, so
174
+ // nothing reading `id` today has to change.
175
+ ...(comment.key ? { key: comment.key } : {}),
169
176
  file: comment.file,
170
177
  line: comment.line,
171
178
  anchor: comment.lineContent ?? null,
@@ -178,6 +185,7 @@ function toAgentComment(comment, id) {
178
185
  entry.followUps = comment.followUps.map(followUp => {
179
186
  const item = { body: followUp.text };
180
187
  if (followUp.timestamp) item.at = followUp.timestamp;
188
+ if (followUp.by) item.by = followUp.by;
181
189
  return item;
182
190
  });
183
191
  }
@@ -322,8 +330,10 @@ function formatPrompt(document) {
322
330
 
323
331
  if (comment.followUps?.length > 0) {
324
332
  lines.push('Follow-ups:', '');
333
+ // An agent's answer is labelled as one. Unlabelled, "I'd keep it
334
+ // inclusive" would read as the reviewer's own instruction.
325
335
  comment.followUps.forEach((followUp, index) => {
326
- lines.push(`${index + 1}. ${followUp.body}`);
336
+ lines.push(`${index + 1}. ${followUp.by ? `(${followUp.by}) ` : ''}${followUp.body}`);
327
337
  });
328
338
  lines.push('');
329
339
  }
@@ -0,0 +1,191 @@
1
+ 'use strict';
2
+
3
+ const { EventEmitter } = require('events');
4
+
5
+ const { updateSavedComments } = require('./store');
6
+ const { watchComments } = require('./watch');
7
+ const { announce, listAgents, watchAgents } = require('./presence');
8
+ const { mintKey } = require('./comments');
9
+ const { awaitingAnswer, newReviewerMessages, appendReply, lastAuthor, followUpIdentity } = require('./threads');
10
+ const { questionFor } = require('./claude-background');
11
+
12
+ /**
13
+ * Keep a review's threads answered by a background Claude Code session.
14
+ *
15
+ * Everything the reviewer says -- a new comment, a reply in a thread, from
16
+ * this tab or any other -- reaches the session through the comments file,
17
+ * and every answer goes back the same way, as a follow-up written `by:
18
+ * claude`. Nothing here talks to the page directly except the text of an
19
+ * answer still being written, which is not worth a file write per word.
20
+ *
21
+ * Threads already waiting when it connects are answered first, oldest first.
22
+ */
23
+
24
+ /** The reviewer's messages in a thread, by identity: the comment, and their follow-ups. */
25
+ function reviewerMessages(comment) {
26
+ return new Set([
27
+ `comment:${comment.key}`,
28
+ ...(comment.followUps ?? []).filter(followUp => !followUp.by).map(followUpIdentity)
29
+ ]);
30
+ }
31
+
32
+ class Answerer extends EventEmitter {
33
+ /**
34
+ * @param {object} options
35
+ * @param {string} options.reviewsDir
36
+ * @param {string} options.repoPath
37
+ * @param {import('./claude-background').BackgroundSession} options.session
38
+ * @param {{id: string, title: string|null}} options.claudeSession the session being asked
39
+ * @param {string} [options.agent] the name answers are written under
40
+ */
41
+ constructor({ reviewsDir, repoPath, session, claudeSession, agent = 'claude' }) {
42
+ super();
43
+ this.reviewsDir = reviewsDir;
44
+ this.repoPath = repoPath;
45
+ this.session = session;
46
+ this.claudeSession = claudeSession;
47
+ this.agent = agent;
48
+
49
+ /** @type {import('./comments').Comment[]|null} */
50
+ this.known = null;
51
+ /** @type {Array<{key: string, force: boolean}>} */
52
+ this.queue = [];
53
+ /** @type {{key: string, asked: Set<string>}|null} */
54
+ this.inFlight = null;
55
+ this.stopped = false;
56
+
57
+ this.presence = announce(reviewsDir, repoPath, {
58
+ kind: 'background',
59
+ agent,
60
+ session: claudeSession.id,
61
+ title: claudeSession.title
62
+ });
63
+
64
+ session.on('session', () => this.emit('status', this.status()));
65
+ session.on('working', ({ key }) => this.presence.update({ working: [key] }));
66
+ session.on('partial', ({ key, text }) => this.emit('partial', { key, text }));
67
+ session.on('answer', ({ key, text, session: answeredBy }) => this.onAnswer(key, text, answeredBy));
68
+ session.on('failed', ({ key, message }) => {
69
+ this.inFlight = null;
70
+ this.presence.update({ working: [] });
71
+ this.emit('failed', { key, message });
72
+ this.emit('status', this.status());
73
+ this.dispatch();
74
+ });
75
+ session.on('exit', ({ message, fatal }) => {
76
+ if (message) this.emit('failed', { key: null, message });
77
+ // Claude Code could not even be started: say so once and disconnect,
78
+ // rather than show "connected" while every question fails the same way.
79
+ if (fatal) this.stop().then(() => this.emit('stopped'));
80
+ });
81
+
82
+ this.watcher = watchComments(reviewsDir, repoPath, state => this.onComments(state.comments));
83
+ // Threads held back while a live session was answering are this one's
84
+ // again when it leaves; nothing else would prompt another look.
85
+ this.agentsWatcher = watchAgents(reviewsDir, repoPath, () => this.dispatch());
86
+ }
87
+
88
+ /** @param {import('./comments').Comment[]} comments */
89
+ onComments(comments) {
90
+ if (this.stopped) return;
91
+ if (this.known === null) {
92
+ for (const comment of awaitingAnswer(comments)) this.enqueue(comment.key);
93
+ } else {
94
+ for (const { comment } of newReviewerMessages(this.known, comments)) this.enqueue(comment.key);
95
+ }
96
+ this.known = comments;
97
+ this.dispatch();
98
+ }
99
+
100
+ enqueue(key, force = false) {
101
+ const queued = this.queue.find(item => item.key === key);
102
+ if (queued) {
103
+ queued.force ||= force;
104
+ return;
105
+ }
106
+ this.queue.push({ key, force });
107
+ }
108
+
109
+ async dispatch() {
110
+ if (this.stopped || this.inFlight || this.queue.length === 0 || this.known === null) return;
111
+
112
+ // An agent answering live, in the reviewer's own terminal, takes
113
+ // precedence. Two agents answering one thread is noise, and the live one
114
+ // is the one somebody started on purpose.
115
+ const live = (await listAgents(this.reviewsDir, this.repoPath).catch(() => []))
116
+ .some(agent => agent.kind === 'channel');
117
+ if (live || this.inFlight || this.stopped) return;
118
+
119
+ while (this.queue.length > 0) {
120
+ const { key, force } = this.queue.shift();
121
+ const comment = this.known.find(entry => entry.key === key);
122
+ // Gone, or answered by somebody else in the meantime.
123
+ if (!comment || (!force && lastAuthor(comment) !== null)) continue;
124
+
125
+ this.inFlight = { key, asked: reviewerMessages(comment) };
126
+ this.session.ask(key, questionFor(comment));
127
+ this.emit('status', this.status());
128
+ return;
129
+ }
130
+ this.emit('status', this.status());
131
+ }
132
+
133
+ async onAnswer(key, text, answeredBy) {
134
+ const asked = this.inFlight?.key === key ? this.inFlight.asked : new Set();
135
+ let askAgain = false;
136
+
137
+ try {
138
+ await updateSavedComments(this.reviewsDir, this.repoPath, current => {
139
+ const thread = current.comments.find(comment => comment.key === key);
140
+ if (!thread) return null; // deleted while it was being answered
141
+ // Something the reviewer added while the answer was being written
142
+ // was not part of the question, so the thread is asked again --
143
+ // with everything, this answer included -- once this one is posted.
144
+ askAgain = [...reviewerMessages(thread)].some(identity => !asked.has(identity));
145
+ return {
146
+ comments: appendReply(current.comments, key, {
147
+ key: mintKey(),
148
+ text,
149
+ timestamp: new Date().toISOString(),
150
+ by: this.agent,
151
+ ...(answeredBy ? { session: answeredBy } : {})
152
+ })
153
+ };
154
+ });
155
+ } catch (error) {
156
+ this.emit('failed', { key, message: `Could not save the answer: ${error.message}` });
157
+ }
158
+
159
+ this.inFlight = null;
160
+ await this.presence.update({ working: [] });
161
+ if (askAgain) this.enqueue(key, true);
162
+ // Said even when nothing else is queued: the page shows "answering…"
163
+ // until it hears this thread is no longer being worked on.
164
+ this.emit('status', this.status());
165
+ this.dispatch();
166
+ }
167
+
168
+ status() {
169
+ return {
170
+ kind: 'background',
171
+ agent: this.agent,
172
+ session: this.claudeSession.id,
173
+ title: this.claudeSession.title,
174
+ answeringIn: this.session.answeringSession,
175
+ forked: this.session.fork,
176
+ working: this.inFlight?.key ?? null,
177
+ queued: this.queue.map(item => item.key)
178
+ };
179
+ }
180
+
181
+ async stop() {
182
+ if (this.stopped) return;
183
+ this.stopped = true;
184
+ this.watcher.close();
185
+ this.agentsWatcher.close();
186
+ await this.session.stop();
187
+ await this.presence.close();
188
+ }
189
+ }
190
+
191
+ module.exports = { Answerer };