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/server.js CHANGED
@@ -12,16 +12,24 @@ const {
12
12
  resolveRepoFile,
13
13
  PathEscapeError
14
14
  } = require('./lib/paths');
15
- const { collectWorkingChanges, collectStagedChanges, collectCommitChanges, parseNameStatus } = require('./lib/changes');
15
+ const { collectWorkingTreeChanges, describeWorkingChanges, collectStagedChanges, collectCommitChanges, parseNameStatus } = require('./lib/changes');
16
16
  const { SessionStore } = require('./lib/sessions');
17
- const { normalizeComments } = require('./lib/comments');
17
+ const { normalizeComments, withKeys } = require('./lib/comments');
18
+ const { keepFollowUpsFrom } = require('./lib/threads');
18
19
  const { formatReview, reviewFilename, commentsFilename } = require('./lib/review');
19
- const { buildReviewDocument } = require('./lib/agent');
20
- const { readSavedComments, ReviewOwnershipError } = require('./lib/store');
20
+ const { buildReviewDocument, describeScope } = require('./lib/agent');
21
+ const { readSavedComments, updateSavedComments, ReviewOwnershipError } = require('./lib/store');
21
22
  const { repoRoot } = require('./lib/repo');
22
23
  const { browse } = require('./lib/browse');
23
24
  const { recordRecent, describeRecents } = require('./lib/recents');
24
25
  const { listReviews } = require('./lib/export');
26
+ const { EventEmitter } = require('events');
27
+ const { watchComments } = require('./lib/watch');
28
+ const { watchAgents } = require('./lib/presence');
29
+ const { listClaudeSessions, findClaudeSession } = require('./lib/claude-sessions');
30
+ const { BackgroundSession } = require('./lib/claude-background');
31
+ const { Answerer } = require('./lib/answerer');
32
+ const { resolveClaudeCommand } = require('./lib/claude-command');
25
33
 
26
34
  const DEFAULT_PORT = 4500;
27
35
  const DEFAULT_HOST = '127.0.0.1';
@@ -60,6 +68,15 @@ function loopbackHostOnly(req, res, next) {
60
68
  }
61
69
 
62
70
 
71
+ /**
72
+ * An agent's presence record, as the page is told about it.
73
+ *
74
+ * @param {import('./lib/presence').AgentPresence} agent
75
+ */
76
+ function describeAgent({ id, kind, agent, session, title, startedAt, working }) {
77
+ return { id, kind, agent, session, title, startedAt, working };
78
+ }
79
+
63
80
  /** A path inside the repository that names nothing, on disk or at HEAD. */
