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/README.md +122 -4
- package/bin/reviewer.js +286 -7
- package/lib/agent.js +25 -5
- package/lib/answerer.js +191 -0
- package/lib/changes.js +83 -4
- 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 +197 -20
- package/lib/comments.js +175 -10
- package/lib/export.js +2 -2
- package/lib/presence.js +223 -0
- package/lib/review.js +3 -1
- package/lib/sessions.js +8 -4
- package/lib/store.js +290 -7
- package/lib/threads.js +180 -0
- package/lib/watch.js +131 -0
- package/man/git-reviewer.1 +58 -0
- package/package.json +1 -1
- package/public/app.js +811 -119
- package/public/index.html +33 -0
- package/public/style.css +277 -0
- package/server.js +338 -43
package/lib/answerer.js
ADDED
|
@@ -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 };
|
package/lib/changes.js
CHANGED
|
@@ -12,8 +12,14 @@
|
|
|
12
12
|
* @typedef {object} ChangedFile
|
|
13
13
|
* @property {string} path repo-relative path
|
|
14
14
|
* @property {'A'|'M'|'D'|'R'|'B'} status
|
|
15
|
+
* @property {boolean} [directory] a folded, wholly untracked directory
|
|
16
|
+
* @property {number} [fileCount] untracked files represented by that directory
|
|
15
17
|
*/
|
|
16
18
|
|
|
19
|
+
// Keep a new source folder reviewable file by file, while hundreds of
|
|
20
|
+
// untracked files take one row instead of pushing tracked edits off screen.
|
|
21
|
+
const UNTRACKED_DIRECTORY_LIMIT = 200;
|
|
22
|
+
|
|
17
23
|
/**
|
|
18
24
|
* Unquotes git's octal-escaped, C-quoted filename strings (e.g. "\"caf\\303\\251.txt\"" -> "café.txt").
|
|
19
25
|
*
|
|
@@ -136,30 +142,101 @@ function parseCommitFilePath(raw) {
|
|
|
136
142
|
* most- to least-specific for that reason.
|
|
137
143
|
*
|
|
138
144
|
* @param {import('simple-git').StatusResult} status
|
|
145
|
+
* @param {string[]} [untrackedDirectories] directory entries from Git's normal untracked status
|
|
139
146
|
* @returns {ChangedFile[]}
|
|
140
147
|
*/
|
|
141
|
-
function collectWorkingChanges(status) {
|
|
148
|
+
function collectWorkingChanges(status, untrackedDirectories = []) {
|
|
149
|
+
const directories = new Set(untrackedDirectories.map(unquoteGitPath).filter(p => p?.endsWith('/')));
|
|
150
|
+
const directoryOf = new Map();
|
|
151
|
+
const counts = new Map();
|
|
152
|
+
|
|
153
|
+
for (const rawPath of status?.not_added ?? []) {
|
|
154
|
+
const filePath = unquoteGitPath(rawPath);
|
|
155
|
+
if (!filePath || directoryOf.has(filePath)) continue;
|
|
156
|
+
// Check path components, not every directory against every file. The
|
|
157
|
+
// trailing slash also keeps output/ separate from output-neighbor/.
|
|
158
|
+
for (let slash = filePath.indexOf('/'); slash !== -1; slash = filePath.indexOf('/', slash + 1)) {
|
|
159
|
+
const directory = filePath.slice(0, slash + 1);
|
|
160
|
+
if (!directories.has(directory)) continue;
|
|
161
|
+
directoryOf.set(filePath, directory);
|
|
162
|
+
counts.set(directory, (counts.get(directory) ?? 0) + 1);
|
|
163
|
+
break;
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
142
167
|
const buckets = [
|
|
143
168
|
[status?.renamed ?? [], 'R'],
|
|
144
169
|
[status?.deleted ?? [], 'D'],
|
|
145
170
|
[status?.created ?? [], 'A'],
|
|
146
|
-
[status?.not_added ?? [], 'A'],
|
|
171
|
+
[status?.not_added ?? [], 'A', true],
|
|
147
172
|
[status?.modified ?? [], 'M']
|
|
148
173
|
];
|
|
149
174
|
|
|
150
175
|
const seen = new Map();
|
|
176
|
+
const untrackedPaths = new Set();
|
|
151
177
|
|
|
152
|
-
for (const [files, letter] of buckets) {
|
|
178
|
+
for (const [files, letter, untracked] of buckets) {
|
|
153
179
|
for (const entry of files) {
|
|
154
180
|
// `renamed` holds `{ from, to }`; every other bucket holds plain strings.
|
|
155
181
|
const rawPath = typeof entry === 'string' ? entry : entry?.to;
|
|
156
182
|
const filePath = unquoteGitPath(rawPath);
|
|
157
183
|
if (!filePath || seen.has(filePath)) continue;
|
|
184
|
+
const directory = untracked ? directoryOf.get(filePath) : null;
|
|
185
|
+
const fileCount = counts.get(directory) ?? 0;
|
|
186
|
+
if (fileCount > UNTRACKED_DIRECTORY_LIMIT) {
|
|
187
|
+
if (!seen.has(directory)) {
|
|
188
|
+
seen.set(directory, { path: directory, status: 'A', directory: true, fileCount });
|
|
189
|
+
untrackedPaths.add(directory);
|
|
190
|
+
}
|
|
191
|
+
continue;
|
|
192
|
+
}
|
|
158
193
|
seen.set(filePath, { path: filePath, status: letter });
|
|
194
|
+
if (untracked) untrackedPaths.add(filePath);
|
|
159
195
|
}
|
|
160
196
|
}
|
|
161
197
|
|
|
162
|
-
|
|
198
|
+
// Put tracked edits on screen first without changing status precedence.
|
|
199
|
+
// The source bucket matters: a staged addition also has status A.
|
|
200
|
+
const files = [...seen.values()];
|
|
201
|
+
return [
|
|
202
|
+
...files.filter(file => !untrackedPaths.has(file.path)),
|
|
203
|
+
...files.filter(file => untrackedPaths.has(file.path))
|
|
204
|
+
];
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* Git's normal status identifies wholly untracked directories; its all
|
|
209
|
+
* status supplies the files to count and keep individually in small ones.
|
|
210
|
+
* Turning untracked files off must avoid enumerating them in the first place.
|
|
211
|
+
* @param {import('simple-git').SimpleGit} git
|
|
212
|
+
* @param {boolean} [untracked]
|
|
213
|
+
* @returns {Promise<ChangedFile[]>}
|
|
214
|
+
*/
|
|
215
|
+
async function collectWorkingTreeChanges(git, untracked = true) {
|
|
216
|
+
if (!untracked) return collectWorkingChanges(await git.status(['--untracked-files=no']));
|
|
217
|
+
const status = await git.status(['--untracked-files=normal']);
|
|
218
|
+
const directories = (status.not_added ?? []).filter(p => unquoteGitPath(p).endsWith('/'));
|
|
219
|
+
if (!directories.length) return collectWorkingChanges(status);
|
|
220
|
+
return collectWorkingChanges(await git.status(['--untracked-files=all']), directories);
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/** Explain the visible rows and the files represented by folded directories. */
|
|
224
|
+
function describeWorkingChanges(files, untracked = true) {
|
|
225
|
+
const directories = files.filter(file => file.directory);
|
|
226
|
+
const count = files.length - directories.length;
|
|
227
|
+
const headline = `Found ${count} changed file${count === 1 ? '' : 's'}`;
|
|
228
|
+
if (!untracked) {
|
|
229
|
+
const changes = count ? headline : 'No tracked changes';
|
|
230
|
+
return `${changes} — untracked files hidden`;
|
|
231
|
+
}
|
|
232
|
+
const total = directories.reduce((sum, file) => sum + file.fileCount, 0);
|
|
233
|
+
const summary = directories.length
|
|
234
|
+
? `; ${directories.length} folder${directories.length === 1 ? '' : 's'} folded (${total.toLocaleString('en-US')} untracked files)`
|
|
235
|
+
: '';
|
|
236
|
+
const folded = directories.map(file =>
|
|
237
|
+
`${file.path} holds ${file.fileCount.toLocaleString('en-US')} untracked files; individual files are not listed.`
|
|
238
|
+
).join(' ');
|
|
239
|
+
return headline + summary + (folded ? `. ${folded}` : '');
|
|
163
240
|
}
|
|
164
241
|
|
|
165
242
|
/**
|
|
@@ -287,6 +364,8 @@ async function collectStagedChanges(git) {
|
|
|
287
364
|
module.exports = {
|
|
288
365
|
collectStagedChanges,
|
|
289
366
|
collectWorkingChanges,
|
|
367
|
+
collectWorkingTreeChanges,
|
|
368
|
+
describeWorkingChanges,
|
|
290
369
|
collectCommitChanges,
|
|
291
370
|
classifyCommitFile,
|
|
292
371
|
unquoteGitPath,
|
package/lib/channel.js
ADDED
|
@@ -0,0 +1,286 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { updateSavedComments, readSavedComments } = require('./store');
|
|
4
|
+
const { watchComments } = require('./watch');
|
|
5
|
+
const { announce } = require('./presence');
|
|
6
|
+
const { mintKey, isKey } = require('./comments');
|
|
7
|
+
const { awaitingAnswer, newReviewerMessages, appendReply, describeThread, lastAuthor } = require('./threads');
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* `reviewer channel`: a Claude Code channel for one repository's review.
|
|
11
|
+
*
|
|
12
|
+
* Claude Code runs this as an MCP server over stdio, and with channels
|
|
13
|
+
* enabled for it (`--dangerously-load-development-channels server:reviewer`
|
|
14
|
+
* during the research preview) it may push messages into the running
|
|
15
|
+
* session. Every comment the reviewer writes, and every reply they add in a
|
|
16
|
+
* thread, is pushed as one; the session answers by calling `reply`, which
|
|
17
|
+
* writes the answer into the thread, where the page shows it within a
|
|
18
|
+
* second. The session is the one the reviewer chose -- `reviewer connect`
|
|
19
|
+
* resumes the session that wrote the code -- so the answers come from the
|
|
20
|
+
* agent that knows why it wrote what it wrote.
|
|
21
|
+
*
|
|
22
|
+
* The protocol is spoken directly: MCP is JSON-RPC 2.0, one message per line,
|
|
23
|
+
* and this server needs four methods of it. The SDK would bring seventeen
|
|
24
|
+
* dependencies and a second major of express into a package that has three
|
|
25
|
+
* (#4).
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
const PROTOCOL_VERSION = '2025-11-25';
|
|
29
|
+
|
|
30
|
+
// At most this many waiting threads are pushed one by one when the session
|
|
31
|
+
// connects; the rest are summarised, so connecting to an old review with
|
|
32
|
+
// fifty open threads does not bury the session.
|
|
33
|
+
const BACKLOG_ONE_BY_ONE = 8;
|
|
34
|
+
|
|
35
|
+
/** @param {string} repoPath */
|
|
36
|
+
function instructionsFor(repoPath) {
|
|
37
|
+
return [
|
|
38
|
+
`reviewer is a local code review tool. While this server is connected, every comment a reviewer leaves on`,
|
|
39
|
+
`the changes in ${repoPath}, and every reply they add to a thread, arrives as a`,
|
|
40
|
+
'<channel source="reviewer" key="..." file="..." line="..."> message holding the thread so far.',
|
|
41
|
+
'',
|
|
42
|
+
'Answer each one by calling the reply tool with its key. Your reply is shown under the reviewer\'s',
|
|
43
|
+
'comment exactly as written: keep it short and direct; Markdown is fine. Answer from what you know about',
|
|
44
|
+
'why the code is the way it is, and read files when you need to check. Do not change files because of a',
|
|
45
|
+
'comment unless it explicitly asks you to make the change now; otherwise say what you would change. If',
|
|
46
|
+
'several arrive together, answer each one separately. list_comments shows what is still waiting.'
|
|
47
|
+
].join('\n');
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const TOOLS = [
|
|
51
|
+
{
|
|
52
|
+
name: 'reply',
|
|
53
|
+
description: 'Post a reply in a review comment thread. The reviewer sees it under their comment straight away.',
|
|
54
|
+
inputSchema: {
|
|
55
|
+
type: 'object',
|
|
56
|
+
properties: {
|
|
57
|
+
key: { type: 'string', description: 'The key of the comment, from the channel message or list_comments' },
|
|
58
|
+
text: { type: 'string', description: 'The reply, in Markdown' }
|
|
59
|
+
},
|
|
60
|
+
required: ['key', 'text']
|
|
61
|
+
}
|
|
62
|
+
},
|
|
63
|
+
{
|
|
64
|
+
name: 'list_comments',
|
|
65
|
+
description: 'List the review\'s comment threads: by default only those whose last word is the reviewer\'s.',
|
|
66
|
+
inputSchema: {
|
|
67
|
+
type: 'object',
|
|
68
|
+
properties: {
|
|
69
|
+
all: { type: 'boolean', description: 'Include threads that already have an answer' }
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
];
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* A thread as the session is told about it.
|
|
77
|
+
*
|
|
78
|
+
* @param {import('./comments').Comment} comment
|
|
79
|
+
* @param {boolean} followUp whether this is a new reply in a thread rather than a new comment
|
|
80
|
+
*/
|
|
81
|
+
function channelMessage(comment, followUp) {
|
|
82
|
+
return {
|
|
83
|
+
content: [
|
|
84
|
+
followUp ? 'The reviewer replied in a comment thread:' : 'New review comment:',
|
|
85
|
+
'',
|
|
86
|
+
describeThread(comment),
|
|
87
|
+
'',
|
|
88
|
+
`Answer with the reply tool, key "${comment.key}".`
|
|
89
|
+
].join('\n'),
|
|
90
|
+
meta: { key: comment.key, file: comment.file, line: String(comment.line) }
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Run the channel until its input closes.
|
|
96
|
+
*
|
|
97
|
+
* @param {object} options
|
|
98
|
+
* @param {string} options.repoPath repository whose review this serves
|
|
99
|
+
* @param {string} options.reviewsDir
|
|
100
|
+
* @param {NodeJS.ReadableStream} [options.input]
|
|
101
|
+
* @param {NodeJS.WritableStream} [options.output]
|
|
102
|
+
* @param {NodeJS.ProcessEnv} [options.env]
|
|
103
|
+
* @param {string} [options.version]
|
|
104
|
+
* @param {string|null} [options.title] what the connected session is about, for the page
|
|
105
|
+
* @param {(line: string) => void} [options.log] diagnostics; stdout belongs to the protocol
|
|
106
|
+
* @returns {Promise<void>} settles when the input ends
|
|
107
|
+
*/
|
|
108
|
+
function runChannel({
|
|
109
|
+
repoPath,
|
|
110
|
+
reviewsDir,
|
|
111
|
+
input = process.stdin,
|
|
112
|
+
output = process.stdout,
|
|
113
|
+
env = process.env,
|
|
114
|
+
version = '0.0.0',
|
|
115
|
+
title = null,
|
|
116
|
+
log = line => process.stderr.write(`reviewer channel: ${line}\n`)
|
|
117
|
+
}) {
|
|
118
|
+
const session = env.CLAUDE_CODE_SESSION_ID || null;
|
|
119
|
+
const send = message => output.write(`${JSON.stringify({ jsonrpc: '2.0', ...message })}\n`);
|
|
120
|
+
const notify = (method, params) => send({ method, params });
|
|
121
|
+
|
|
122
|
+
/** @type {import('./comments').Comment[]|null} */
|
|
123
|
+
let known = null;
|
|
124
|
+
let watcher = null;
|
|
125
|
+
let presence = null;
|
|
126
|
+
const working = new Set();
|
|
127
|
+
|
|
128
|
+
const setWorking = () => presence?.update({ working: [...working] });
|
|
129
|
+
|
|
130
|
+
const push = (comment, followUp) => {
|
|
131
|
+
notify('notifications/claude/channel', channelMessage(comment, followUp));
|
|
132
|
+
working.add(comment.key);
|
|
133
|
+
};
|
|
134
|
+
|
|
135
|
+
const start = () => {
|
|
136
|
+
if (watcher) return;
|
|
137
|
+
presence = announce(reviewsDir, repoPath, { kind: 'channel', agent: 'claude', session, title });
|
|
138
|
+
watcher = watchComments(reviewsDir, repoPath, state => {
|
|
139
|
+
const comments = state.comments;
|
|
140
|
+
if (known === null) {
|
|
141
|
+
const waiting = awaitingAnswer(comments);
|
|
142
|
+
waiting.slice(0, BACKLOG_ONE_BY_ONE).forEach(comment => push(comment, (comment.followUps ?? []).length > 0));
|
|
143
|
+
if (waiting.length > BACKLOG_ONE_BY_ONE) {
|
|
144
|
+
notify('notifications/claude/channel', {
|
|
145
|
+
content: `${waiting.length - BACKLOG_ONE_BY_ONE} more comment threads in this review are waiting for an answer. ` +
|
|
146
|
+
'Call list_comments to see them, and answer each with the reply tool.',
|
|
147
|
+
meta: { waiting: String(waiting.length - BACKLOG_ONE_BY_ONE) }
|
|
148
|
+
});
|
|
149
|
+
}
|
|
150
|
+
} else {
|
|
151
|
+
for (const { comment, followUp } of newReviewerMessages(known, comments)) push(comment, followUp !== null);
|
|
152
|
+
}
|
|
153
|
+
// A thread answered from somewhere else is no longer this session's to answer.
|
|
154
|
+
for (const key of working) {
|
|
155
|
+
const thread = comments.find(comment => comment.key === key);
|
|
156
|
+
if (!thread || lastAuthor(thread) !== null) working.delete(key);
|
|
157
|
+
}
|
|
158
|
+
known = comments;
|
|
159
|
+
setWorking();
|
|
160
|
+
}, { onError: error => log(`could not read the review: ${error.message}`) });
|
|
161
|
+
};
|
|
162
|
+
|
|
163
|
+
const tools = {
|
|
164
|
+
async reply({ key, text } = {}) {
|
|
165
|
+
if (!isKey(key)) return failure('key must be the key of a comment, from the channel message or list_comments.');
|
|
166
|
+
if (typeof text !== 'string' || text.trim() === '') return failure('text must be the reply to post.');
|
|
167
|
+
|
|
168
|
+
let found = false;
|
|
169
|
+
await updateSavedComments(reviewsDir, repoPath, current => {
|
|
170
|
+
const updated = appendReply(current.comments, key, {
|
|
171
|
+
key: mintKey(),
|
|
172
|
+
text: text.trim(),
|
|
173
|
+
timestamp: new Date().toISOString(),
|
|
174
|
+
by: 'claude',
|
|
175
|
+
...(session ? { session } : {})
|
|
176
|
+
});
|
|
177
|
+
found = updated !== null;
|
|
178
|
+
return updated ? { comments: updated } : null;
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
// An unknown key fails out loud: a reply that silently went nowhere
|
|
182
|
+
// would read, to the session, exactly like one the reviewer received.
|
|
183
|
+
if (!found) return failure(`No comment with key "${key}" in this review. Call list_comments for the current ones.`);
|
|
184
|
+
working.delete(key);
|
|
185
|
+
setWorking();
|
|
186
|
+
return success('Posted. The reviewer can see it under their comment now.');
|
|
187
|
+
},
|
|
188
|
+
|
|
189
|
+
async list_comments({ all = false } = {}) {
|
|
190
|
+
const { comments } = await readSavedComments(reviewsDir, repoPath);
|
|
191
|
+
const shown = all ? comments : awaitingAnswer(comments);
|
|
192
|
+
const threads = shown.map(comment => ({
|
|
193
|
+
key: comment.key,
|
|
194
|
+
file: comment.file,
|
|
195
|
+
line: comment.line,
|
|
196
|
+
waiting: lastAuthor(comment) === null,
|
|
197
|
+
thread: describeThread(comment)
|
|
198
|
+
}));
|
|
199
|
+
return success(threads.length === 0
|
|
200
|
+
? (all ? 'This review has no comments yet.' : 'Nothing is waiting for an answer.')
|
|
201
|
+
: JSON.stringify(threads, null, 2));
|
|
202
|
+
}
|
|
203
|
+
};
|
|
204
|
+
|
|
205
|
+
const handle = async message => {
|
|
206
|
+
const { id, method, params } = message;
|
|
207
|
+
const isRequest = id !== undefined && id !== null;
|
|
208
|
+
|
|
209
|
+
switch (method) {
|
|
210
|
+
case 'initialize':
|
|
211
|
+
return send({
|
|
212
|
+
id,
|
|
213
|
+
result: {
|
|
214
|
+
protocolVersion: PROTOCOL_VERSION,
|
|
215
|
+
capabilities: { tools: {}, experimental: { 'claude/channel': {} } },
|
|
216
|
+
serverInfo: { name: 'reviewer', title: 'reviewer', version },
|
|
217
|
+
instructions: instructionsFor(repoPath)
|
|
218
|
+
}
|
|
219
|
+
});
|
|
220
|
+
case 'notifications/initialized':
|
|
221
|
+
return start();
|
|
222
|
+
case 'ping':
|
|
223
|
+
return send({ id, result: {} });
|
|
224
|
+
case 'tools/list':
|
|
225
|
+
return send({ id, result: { tools: TOOLS } });
|
|
226
|
+
case 'tools/call': {
|
|
227
|
+
const tool = tools[params?.name];
|
|
228
|
+
if (!tool) return send({ id, error: { code: -32602, message: `Unknown tool: ${params?.name}` } });
|
|
229
|
+
try {
|
|
230
|
+
return send({ id, result: await tool(params.arguments ?? {}) });
|
|
231
|
+
} catch (error) {
|
|
232
|
+
return send({ id, result: failure(error.message) });
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
default:
|
|
236
|
+
// Including `server/discover`, the 2026-07-28 revision's replacement
|
|
237
|
+
// for `initialize`. Claude Code tries it first and, answered "method
|
|
238
|
+
// not found", falls back to `initialize` -- the handshake channels
|
|
239
|
+
// are documented against.
|
|
240
|
+
if (isRequest) send({ id, error: { code: -32601, message: `Method not found: ${method}` } });
|
|
241
|
+
return undefined;
|
|
242
|
+
}
|
|
243
|
+
};
|
|
244
|
+
|
|
245
|
+
return new Promise(resolve => {
|
|
246
|
+
let buffered = '';
|
|
247
|
+
let pending = Promise.resolve();
|
|
248
|
+
input.setEncoding?.('utf-8');
|
|
249
|
+
input.on('data', chunk => {
|
|
250
|
+
buffered += chunk;
|
|
251
|
+
let newline;
|
|
252
|
+
while ((newline = buffered.indexOf('\n')) >= 0) {
|
|
253
|
+
const line = buffered.slice(0, newline).trim();
|
|
254
|
+
buffered = buffered.slice(newline + 1);
|
|
255
|
+
if (!line) continue;
|
|
256
|
+
let message;
|
|
257
|
+
try {
|
|
258
|
+
message = JSON.parse(line);
|
|
259
|
+
} catch {
|
|
260
|
+
send({ id: null, error: { code: -32700, message: 'Parse error' } });
|
|
261
|
+
continue;
|
|
262
|
+
}
|
|
263
|
+
// In order: a reply must not overtake the list it was based on.
|
|
264
|
+
pending = pending.then(() => handle(message)).catch(error => log(error.message));
|
|
265
|
+
}
|
|
266
|
+
});
|
|
267
|
+
input.on('end', async () => {
|
|
268
|
+
await pending;
|
|
269
|
+
watcher?.close();
|
|
270
|
+
await presence?.close();
|
|
271
|
+
resolve();
|
|
272
|
+
});
|
|
273
|
+
});
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
/** @param {string} text */
|
|
277
|
+
function success(text) {
|
|
278
|
+
return { content: [{ type: 'text', text }] };
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
/** @param {string} text */
|
|
282
|
+
function failure(text) {
|
|
283
|
+
return { content: [{ type: 'text', text }], isError: true };
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
module.exports = { runChannel, channelMessage, instructionsFor, TOOLS, PROTOCOL_VERSION };
|