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/server.js CHANGED
@@ -14,14 +14,25 @@ const {
14
14
  } = require('./lib/paths');
15
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
20
  const { buildReviewDocument, describeScope } = require('./lib/agent');
20
- const { readSavedComments, ReviewOwnershipError } = require('./lib/store');
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');
33
+ const {
34
+ buildMatcher, searchDiffs, grepRepository, definesName, summarizeReferences, SearchError, SCOPES, MAX_RESULTS
35
+ } = require('./lib/search');
25
36
 
26
37
  const DEFAULT_PORT = 4500;
27
38
  const DEFAULT_HOST = '127.0.0.1';
@@ -60,6 +71,15 @@ function loopbackHostOnly(req, res, next) {
60
71
  }
61
72
 
62
73
 
74
+ /**
75
+ * An agent's presence record, as the page is told about it.
76
+ *
77
+ * @param {import('./lib/presence').AgentPresence} agent
78
+ */
79
+ function describeAgent({ id, kind, agent, session, title, startedAt, working }) {
80
+ return { id, kind, agent, session, title, startedAt, working };
81
+ }
82
+
63
83
  /** A path inside the repository that names nothing, on disk or at HEAD. */
64
84
  class FileNotFoundError extends Error {
65
85
  /** @param {string} filePath */
@@ -95,6 +115,10 @@ class FileNotFoundError extends Error {
95
115
  * leaves before deciding it is not coming back.
96
116
  * @param {boolean} [options.loopbackOnly] refuse any request whose `Host` is
97
117
  * not a loopback name. `startServer` turns it on when it binds to loopback.
118
+ * @param {object} [options.claude] how Claude Code is found and run, for
119
+ * answering comments from a session: `command` (default `claude`, or
120
+ * `REVIEWER_CLAUDE`), `configDir` (where its sessions are kept) and
121
+ * `spawn`, all injectable for tests.
98
122
  * @returns {import('express').Express}
99
123
  */
100
124
  // How far back the range picker offers to go. Long enough to cover the work
@@ -122,8 +146,10 @@ function createApp(options = {}) {
122
146
  handoff = false,
123
147
  onIdle = null,
124
148
  idleGraceMs = 5000,
125
- loopbackOnly = false
149
+ loopbackOnly = false,
150
+ claude = {}
126
151
  } = options;
152
+ const claudeCommand = claude.command ?? process.env.REVIEWER_CLAUDE ?? 'claude';
127
153
 
128
154
  const app = express();
129
155
 
@@ -136,6 +162,38 @@ function createApp(options = {}) {
136
162
  app.locals.sessions = sessions;
137
163
  app.locals.reviewsDir = reviewsDir;
138
164
 
165
+ // Background Claude Code sessions answering comments, one per repository
166
+ // at most, and the feed their answers-in-progress go out on. An answer is
167
+ // only written to the review once it is finished; until then the words
168
+ // arriving are sent to the page directly, not written to disk word by word.
169
+ /** @type {Map<string, Answerer>} */
170
+ const answerers = new Map();
171
+ const live = new EventEmitter();
172
+ live.setMaxListeners(0);
173
+
174
+ // Connecting and disconnecting a repository's background session happen
175
+ // one at a time. Two connects racing would each stop the session they
176
+ // found -- none -- and both start one, and the first would be left
177
+ // running with nothing to stop it.
178
+ /** @type {Map<string, Promise<void>>} */
179
+ const agentLocks = new Map();
180
+ const withAgentLock = (repoPath, task) => {
181
+ const previous = agentLocks.get(repoPath) ?? Promise.resolve();
182
+ const run = previous.then(task, task);
183
+ const tail = run.then(() => {}, () => {});
184
+ agentLocks.set(repoPath, tail);
185
+ tail.then(() => {
186
+ if (agentLocks.get(repoPath) === tail) agentLocks.delete(repoPath);
187
+ });
188
+ return run;
189
+ };
190
+
191
+ app.locals.stopAgents = async () => {
192
+ const running = [...answerers.values()];
193
+ answerers.clear();
194
+ await Promise.all(running.map(answerer => answerer.stop()));
195
+ };
196
+
139
197
  // How many tabs are currently holding `/api/alive` open, and the timer that
140
198
  // starts when that reaches zero. See the route for why it is a timer.
141
199
  let watching = 0;
@@ -756,22 +814,23 @@ function createApp(options = {}) {
756
814
  * after having been above it, so `reviewer` whose browser never launched
757
815
  * keeps serving rather than exiting into an empty terminal.
758
816
  */
759
- app.get('/api/alive', (req, res) => {
817
+ /**
818
+ * Count a tab as watching for as long as `res` stays open.
819
+ *
820
+ * Shared by `/api/alive` and `/api/events`: a tab holds exactly one of
821
+ * them, the first before a repository is open and the second after.
822
+ * Browsers allow six connections to one host, and a tab holding two would
823
+ * leave three tabs nothing to save with.
824
+ *
825
+ * @param {import('express').Response} res
826
+ */
827
+ function countWatcher(res) {
760
828
  if (idleTimer) {
761
829
  clearTimeout(idleTimer);
762
830
  idleTimer = null;
763
831
  }
764
832
  watching += 1;
765
833
 
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
834
  res.on('close', () => {
776
835
  watching -= 1;
777
836
  if (watching > 0 || !onIdle) return;
@@ -785,6 +844,19 @@ function createApp(options = {}) {
785
844
  // waiting out the grace period before the process could exit.
786
845
  idleTimer.unref();
787
846
  });
847
+ }
848
+
849
+ app.get('/api/alive', (req, res) => {
850
+ countWatcher(res);
851
+
852
+ res.set({
853
+ 'Content-Type': 'text/event-stream',
854
+ 'Cache-Control': 'no-cache',
855
+ Connection: 'keep-alive'
856
+ });
857
+ // A comment frame, which EventSource ignores. Sending it now is what
858
+ // flushes the headers, so the client is connected rather than pending.
859
+ res.write(': watching\n\n');
788
860
  });
789
861
 
790
862
  /** List the directories inside one, so a repository can be found by looking. */
@@ -919,6 +991,11 @@ function createApp(options = {}) {
919
991
  sessions.get(repoId).foldedDirectories = new Map(
920
992
  files.filter(file => file.directory).map(file => [file.path, file.fileCount])
921
993
  );
994
+ // What a search of the review looks through: the files it shows, less
995
+ // folded directories and binaries, which have no lines to match.
996
+ sessions.get(repoId).files = files
997
+ .filter(file => !file.directory && file.status !== 'B')
998
+ .map(file => file.path);
922
999
  // stderr, not stdout. `reviewer | claude -p ...` puts the finished
923
1000
  // review on stdout, and anything else written there lands in the middle
924
1001
  // of it. Progress is for the person watching; stdout is for the pipe.
@@ -1009,13 +1086,30 @@ function createApp(options = {}) {
1009
1086
  }
1010
1087
  });
1011
1088
 
1012
- /** Persist the current comments. Called on every edit. */
1089
+ /**
1090
+ * Persist the current comments. Called on every edit.
1091
+ *
1092
+ * A caller that sends the `lastUpdated` it loaded saves conditionally
1093
+ * (#107): if the file has moved on since -- another tab, `reviewer
1094
+ * resolve`, an agent -- the save is refused with 409 and the current
1095
+ * comments and stamp ride along, so the caller can merge and retry
1096
+ * instead of silently overwriting what it never saw. A caller that
1097
+ * sends no `lastUpdated` at all keeps the old unconditional behaviour:
1098
+ * scripts written against the original route are not tabs racing each
1099
+ * other, and breaking them would not protect anyone.
1100
+ */
1013
1101
  app.post('/api/save-comments', async (req, res) => {
1014
1102
  try {
1015
1103
  const { repoId, comments } = req.body ?? {};
1016
1104
  const session = requireSession(res, repoId);
1017
1105
  if (!session) return;
1018
1106
 
1107
+ const conditional = Object.prototype.hasOwnProperty.call(req.body ?? {}, 'lastUpdated');
1108
+ const expected = req.body?.lastUpdated ?? null;
1109
+ if (conditional && expected !== null && typeof expected !== 'string') {
1110
+ return res.status(400).json({ error: 'lastUpdated must be a string or null' });
1111
+ }
1112
+
1019
1113
  let normalized;
1020
1114
  try {
1021
1115
  normalized = normalizeComments(comments);
@@ -1023,27 +1117,47 @@ function createApp(options = {}) {
1023
1117
  return res.status(400).json({ error: error.message });
1024
1118
  }
1025
1119
 
1026
- await fs.mkdir(reviewsDir, { recursive: true });
1027
-
1028
- // `mode` is on the envelope rather than on each comment: it describes
1029
- // the review, not any one remark in it, and it is what lets `reviewer
1030
- // export` say what was compared. Without it an exported review could
1031
- // only report where HEAD is, which says nothing about whether the
1032
- // comments are about committed work or uncommitted work.
1033
- const data = {
1034
- repoPath: session.repoPath,
1035
- lastUpdated: new Date().toISOString(),
1036
- mode: session.mode,
1037
- range: session.range,
1038
- ...(session.untracked === false ? { untracked: false } : {}),
1039
- comments: normalized
1040
- };
1120
+ // Comments posted without keys -- older pages, scripts -- get the key
1121
+ // their content derives, so re-posting the same list keeps each
1122
+ // comment's identity, and the follow-ups below can find their thread.
1123
+ const incoming = withKeys(normalized);
1124
+
1125
+ let refusal = null;
1126
+ const saved = await updateSavedComments(reviewsDir, session.repoPath, current => {
1127
+ if (conditional && (current.lastUpdated ?? null) !== expected) {
1128
+ refusal = {
1129
+ error: 'Comments have changed since they were loaded. Reload, merge and retry.',
1130
+ comments: current.comments,
1131
+ lastUpdated: current.lastUpdated ?? null
1132
+ };
1133
+ return null;
1134
+ }
1041
1135
 
1042
- const target = path.join(reviewsDir, commentsFilename(session.repoPath));
1043
- await fs.writeFile(target, `${JSON.stringify(data, null, 2)}\n`);
1136
+ // `mode` is on the envelope rather than on each comment: it describes
1137
+ // the review, not any one remark in it, and it is what lets `reviewer
1138
+ // export` say what was compared. Without it an exported review could
1139
+ // only report where HEAD is, which says nothing about whether the
1140
+ // comments are about committed work or uncommitted work.
1141
+ //
1142
+ // Follow-ups already on disk are kept for every comment the save
1143
+ // still holds: an agent may have answered in a thread since this tab
1144
+ // last heard from the server, and a whole-list save must not erase it.
1145
+ return {
1146
+ comments: keepFollowUpsFrom(incoming, current.comments),
1147
+ scope: { mode: session.mode, range: session.range, untracked: session.untracked }
1148
+ };
1149
+ });
1044
1150
 
1045
- res.json({ message: 'Comments saved successfully' });
1151
+ const outcome = refusal
1152
+ ? { status: 409, body: refusal }
1153
+ // What was written, which can be more than what was sent -- the
1154
+ // follow-ups kept from disk -- so the page can show it at once.
1155
+ : { status: 200, body: { message: 'Comments saved successfully', lastUpdated: saved.lastUpdated, comments: saved.comments } };
1156
+ res.status(outcome.status).json(outcome.body);
1046
1157
  } catch (error) {
1158
+ if (error instanceof ReviewOwnershipError) {
1159
+ return res.status(409).json({ error: error.message });
1160
+ }
1047
1161
  console.error('Save comments error:', error);
1048
1162
  res.status(500).json({ error: error.message });
1049
1163
  }
@@ -1055,8 +1169,8 @@ function createApp(options = {}) {
1055
1169
  const session = requireSession(res, req.params.repoId);
1056
1170
  if (!session) return;
1057
1171
 
1058
- const { comments } = await readSavedComments(reviewsDir, session.repoPath);
1059
- res.json({ comments });
1172
+ const { comments, lastUpdated } = await readSavedComments(reviewsDir, session.repoPath);
1173
+ res.json({ comments, lastUpdated: lastUpdated ?? null });
1060
1174
  } catch (error) {
1061
1175
  if (error instanceof ReviewOwnershipError) {
1062
1176
  return res.status(409).json({ error: error.message });
@@ -1159,6 +1273,286 @@ function createApp(options = {}) {
1159
1273
  }
1160
1274
  });
1161
1275
 
1276
+ /**
1277
+ * The live feed for one open review.
1278
+ *
1279
+ * `comments` whenever the saved comments change -- whoever changed them:
1280
+ * another tab, an agent answering in a thread, `reviewer reply` -- and once
1281
+ * on connecting; `agents` whenever an agent connects, leaves, or starts on
1282
+ * a thread; and, while a background session here is answering, `partial`
1283
+ * with the answer as far as it has got. The page applies `comments` the way
1284
+ * it applies a refused save, so nothing it is holding is lost.
1285
+ */
1286
+ app.get('/api/events/:repoId', (req, res) => {
1287
+ const session = requireSession(res, req.params.repoId);
1288
+ if (!session) return;
1289
+
1290
+ // The page's one long-lived connection once a repository is open, so it
1291
+ // is also how the server knows the tab is still there (see /api/alive).
1292
+ countWatcher(res);
1293
+
1294
+ res.set({
1295
+ 'Content-Type': 'text/event-stream',
1296
+ 'Cache-Control': 'no-cache',
1297
+ Connection: 'keep-alive'
1298
+ });
1299
+ res.write(': events\n\n');
1300
+
1301
+ const send = (event, data) => {
1302
+ if (!res.writableEnded) res.write(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`);
1303
+ };
1304
+
1305
+ const comments = watchComments(reviewsDir, session.repoPath, state => {
1306
+ send('comments', { comments: state.comments, lastUpdated: state.lastUpdated ?? null });
1307
+ }, { onError: error => console.error('Review events:', error.message) });
1308
+ const agents = watchAgents(reviewsDir, session.repoPath, list => {
1309
+ send('agents', { agents: list.map(describeAgent) });
1310
+ });
1311
+ const onLive = (repoPath, event, data) => {
1312
+ if (repoPath === session.repoPath) send(event, data);
1313
+ };
1314
+ live.on('event', onLive);
1315
+ send('answerer', answerers.get(session.repoPath)?.status() ?? null);
1316
+
1317
+ // Proxies close a connection that has been quiet for a minute or two.
1318
+ const keepAlive = setInterval(() => res.write(': ping\n\n'), 20000);
1319
+ keepAlive.unref();
1320
+
1321
+ res.on('close', () => {
1322
+ comments.close();
1323
+ agents.close();
1324
+ live.off('event', onLive);
1325
+ clearInterval(keepAlive);
1326
+ });
1327
+ });
1328
+
1329
+ /**
1330
+ * The Claude Code sessions that worked in this repository, newest first,
1331
+ * to choose the one that should answer -- normally the one that wrote the
1332
+ * code under review.
1333
+ */
1334
+ app.get('/api/agent/sessions/:repoId', async (req, res) => {
1335
+ const session = requireSession(res, req.params.repoId);
1336
+ if (!session) return;
1337
+ try {
1338
+ const sessions = await listClaudeSessions(session.repoPath, { configDir: claude.configDir });
1339
+ res.json({ sessions, answerer: answerers.get(session.repoPath)?.status() ?? null });
1340
+ } catch (error) {
1341
+ console.error('Session list error:', error);
1342
+ res.status(500).json({ error: error.message });
1343
+ }
1344
+ });
1345
+
1346
+ /**
1347
+ * Have a Claude Code session answer this review's comments.
1348
+ *
1349
+ * Resumes the session in the background -- as a copy unless `fork` is
1350
+ * false -- and puts every thread waiting for an answer to it, then every
1351
+ * comment and reply the reviewer writes from now on. Only a session that
1352
+ * worked in this repository can be named: the id goes on a command line,
1353
+ * and the list is the whitelist.
1354
+ */
1355
+ app.post('/api/agent/connect', async (req, res) => {
1356
+ try {
1357
+ const { repoId, sessionId, fork = true } = req.body ?? {};
1358
+ const session = requireSession(res, repoId);
1359
+ if (!session) return;
1360
+ if (typeof fork !== 'boolean') {
1361
+ return res.status(400).json({ error: 'fork must be true or false' });
1362
+ }
1363
+
1364
+ const found = await findClaudeSession(session.repoPath, sessionId, { configDir: claude.configDir });
1365
+ if (!found) {
1366
+ return res.status(404).json({ error: 'No Claude Code session with that id has worked in this repository.' });
1367
+ }
1368
+
1369
+ const answerer = await withAgentLock(session.repoPath, async () => {
1370
+ await answerers.get(session.repoPath)?.stop();
1371
+ answerers.delete(session.repoPath);
1372
+ return startAnswerer(session, found, fork);
1373
+ });
1374
+
1375
+ console.error(`Answering comments from Claude Code session ${found.id}${fork ? ' (in a copy)' : ''}`);
1376
+ live.emit('event', session.repoPath, 'answerer', answerer.status());
1377
+ res.json({ answerer: answerer.status() });
1378
+ } catch (error) {
1379
+ console.error('Agent connect error:', error);
1380
+ res.status(500).json({ error: error.message });
1381
+ }
1382
+ });
1383
+
1384
+ /**
1385
+ * Start a background session answering a repository's comments.
1386
+ *
1387
+ * @param {{repoPath: string}} session
1388
+ * @param {import('./lib/claude-sessions').ClaudeSession} found
1389
+ * @param {boolean} fork
1390
+ * @returns {Promise<Answerer>}
1391
+ */
1392
+ async function startAnswerer(session, found, fork) {
1393
+ const about = found.title ?? found.firstPrompt ?? found.id;
1394
+ // Claude Code finds a session by the directory it ran in. If that has
1395
+ // since been removed, the repository root is the next best place to
1396
+ // run from, rather than a spawn error that reads like a missing binary.
1397
+ const cwd = await fs.access(found.cwd).then(() => found.cwd, () => session.repoPath);
1398
+ const background = new BackgroundSession({
1399
+ sessionId: found.id,
1400
+ cwd,
1401
+ fork,
1402
+ name: fork ? `Review answers: ${about}`.slice(0, 80) : null,
1403
+ claude: claude.spawn ? claudeCommand : resolveClaudeCommand(claudeCommand),
1404
+ spawn: claude.spawn
1405
+ });
1406
+ const answerer = new Answerer({
1407
+ reviewsDir,
1408
+ repoPath: session.repoPath,
1409
+ session: background,
1410
+ claudeSession: { id: found.id, title: about }
1411
+ });
1412
+ answerer.on('partial', data => live.emit('event', session.repoPath, 'partial', data));
1413
+ answerer.on('failed', data => live.emit('event', session.repoPath, 'agent-error', data));
1414
+ answerer.on('status', status => live.emit('event', session.repoPath, 'answerer', status));
1415
+ answerer.on('stopped', () => {
1416
+ if (answerers.get(session.repoPath) === answerer) answerers.delete(session.repoPath);
1417
+ live.emit('event', session.repoPath, 'answerer', null);
1418
+ });
1419
+ answerers.set(session.repoPath, answerer);
1420
+ background.start();
1421
+ return answerer;
1422
+ }
1423
+
1424
+ /** Stop the background session answering this review, if there is one. */
1425
+ app.post('/api/agent/disconnect', async (req, res) => {
1426
+ const { repoId } = req.body ?? {};
1427
+ const session = requireSession(res, repoId);
1428
+ if (!session) return;
1429
+
1430
+ const answerer = await withAgentLock(session.repoPath, async () => {
1431
+ const running = answerers.get(session.repoPath);
1432
+ answerers.delete(session.repoPath);
1433
+ await running?.stop();
1434
+ return running;
1435
+ });
1436
+ live.emit('event', session.repoPath, 'answerer', null);
1437
+ res.json({ disconnected: Boolean(answerer) });
1438
+ });
1439
+
1440
+ // A review's diffs are built once and searched as the reviewer types; this
1441
+ // long is long enough for a burst of typing and short enough to notice an
1442
+ // edit made since.
1443
+ const SEARCH_CACHE_MS = 15000;
1444
+
1445
+ /**
1446
+ * Every changed file's diff, as the page draws it, for searching.
1447
+ *
1448
+ * @param {object} session
1449
+ * @returns {Promise<Array<{path: string, lines: object[]}>>}
1450
+ */
1451
+ async function diffsForSearch(session) {
1452
+ const cached = session.searchCache;
1453
+ if (cached && Date.now() - cached.at < SEARCH_CACHE_MS) return cached.files;
1454
+
1455
+ const git = gitFactory(session.repoPath);
1456
+ const paths = session.files ?? [];
1457
+ const files = [];
1458
+ // A few at a time: a review of two hundred files should not start two
1459
+ // hundred git processes at once.
1460
+ for (let i = 0; i < paths.length; i += 8) {
1461
+ const batch = await Promise.all(paths.slice(i, i + 8).map(async filePath => {
1462
+ try {
1463
+ const diffText = await diffForFile(git, session, filePath);
1464
+ if (isBinaryDiff(diffText)) return null;
1465
+ const content = await readFileForMode(session, filePath);
1466
+ return { path: filePath, lines: parseDiff(diffText, content) };
1467
+ } catch {
1468
+ return null; // gone since the review was opened
1469
+ }
1470
+ }));
1471
+ files.push(...batch.filter(Boolean));
1472
+ }
1473
+
1474
+ session.searchCache = { at: Date.now(), files };
1475
+ return files;
1476
+ }
1477
+
1478
+ /**
1479
+ * Search the review: every changed file at once, and with `scope=repo` the
1480
+ * rest of the repository at the tree under review.
1481
+ *
1482
+ * `q` is the text; `mode` is contains, word, prefix, suffix or regex;
1483
+ * `case=1` matches case; `scope` is changes (lines the change added or
1484
+ * deleted), files (every line of the changed files) or repo. `symbol=1`
1485
+ * is a references search: whole word, case-sensitive, and each result is
1486
+ * marked as a definition or a use. Results say which side of the change
1487
+ * they are on, and `diffIndex` is the line's position in the diff the
1488
+ * page draws for that file.
1489
+ */
1490
+ app.get('/api/search/:repoId', async (req, res) => {
1491
+ const session = requireSession(res, req.params.repoId);
1492
+ if (!session) return;
1493
+
1494
+ const pick = name => (typeof req.query[name] === 'string' ? req.query[name] : '');
1495
+ const query = pick('q');
1496
+ const symbol = pick('symbol') === '1';
1497
+ const scope = pick('scope') || 'files';
1498
+ const match = { mode: symbol ? 'word' : (pick('mode') || 'contains'), caseSensitive: symbol || pick('case') === '1' };
1499
+ if (!SCOPES.includes(scope)) {
1500
+ return res.status(400).json({ error: `Unknown scope "${scope}".` });
1501
+ }
1502
+
1503
+ let matcher;
1504
+ try {
1505
+ matcher = buildMatcher(query, match);
1506
+ } catch (error) {
1507
+ if (error instanceof SearchError) return res.status(400).json({ error: error.message });
1508
+ throw error;
1509
+ }
1510
+
1511
+ try {
1512
+ const started = Date.now();
1513
+ const files = await diffsForSearch(session);
1514
+ const found = searchDiffs(files, matcher, { changedOnly: scope === 'changes' });
1515
+ let { results, truncated } = found;
1516
+
1517
+ if (scope === 'repo' && results.length < MAX_RESULTS) {
1518
+ const elsewhere = await grepRepository({
1519
+ repoPath: session.repoPath,
1520
+ session,
1521
+ query,
1522
+ match,
1523
+ matcher,
1524
+ exclude: new Set(files.map(file => file.path)),
1525
+ limit: MAX_RESULTS - results.length
1526
+ });
1527
+ results = results.concat(elsewhere.results);
1528
+ truncated = truncated || elsewhere.truncated;
1529
+ }
1530
+
1531
+ let summary = null;
1532
+ if (symbol) {
1533
+ for (const result of results) result.role = definesName(result.text, query) ? 'definition' : 'reference';
1534
+ summary = summarizeReferences(results);
1535
+ }
1536
+
1537
+ res.json({
1538
+ query,
1539
+ scope,
1540
+ mode: match.mode,
1541
+ caseSensitive: match.caseSensitive,
1542
+ symbol,
1543
+ results,
1544
+ truncated,
1545
+ files: new Set(results.map(result => result.file)).size,
1546
+ searched: files.length,
1547
+ ms: Date.now() - started,
1548
+ summary
1549
+ });
1550
+ } catch (error) {
1551
+ console.error('Search error:', error);
1552
+ res.status(500).json({ error: error.message });
1553
+ }
1554
+ });
1555
+
1162
1556
  /** Drop a session. Saved comments on disk are left alone. */
1163
1557
  app.delete('/api/cleanup/:repoId', (req, res) => {
1164
1558
  sessions.delete(req.params.repoId);
@@ -1197,6 +1591,9 @@ function startServer(options = {}) {
1197
1591
  }
1198
1592
  resolve(server);
1199
1593
  });
1594
+ // A background session must not outlive the server that started it.
1595
+ server.stopAgents = app.locals.stopAgents;
1596
+ server.on('close', () => { app.locals.stopAgents(); });
1200
1597
  server.on('error', reject);
1201
1598
  });
1202
1599
  }