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.
@@ -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
- return [...seen.values()];
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 };