64
81
  class FileNotFoundError extends Error {
65
82
  /** @param {string} filePath */
@@ -95,6 +112,10 @@ class FileNotFoundError extends Error {
95
112
  * leaves before deciding it is not coming back.
96
113
  * @param {boolean} [options.loopbackOnly] refuse any request whose `Host` is
97
114
  * not a loopback name. `startServer` turns it on when it binds to loopback.
115
+ * @param {object} [options.claude] how Claude Code is found and run, for
116
+ * answering comments from a session: `command` (default `claude`, or
117
+ * `REVIEWER_CLAUDE`), `configDir` (where its sessions are kept) and
118
+ * `spawn`, all injectable for tests.
98
119
  * @returns {import('express').Express}
99
120
  */
100
121
  // How far back the range picker offers to go. Long enough to cover the work
@@ -122,8 +143,10 @@ function createApp(options = {}) {
122
143
  handoff = false,
123
144
  onIdle = null,
124
145
  idleGraceMs = 5000,
125
- loopbackOnly = false
146
+ loopbackOnly = false,
147
+ claude = {}
126
148
  } = options;
149
+ const claudeCommand = claude.command ?? process.env.REVIEWER_CLAUDE ?? 'claude';
127
150
 
128
151
  const app = express();
129
152
 
@@ -136,6 +159,38 @@ function createApp(options = {}) {
136
159
  app.locals.sessions = sessions;
137
160
  app.locals.reviewsDir = reviewsDir;
138
161
 
162
+ // Background Claude Code sessions answering comments, one per repository
163
+ // at most, and the feed their answers-in-progress go out on. An answer is
164
+ // only written to the review once it is finished; until then the words
165
+ // arriving are sent to the page directly, not written to disk word by word.
166
+ /** @type {Map<string, Answerer>} */
167
+ const answerers = new Map();
168
+ const live = new EventEmitter();
169
+ live.setMaxListeners(0);
170
+
171
+ // Connecting and disconnecting a repository's background session happen
172
+ // one at a time. Two connects racing would each stop the session they
173
+ // found -- none -- and both start one, and the first would be left
174
+ // running with nothing to stop it.
175
+ /** @type {Map<string, Promise<void>>} */
176
+ const agentLocks = new Map();
177
+ const withAgentLock = (repoPath, task) => {
178
+ const previous = agentLocks.get(repoPath) ?? Promise.resolve();
179
+ const run = previous.then(task, task);
180
+ const tail = run.then(() => {}, () => {});
181
+ agentLocks.set(repoPath, tail);
182
+ tail.then(() => {
183
+ if (agentLocks.get(repoPath) === tail) agentLocks.delete(repoPath);
184
+ });
185
+ return run;
186
+ };
187
+
188
+ app.locals.stopAgents = async () => {
189
+ const running = [...answerers.values()];
190
+ answerers.clear();
191
+ await Promise.all(running.map(answerer => answerer.stop()));
192
+ };
193
+
139
194
  // How many tabs are currently holding `/api/alive` open, and the timer that
140
195
  // starts when that reaches zero. See the route for why it is a timer.
141
196
  let watching = 0;
@@ -756,22 +811,23 @@ function createApp(options = {}) {
756
811
  * after having been above it, so `reviewer` whose browser never launched
757
812
  * keeps serving rather than exiting into an empty terminal.
758
813
  */
759
- app.get('/api/alive', (req, res) => {
814
+ /**
815
+ * Count a tab as watching for as long as `res` stays open.
816
+ *
817
+ * Shared by `/api/alive` and `/api/events`: a tab holds exactly one of
818
+ * them, the first before a repository is open and the second after.
819
+ * Browsers allow six connections to one host, and a tab holding two would
820
+ * leave three tabs nothing to save with.
821
+ *
822
+ * @param {import('express').Response} res
823
+ */
824
+ function countWatcher(res) {
760
825
  if (idleTimer) {
761
826
  clearTimeout(idleTimer);
762
827
  idleTimer = null;
763
828
  }
764
829
  watching += 1;
765
830
 
766
- res.set({
767
- 'Content-Type': 'text/event-stream',
768
- 'Cache-Control': 'no-cache',
769
- Connection: 'keep-alive'
770
- });
771
- // A comment frame, which EventSource ignores. Sending it now is what
772
- // flushes the headers, so the client is connected rather than pending.
773
- res.write(': watching\n\n');
774
-
775
831
  res.on('close', () => {
776
832
  watching -= 1;
777
833
  if (watching > 0 || !onIdle) return;
@@ -785,6 +841,19 @@ function createApp(options = {}) {
785
841
  // waiting out the grace period before the process could exit.
786
842
  idleTimer.unref();
787
843
  });
844
+ }
845
+
846
+ app.get('/api/alive', (req, res) => {
847
+ countWatcher(res);
848
+
849
+ res.set({
850
+ 'Content-Type': 'text/event-stream',
851
+ 'Cache-Control': 'no-cache',
852
+ Connection: 'keep-alive'
853
+ });
854
+ // A comment frame, which EventSource ignores. Sending it now is what
855
+ // flushes the headers, so the client is connected rather than pending.
856
+ res.write(': watching\n\n');
788
857
  });
789
858
 
790
859
  /** List the directories inside one, so a repository can be found by looking. */
@@ -838,7 +907,10 @@ function createApp(options = {}) {
838
907
  // Resolve to the root of the working tree before anything is keyed on
839
908
  // this path. A subdirectory passes `checkIsRepo()` quite happily and
840
909
  // then produces a file list whose diffs are all empty; see lib/repo.js.
841
- const { base, head, commits: run, staged = false } = req.body ?? {};
910
+ const { base, head, commits: run, staged = false, untracked = true } = req.body ?? {};
911
+ if (typeof untracked !== 'boolean') {
912
+ return res.status(400).json({ error: 'The untracked option must be a boolean.' });
913
+ }
842
914
  if (typeof staged !== 'boolean' || (staged && (base != null || head != null || run != null))) {
843
915
  return res.status(400).json({ error: 'Choose staged changes or a commit comparison, not both.' });
844
916
  }
@@ -887,12 +959,14 @@ function createApp(options = {}) {
887
959
  mode = 'range';
888
960
  message = describeRange(range, files.length);
889
961
  } else {
890
- files = collectWorkingChanges(await git.status());
962
+ files = await collectWorkingTreeChanges(git, untracked);
891
963
  mode = 'working';
892
- message = `Found ${files.length} changed file(s)`;
964
+ message = describeWorkingChanges(files, untracked);
893
965
 
894
966
  // A clean tree has nothing to review, so fall back to the last commit.
895
- if (files.length === 0) {
967
+ // Hiding untracked files is an explicit working-tree scope. An empty
968
+ // tracked review must not silently become a review of committed work.
969
+ if (files.length === 0 && untracked) {
896
970
  try {
897
971
  const lastCommit = await resolveRange(git, 'HEAD~1', 'HEAD');
898
972
  const commitFiles = await collectRangeChanges(git, lastCommit);
@@ -910,7 +984,10 @@ function createApp(options = {}) {
910
984
  }
911
985
  }
912
986
 
913
- const repoId = sessions.create(root, mode, range);
987
+ const repoId = sessions.create(root, mode, range, untracked);
988
+ sessions.get(repoId).foldedDirectories = new Map(
989
+ files.filter(file => file.directory).map(file => [file.path, file.fileCount])
990
+ );
914
991
  // stderr, not stdout. `reviewer | claude -p ...` puts the finished
915
992
  // review on stdout, and anything else written there lands in the middle
916
993
  // of it. Progress is for the person watching; stdout is for the pipe.
@@ -925,7 +1002,7 @@ function createApp(options = {}) {
925
1002
 
926
1003
  // `repoPath` in the response is the resolved root, not what was asked
927
1004
  // for, so the page can show which repository it actually opened.
928
- res.json({ repoId, files, repoPath: root, mode, range, message });
1005
+ res.json({ repoId, files, repoPath: root, mode, range, message, untracked });
929
1006
  } catch (error) {
930
1007
  console.error('Load repo error:', error);
931
1008
  res.status(500).json({ error: error.message });
@@ -946,6 +1023,10 @@ function createApp(options = {}) {
946
1023
  // works because of statement order is not a containment check.
947
1024
  resolveRepoFile(session.repoPath, filePath);
948
1025
 
1026
+ if (session.foldedDirectories?.has(filePath)) {
1027
+ return res.json({ filePath, directory: true, fileCount: session.foldedDirectories.get(filePath), binary: false, diffLines: [] });
1028
+ }
1029
+
949
1030
  const git = gitFactory(session.repoPath);
950
1031
  const diffText = await diffForFile(git, session, filePath);
951
1032
 
@@ -978,6 +1059,11 @@ function createApp(options = {}) {
978
1059
  const session = requireSession(res, repoId);
979
1060
  if (!session) return;
980
1061
 
1062
+ resolveRepoFile(session.repoPath, filePath);
1063
+ if (session.foldedDirectories?.has(filePath)) {
1064
+ return res.json({ filePath, directory: true, fileCount: session.foldedDirectories.get(filePath), lines: [] });
1065
+ }
1066
+
981
1067
  const content = await readFileForMode(session, filePath);
982
1068
  res.json({ filePath, lines: content.split('\n') });
983
1069
  } catch (error) {
@@ -992,13 +1078,30 @@ function createApp(options = {}) {
992
1078
  }
993
1079
  });
994
1080
 
995
- /** Persist the current comments. Called on every edit. */
1081
+ /**
1082
+ * Persist the current comments. Called on every edit.
1083
+ *
1084
+ * A caller that sends the `lastUpdated` it loaded saves conditionally
1085
+ * (#107): if the file has moved on since -- another tab, `reviewer
1086
+ * resolve`, an agent -- the save is refused with 409 and the current
1087
+ * comments and stamp ride along, so the caller can merge and retry
1088
+ * instead of silently overwriting what it never saw. A caller that
1089
+ * sends no `lastUpdated` at all keeps the old unconditional behaviour:
1090
+ * scripts written against the original route are not tabs racing each
1091
+ * other, and breaking them would not protect anyone.
1092
+ */
996
1093
  app.post('/api/save-comments', async (req, res) => {
997
1094
  try {
998
1095
  const { repoId, comments } = req.body ?? {};
999
1096
  const session = requireSession(res, repoId);
1000
1097
  if (!session) return;
1001
1098
 
1099
+ const conditional = Object.prototype.hasOwnProperty.call(req.body ?? {}, 'lastUpdated');
1100
+ const expected = req.body?.lastUpdated ?? null;
1101
+ if (conditional && expected !== null && typeof expected !== 'string') {
1102
+ return res.status(400).json({ error: 'lastUpdated must be a string or null' });
1103
+ }
1104
+
1002
1105
  let normalized;
1003
1106
  try {
1004
1107
  normalized = normalizeComments(comments);
@@ -1006,26 +1109,47 @@ function createApp(options = {}) {
1006
1109
  return res.status(400).json({ error: error.message });
1007
1110
  }
1008
1111
 
1009
- await fs.mkdir(reviewsDir, { recursive: true });
1010
-
1011
- // `mode` is on the envelope rather than on each comment: it describes
1012
- // the review, not any one remark in it, and it is what lets `reviewer
1013
- // export` say what was compared. Without it an exported review could
1014
- // only report where HEAD is, which says nothing about whether the
1015
- // comments are about committed work or uncommitted work.
1016
- const data = {
1017
- repoPath: session.repoPath,
1018
- lastUpdated: new Date().toISOString(),
1019
- mode: session.mode,
1020
- range: session.range,
1021
- comments: normalized
1022
- };
1112
+ // Comments posted without keys -- older pages, scripts -- get the key
1113
+ // their content derives, so re-posting the same list keeps each
1114
+ // comment's identity, and the follow-ups below can find their thread.
1115
+ const incoming = withKeys(normalized);
1116
+
1117
+ let refusal = null;
1118
+ const saved = await updateSavedComments(reviewsDir, session.repoPath, current => {
1119
+ if (conditional && (current.lastUpdated ?? null) !== expected) {
1120
+ refusal = {
1121
+ error: 'Comments have changed since they were loaded. Reload, merge and retry.',
1122
+ comments: current.comments,
1123
+ lastUpdated: current.lastUpdated ?? null
1124
+ };
1125
+ return null;
1126
+ }
1023
1127
 
1024
- const target = path.join(reviewsDir, commentsFilename(session.repoPath));
1025
- await fs.writeFile(target, `${JSON.stringify(data, null, 2)}\n`);
1128
+ // `mode` is on the envelope rather than on each comment: it describes
1129
+ // the review, not any one remark in it, and it is what lets `reviewer
1130
+ // export` say what was compared. Without it an exported review could
1131
+ // only report where HEAD is, which says nothing about whether the
1132
+ // comments are about committed work or uncommitted work.
1133
+ //
1134
+ // Follow-ups already on disk are kept for every comment the save
1135
+ // still holds: an agent may have answered in a thread since this tab
1136
+ // last heard from the server, and a whole-list save must not erase it.
1137
+ return {
1138
+ comments: keepFollowUpsFrom(incoming, current.comments),
1139
+ scope: { mode: session.mode, range: session.range, untracked: session.untracked }
1140
+ };
1141
+ });
1026
1142
 
1027
- res.json({ message: 'Comments saved successfully' });
1143
+ const outcome = refusal
1144
+ ? { status: 409, body: refusal }
1145
+ // What was written, which can be more than what was sent -- the
1146
+ // follow-ups kept from disk -- so the page can show it at once.
1147
+ : { status: 200, body: { message: 'Comments saved successfully', lastUpdated: saved.lastUpdated, comments: saved.comments } };
1148
+ res.status(outcome.status).json(outcome.body);
1028
1149
  } catch (error) {
1150
+ if (error instanceof ReviewOwnershipError) {
1151
+ return res.status(409).json({ error: error.message });
1152
+ }
1029
1153
  console.error('Save comments error:', error);
1030
1154
  res.status(500).json({ error: error.message });
1031
1155
  }
@@ -1037,8 +1161,8 @@ function createApp(options = {}) {
1037
1161
  const session = requireSession(res, req.params.repoId);
1038
1162
  if (!session) return;
1039
1163
 
1040
- const { comments } = await readSavedComments(reviewsDir, session.repoPath);
1041
- res.json({ comments });
1164
+ const { comments, lastUpdated } = await readSavedComments(reviewsDir, session.repoPath);
1165
+ res.json({ comments, lastUpdated: lastUpdated ?? null });
1042
1166
  } catch (error) {
1043
1167
  if (error instanceof ReviewOwnershipError) {
1044
1168
  return res.status(409).json({ error: error.message });
@@ -1066,7 +1190,6 @@ function createApp(options = {}) {
1066
1190
  }
1067
1191
 
1068
1192
  const generatedAt = new Date();
1069
- const reviewContent = formatReview({ repoPath: session.repoPath, comments, generatedAt });
1070
1193
  const filename = reviewFilename(session.repoPath, generatedAt);
1071
1194
 
1072
1195
  // The commit is worth recording for anything acting on the review later:
@@ -1082,7 +1205,12 @@ function createApp(options = {}) {
1082
1205
  head,
1083
1206
  branch,
1084
1207
  mode: session.mode,
1085
- range: session.range
1208
+ range: session.range,
1209
+ untracked: session.untracked
1210
+ });
1211
+ const reviewContent = formatReview({
1212
+ repoPath: session.repoPath, comments, generatedAt,
1213
+ scope: document.untracked === false ? describeScope(document) : null
1086
1214
  });
1087
1215
  const documentFilename = filename.replace(/\.txt$/, '.json');
1088
1216
 
@@ -1137,6 +1265,170 @@ function createApp(options = {}) {
1137
1265
  }
1138
1266
  });
1139
1267
 
1268
+ /**
1269
+ * The live feed for one open review.
1270
+ *
1271
+ * `comments` whenever the saved comments change -- whoever changed them:
1272
+ * another tab, an agent answering in a thread, `reviewer reply` -- and once
1273
+ * on connecting; `agents` whenever an agent connects, leaves, or starts on
1274
+ * a thread; and, while a background session here is answering, `partial`
1275
+ * with the answer as far as it has got. The page applies `comments` the way
1276
+ * it applies a refused save, so nothing it is holding is lost.
1277
+ */
1278
+ app.get('/api/events/:repoId', (req, res) => {
1279
+ const session = requireSession(res, req.params.repoId);
1280
+ if (!session) return;
1281
+
1282
+ // The page's one long-lived connection once a repository is open, so it
1283
+ // is also how the server knows the tab is still there (see /api/alive).
1284
+ countWatcher(res);
1285
+
1286
+ res.set({
1287
+ 'Content-Type': 'text/event-stream',
1288
+ 'Cache-Control': 'no-cache',
1289
+ Connection: 'keep-alive'
1290
+ });
1291
+ res.write(': events\n\n');
1292
+
1293
+ const send = (event, data) => {
1294
+ if (!res.writableEnded) res.write(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`);
1295
+ };
1296
+
1297
+ const comments = watchComments(reviewsDir, session.repoPath, state => {
1298
+ send('comments', { comments: state.comments, lastUpdated: state.lastUpdated ?? null });
1299
+ }, { onError: error => console.error('Review events:', error.message) });
1300
+ const agents = watchAgents(reviewsDir, session.repoPath, list => {
1301
+ send('agents', { agents: list.map(describeAgent) });
1302
+ });
1303
+ const onLive = (repoPath, event, data) => {
1304
+ if (repoPath === session.repoPath) send(event, data);
1305
+ };
1306
+ live.on('event', onLive);
1307
+ send('answerer', answerers.get(session.repoPath)?.status() ?? null);
1308
+
1309
+ // Proxies close a connection that has been quiet for a minute or two.
1310
+ const keepAlive = setInterval(() => res.write(': ping\n\n'), 20000);
1311
+ keepAlive.unref();
1312
+
1313
+ res.on('close', () => {
1314
+ comments.close();
1315
+ agents.close();
1316
+ live.off('event', onLive);
1317
+ clearInterval(keepAlive);
1318
+ });
1319
+ });
1320
+
1321
+ /**
1322
+ * The Claude Code sessions that worked in this repository, newest first,
1323
+ * to choose the one that should answer -- normally the one that wrote the
1324
+ * code under review.
1325
+ */
1326
+ app.get('/api/agent/sessions/:repoId', async (req, res) => {
1327
+ const session = requireSession(res, req.params.repoId);
1328
+ if (!session) return;
1329
+ try {
1330
+ const sessions = await listClaudeSessions(session.repoPath, { configDir: claude.configDir });
1331
+ res.json({ sessions, answerer: answerers.get(session.repoPath)?.status() ?? null });
1332
+ } catch (error) {
1333
+ console.error('Session list error:', error);
1334
+ res.status(500).json({ error: error.message });
1335
+ }
1336
+ });
1337
+
1338
+ /**
1339
+ * Have a Claude Code session answer this review's comments.
1340
+ *
1341
+ * Resumes the session in the background -- as a copy unless `fork` is
1342
+ * false -- and puts every thread waiting for an answer to it, then every
1343
+ * comment and reply the reviewer writes from now on. Only a session that
1344
+ * worked in this repository can be named: the id goes on a command line,
1345
+ * and the list is the whitelist.
1346
+ */
1347
+ app.post('/api/agent/connect', async (req, res) => {
1348
+ try {
1349
+ const { repoId, sessionId, fork = true } = req.body ?? {};
1350
+ const session = requireSession(res, repoId);
1351
+ if (!session) return;
1352
+ if (typeof fork !== 'boolean') {
1353
+ return res.status(400).json({ error: 'fork must be true or false' });
1354
+ }
1355
+
1356
+ const found = await findClaudeSession(session.repoPath, sessionId, { configDir: claude.configDir });
1357
+ if (!found) {
1358
+ return res.status(404).json({ error: 'No Claude Code session with that id has worked in this repository.' });
1359
+ }
1360
+
1361
+ const answerer = await withAgentLock(session.repoPath, async () => {
1362
+ await answerers.get(session.repoPath)?.stop();
1363
+ answerers.delete(session.repoPath);
1364
+ return startAnswerer(session, found, fork);
1365
+ });
1366
+
1367
+ console.error(`Answering comments from Claude Code session ${found.id}${fork ? ' (in a copy)' : ''}`);
1368
+ live.emit('event', session.repoPath, 'answerer', answerer.status());
1369
+ res.json({ answerer: answerer.status() });
1370
+ } catch (error) {
1371
+ console.error('Agent connect error:', error);
1372
+ res.status(500).json({ error: error.message });
1373
+ }
1374
+ });
1375
+
1376
+ /**
1377
+ * Start a background session answering a repository's comments.
1378
+ *
1379
+ * @param {{repoPath: string}} session
1380
+ * @param {import('./lib/claude-sessions').ClaudeSession} found
1381
+ * @param {boolean} fork
1382
+ * @returns {Promise<Answerer>}
1383
+ */
1384
+ async function startAnswerer(session, found, fork) {
1385
+ const about = found.title ?? found.firstPrompt ?? found.id;
1386
+ // Claude Code finds a session by the directory it ran in. If that has
1387
+ // since been removed, the repository root is the next best place to
1388
+ // run from, rather than a spawn error that reads like a missing binary.
1389
+ const cwd = await fs.access(found.cwd).then(() => found.cwd, () => session.repoPath);
1390
+ const background = new BackgroundSession({
1391
+ sessionId: found.id,
1392
+ cwd,
1393
+ fork,
1394
+ name: fork ? `Review answers: ${about}`.slice(0, 80) : null,
1395
+ claude: claude.spawn ? claudeCommand : resolveClaudeCommand(claudeCommand),
1396
+ spawn: claude.spawn
1397
+ });
1398
+ const answerer = new Answerer({
1399
+ reviewsDir,
1400
+ repoPath: session.repoPath,
1401
+ session: background,
1402
+ claudeSession: { id: found.id, title: about }
1403
+ });
1404
+ answerer.on('partial', data => live.emit('event', session.repoPath, 'partial', data));
1405
+ answerer.on('failed', data => live.emit('event', session.repoPath, 'agent-error', data));
1406
+ answerer.on('status', status => live.emit('event', session.repoPath, 'answerer', status));
1407
+ answerer.on('stopped', () => {
1408
+ if (answerers.get(session.repoPath) === answerer) answerers.delete(session.repoPath);
1409
+ live.emit('event', session.repoPath, 'answerer', null);
1410
+ });
1411
+ answerers.set(session.repoPath, answerer);
1412
+ background.start();
1413
+ return answerer;
1414
+ }
1415
+
1416
+ /** Stop the background session answering this review, if there is one. */
1417
+ app.post('/api/agent/disconnect', async (req, res) => {
1418
+ const { repoId } = req.body ?? {};
1419
+ const session = requireSession(res, repoId);
1420
+ if (!session) return;
1421
+
1422
+ const answerer = await withAgentLock(session.repoPath, async () => {
1423
+ const running = answerers.get(session.repoPath);
1424
+ answerers.delete(session.repoPath);
1425
+ await running?.stop();
1426
+ return running;
1427
+ });
1428
+ live.emit('event', session.repoPath, 'answerer', null);
1429
+ res.json({ disconnected: Boolean(answerer) });
1430
+ });
1431
+
1140
1432
  /** Drop a session. Saved comments on disk are left alone. */
1141
1433
  app.delete('/api/cleanup/:repoId', (req, res) => {
1142
1434
  sessions.delete(req.params.repoId);
@@ -1175,6 +1467,9 @@ function startServer(options = {}) {
1175
1467
  }
1176
1468
  resolve(server);
1177
1469
  });
1470
+ // A background session must not outlive the server that started it.
1471
+ server.stopAgents = app.locals.stopAgents;
1472
+ server.on('close', () => { app.locals.stopAgents(); });
1178
1473
  server.on('error', reject);
1179
1474
  });
1180
1475
  }