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 +66 -5
- package/bin/reviewer.js +256 -1
- package/lib/agent.js +12 -2
- package/lib/answerer.js +191 -0
- package/lib/channel.js +286 -0
- package/lib/claude-background.js +316 -0
- package/lib/claude-command.js +90 -0
- package/lib/claude-sessions.js +229 -0
- package/lib/cli.js +147 -7
- package/lib/comments.js +175 -10
- package/lib/presence.js +223 -0
- package/lib/store.js +284 -9
- package/lib/threads.js +180 -0
- package/lib/watch.js +131 -0
- package/man/git-reviewer.1 +47 -0
- package/package.json +1 -1
- package/public/app.js +739 -107
- package/public/index.html +33 -0
- package/public/style.css +271 -0
- package/server.js +307 -34
package/server.js
CHANGED
|
@@ -14,14 +14,22 @@ 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');
|
|
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
|
-
|
|
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. */
|
|
@@ -1009,13 +1078,30 @@ function createApp(options = {}) {
|
|
|
1009
1078
|
}
|
|
1010
1079
|
});
|
|
1011
1080
|
|
|
1012
|
-
/**
|
|
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
|
+
*/
|
|
1013
1093
|
app.post('/api/save-comments', async (req, res) => {
|
|
1014
1094
|
try {
|
|
1015
1095
|
const { repoId, comments } = req.body ?? {};
|
|
1016
1096
|
const session = requireSession(res, repoId);
|
|
1017
1097
|
if (!session) return;
|
|
1018
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
|
+
|
|
1019
1105
|
let normalized;
|
|
1020
1106
|
try {
|
|
1021
1107
|
normalized = normalizeComments(comments);
|
|
@@ -1023,27 +1109,47 @@ function createApp(options = {}) {
|
|
|
1023
1109
|
return res.status(400).json({ error: error.message });
|
|
1024
1110
|
}
|
|
1025
1111
|
|
|
1026
|
-
|
|
1027
|
-
|
|
1028
|
-
//
|
|
1029
|
-
|
|
1030
|
-
|
|
1031
|
-
|
|
1032
|
-
|
|
1033
|
-
|
|
1034
|
-
|
|
1035
|
-
|
|
1036
|
-
|
|
1037
|
-
|
|
1038
|
-
|
|
1039
|
-
|
|
1040
|
-
|
|
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
|
+
}
|
|
1041
1127
|
|
|
1042
|
-
|
|
1043
|
-
|
|
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
|
+
});
|
|
1044
1142
|
|
|
1045
|
-
|
|
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);
|
|
1046
1149
|
} catch (error) {
|
|
1150
|
+
if (error instanceof ReviewOwnershipError) {
|
|
1151
|
+
return res.status(409).json({ error: error.message });
|
|
1152
|
+
}
|
|
1047
1153
|
console.error('Save comments error:', error);
|
|
1048
1154
|
res.status(500).json({ error: error.message });
|
|
1049
1155
|
}
|
|
@@ -1055,8 +1161,8 @@ function createApp(options = {}) {
|
|
|
1055
1161
|
const session = requireSession(res, req.params.repoId);
|
|
1056
1162
|
if (!session) return;
|
|
1057
1163
|
|
|
1058
|
-
const { comments } = await readSavedComments(reviewsDir, session.repoPath);
|
|
1059
|
-
res.json({ comments });
|
|
1164
|
+
const { comments, lastUpdated } = await readSavedComments(reviewsDir, session.repoPath);
|
|
1165
|
+
res.json({ comments, lastUpdated: lastUpdated ?? null });
|
|
1060
1166
|
} catch (error) {
|
|
1061
1167
|
if (error instanceof ReviewOwnershipError) {
|
|
1062
1168
|
return res.status(409).json({ error: error.message });
|
|
@@ -1159,6 +1265,170 @@ function createApp(options = {}) {
|
|
|
1159
1265
|
}
|
|
1160
1266
|
});
|
|
1161
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
|
+
|
|
1162
1432
|
/** Drop a session. Saved comments on disk are left alone. */
|
|
1163
1433
|
app.delete('/api/cleanup/:repoId', (req, res) => {
|
|
1164
1434
|
sessions.delete(req.params.repoId);
|
|
@@ -1197,6 +1467,9 @@ function startServer(options = {}) {
|
|
|
1197
1467
|
}
|
|
1198
1468
|
resolve(server);
|
|
1199
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(); });
|
|
1200
1473
|
server.on('error', reject);
|
|
1201
1474
|
});
|
|
1202
1475
|
}
|