@in-the-loop-labs/pair-review 2.3.3 → 2.4.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.
Files changed (45) hide show
  1. package/.pi/skills/review-model-guidance/SKILL.md +1 -1
  2. package/.pi/skills/review-roulette/SKILL.md +1 -1
  3. package/README.md +15 -1
  4. package/package.json +2 -1
  5. package/plugin/.claude-plugin/plugin.json +1 -1
  6. package/plugin/skills/review-requests/SKILL.md +1 -1
  7. package/plugin-code-critic/.claude-plugin/plugin.json +1 -1
  8. package/public/css/pr.css +287 -14
  9. package/public/index.html +121 -57
  10. package/public/js/components/AIPanel.js +2 -1
  11. package/public/js/components/AdvancedConfigTab.js +2 -2
  12. package/public/js/components/AnalysisConfigModal.js +2 -2
  13. package/public/js/components/ChatPanel.js +187 -28
  14. package/public/js/components/CouncilProgressModal.js +4 -7
  15. package/public/js/components/SplitButton.js +66 -1
  16. package/public/js/components/VoiceCentricConfigTab.js +2 -2
  17. package/public/js/index.js +274 -21
  18. package/public/js/pr.js +194 -5
  19. package/public/local.html +8 -1
  20. package/public/pr.html +17 -2
  21. package/src/ai/codex-provider.js +14 -2
  22. package/src/ai/copilot-provider.js +1 -10
  23. package/src/ai/cursor-agent-provider.js +1 -10
  24. package/src/ai/gemini-provider.js +8 -17
  25. package/src/chat/acp-bridge.js +442 -0
  26. package/src/chat/api-reference.js +539 -0
  27. package/src/chat/chat-providers.js +290 -0
  28. package/src/chat/claude-code-bridge.js +499 -0
  29. package/src/chat/codex-bridge.js +601 -0
  30. package/src/chat/pi-bridge.js +56 -3
  31. package/src/chat/prompt-builder.js +12 -11
  32. package/src/chat/session-manager.js +110 -29
  33. package/src/config.js +4 -2
  34. package/src/database.js +50 -2
  35. package/src/github/client.js +43 -0
  36. package/src/routes/chat.js +60 -27
  37. package/src/routes/config.js +24 -1
  38. package/src/routes/github-collections.js +126 -0
  39. package/src/routes/mcp.js +2 -1
  40. package/src/routes/pr.js +166 -2
  41. package/src/routes/reviews.js +2 -1
  42. package/src/routes/shared.js +70 -49
  43. package/src/server.js +27 -1
  44. package/src/utils/safe-parse-json.js +19 -0
  45. package/.pi/skills/pair-review-api/SKILL.md +0 -448
@@ -0,0 +1,499 @@
1
+ // SPDX-License-Identifier: GPL-3.0-or-later
2
+ /**
3
+ * Claude Code Bridge
4
+ *
5
+ * Manages a long-lived Claude Code CLI process in stream-json mode for
6
+ * interactive chat sessions. Communicates over stdin/stdout using NDJSON:
7
+ * - Sends JSON user messages on stdin
8
+ * - Receives NDJSON events (system, assistant, stream_event, result, etc.) on stdout
9
+ *
10
+ * Mirrors the PiBridge / AcpBridge EventEmitter interface so all bridges
11
+ * can be used interchangeably.
12
+ *
13
+ * Emits high-level events: delta, complete, error, tool_use, status, ready, close, session.
14
+ */
15
+
16
+ const { EventEmitter } = require('events');
17
+ const { spawn } = require('child_process');
18
+ const { createInterface } = require('readline');
19
+ const crypto = require('crypto');
20
+ const logger = require('../utils/logger');
21
+ const { quoteShellArgs } = require('../ai/provider');
22
+
23
+ const CLAUDE_CHAT_TOOLS = 'Read,Bash,Grep,Glob,Agent';
24
+
25
+ // Default dependencies (overridable for testing)
26
+ const defaults = {
27
+ spawn,
28
+ createInterface,
29
+ };
30
+
31
+ class ClaudeCodeBridge extends EventEmitter {
32
+ /**
33
+ * @param {Object} options
34
+ * @param {string} [options.model] - Model ID (e.g., 'claude-sonnet-4-6')
35
+ * @param {string} [options.cwd] - Working directory for Claude process
36
+ * @param {string} [options.systemPrompt] - System prompt text (prepended to first message)
37
+ * @param {string} [options.claudeCommand] - Override binary (default: env PAIR_REVIEW_CLAUDE_CMD or 'claude')
38
+ * @param {Object} [options.env] - Extra env vars for subprocess
39
+ * @param {boolean} [options.useShell] - Use shell mode for multi-word commands
40
+ * @param {string} [options.resumeSessionId] - Session ID for resumption
41
+ * @param {Object} [options._deps] - { spawn, createInterface } for testing
42
+ */
43
+ constructor(options = {}) {
44
+ super();
45
+ this.model = options.model || null;
46
+ this.cwd = options.cwd || process.cwd();
47
+ this.systemPrompt = options.systemPrompt || null;
48
+ this.claudeCommand = options.claudeCommand || process.env.PAIR_REVIEW_CLAUDE_CMD || 'claude';
49
+ this.env = options.env || {};
50
+ this.useShell = options.useShell || false;
51
+ this.resumeSessionId = options.resumeSessionId || null;
52
+
53
+ this._deps = { ...defaults, ...options._deps };
54
+ this._process = null;
55
+ this._readline = null;
56
+ this._sessionId = null;
57
+ this._ready = false;
58
+ this._closing = false;
59
+ this._accumulatedText = '';
60
+ this._inMessage = false;
61
+ this._firstMessage = !options.resumeSessionId;
62
+ this._activeTools = new Map();
63
+ }
64
+
65
+ /**
66
+ * Spawn the Claude CLI subprocess in stream-json mode.
67
+ *
68
+ * With --input-format stream-json, the CLI does NOT emit system/init until
69
+ * the first user message is sent on stdin. So start() spawns the process,
70
+ * wires up I/O, and marks the bridge as ready immediately. The session ID
71
+ * is captured later when system/init arrives with the first response.
72
+ *
73
+ * @returns {Promise<void>}
74
+ */
75
+ async start() {
76
+ if (this._process) {
77
+ throw new Error('ClaudeCodeBridge already started');
78
+ }
79
+
80
+ const deps = this._deps;
81
+ const command = this.claudeCommand;
82
+ const args = this._buildArgs();
83
+ const useShell = this.useShell;
84
+
85
+ // For multi-word commands (e.g. "devx claude"), use shell mode.
86
+ // quoteShellArgs handles args containing shell-sensitive chars like {}.
87
+ const spawnCmd = useShell ? `${command} ${quoteShellArgs(args).join(' ')}` : command;
88
+ const spawnArgs = useShell ? [] : args;
89
+
90
+ logger.info(`[ClaudeCodeBridge] Starting: ${command} ${args.join(' ')}`);
91
+
92
+ return new Promise((resolve, reject) => {
93
+ // Remove CLAUDECODE env var to avoid "nested session" error
94
+ const env = { ...process.env, ...this.env };
95
+ delete env.CLAUDECODE;
96
+
97
+ const proc = deps.spawn(spawnCmd, spawnArgs, {
98
+ cwd: this.cwd,
99
+ stdio: ['pipe', 'pipe', 'pipe'],
100
+ env,
101
+ shell: useShell,
102
+ });
103
+
104
+ this._process = proc;
105
+ let spawned = false;
106
+
107
+ // Handle spawn error (e.g., ENOENT)
108
+ proc.on('error', (err) => {
109
+ if (!spawned) {
110
+ this._process = null;
111
+ reject(new Error(`Failed to start Claude CLI: ${err.message}`));
112
+ } else {
113
+ logger.error(`[ClaudeCodeBridge] Process error: ${err.message}`);
114
+ this.emit('error', { error: err });
115
+ }
116
+ });
117
+
118
+ // Handle process exit
119
+ proc.on('close', (code, signal) => {
120
+ this._ready = false;
121
+ this._process = null;
122
+
123
+ if (!spawned) {
124
+ reject(new Error(`Claude CLI exited before ready (code=${code}, signal=${signal})`));
125
+ return;
126
+ }
127
+
128
+ if (!this._closing) {
129
+ logger.warn(`[ClaudeCodeBridge] Process exited unexpectedly (code=${code}, signal=${signal})`);
130
+ this.emit('error', { error: new Error(`Claude CLI exited (code=${code}, signal=${signal})`) });
131
+ } else {
132
+ logger.info(`[ClaudeCodeBridge] Process exited (code=${code}, signal=${signal})`);
133
+ }
134
+
135
+ this.emit('close');
136
+ });
137
+
138
+ // Collect stderr for diagnostics
139
+ proc.stderr.on('data', (data) => {
140
+ const text = data.toString().trim();
141
+ if (text) {
142
+ logger.debug(`[ClaudeCodeBridge] stderr: ${text}`);
143
+ }
144
+ });
145
+
146
+ // Handle stdin errors (e.g., EPIPE if process dies)
147
+ proc.stdin.on('error', (err) => {
148
+ logger.error(`[ClaudeCodeBridge] stdin error: ${err.message}`);
149
+ });
150
+
151
+ // Set up line-by-line parsing of stdout for NDJSON
152
+ this._readline = deps.createInterface({
153
+ input: proc.stdout,
154
+ crlfDelay: Infinity,
155
+ });
156
+
157
+ this._readline.on('line', (line) => {
158
+ if (!line.trim()) return;
159
+
160
+ let msg;
161
+ try {
162
+ msg = JSON.parse(line);
163
+ } catch {
164
+ logger.debug(`[ClaudeCodeBridge] Ignoring unparseable line: ${line.substring(0, 100)}`);
165
+ return;
166
+ }
167
+
168
+ this._handleMessage(msg);
169
+ });
170
+
171
+ // The CLI with --input-format stream-json doesn't emit anything until
172
+ // the first user message is sent. Mark ready after a tick so that
173
+ // synchronous spawn errors (ENOENT) can reject the promise first.
174
+ setImmediate(() => {
175
+ if (!this._process) return; // error already fired and cleaned up
176
+ spawned = true;
177
+ this._ready = true;
178
+ logger.info(`[ClaudeCodeBridge] Spawned (PID ${proc.pid}), ready for messages`);
179
+ this.emit('ready');
180
+ resolve();
181
+ });
182
+ });
183
+ }
184
+
185
+ /**
186
+ * Send a user message to the Claude CLI process.
187
+ * @param {string} content - The message text
188
+ */
189
+ async sendMessage(content) {
190
+ if (!this.isReady()) {
191
+ throw new Error('ClaudeCodeBridge is not ready');
192
+ }
193
+ if (this.isBusy()) {
194
+ throw new Error('ClaudeCodeBridge is busy');
195
+ }
196
+
197
+ this._accumulatedText = '';
198
+ this._inMessage = true;
199
+
200
+ let messageContent = content;
201
+ const isFirst = this.systemPrompt && this._firstMessage;
202
+ if (isFirst) {
203
+ messageContent = this.systemPrompt + '\n\n' + content;
204
+ }
205
+
206
+ logger.debug(`[ClaudeCodeBridge] Sending prompt (${messageContent.length} chars): ${messageContent.substring(0, 100)}${messageContent.length > 100 ? '...' : ''}`);
207
+
208
+ try {
209
+ this._write({
210
+ type: 'user',
211
+ message: { role: 'user', content: messageContent },
212
+ session_id: this._sessionId || '',
213
+ parent_tool_use_id: null,
214
+ });
215
+ if (isFirst) this._firstMessage = false;
216
+ } catch (err) {
217
+ this._inMessage = false;
218
+ throw err;
219
+ }
220
+ }
221
+
222
+ /**
223
+ * Abort the current operation by sending an interrupt control request.
224
+ */
225
+ abort() {
226
+ if (!this.isReady()) return;
227
+
228
+ if (this._sessionId) {
229
+ logger.debug('[ClaudeCodeBridge] Sending interrupt');
230
+ this._write({
231
+ type: 'control_request',
232
+ request: { subtype: 'interrupt' },
233
+ request_id: crypto.randomUUID(),
234
+ });
235
+ } else if (this._process) {
236
+ logger.debug('[ClaudeCodeBridge] No session ID yet, sending SIGTERM');
237
+ this._process.kill('SIGTERM');
238
+ }
239
+ }
240
+
241
+ /**
242
+ * Gracefully shut down the Claude CLI process.
243
+ * @returns {Promise<void>}
244
+ */
245
+ async close() {
246
+ if (!this._process) return;
247
+
248
+ this._closing = true;
249
+ this._activeTools.clear();
250
+ this.removeAllListeners();
251
+
252
+ // Close readline if it exists
253
+ if (this._readline) {
254
+ this._readline.close();
255
+ this._readline = null;
256
+ }
257
+
258
+ return new Promise((resolve) => {
259
+ const proc = this._process;
260
+ if (!proc) {
261
+ resolve();
262
+ return;
263
+ }
264
+
265
+ // If process already exited, 'close' won't fire again — resolve immediately
266
+ if (proc.exitCode !== null || proc.signalCode !== null) {
267
+ this._process = null;
268
+ resolve();
269
+ return;
270
+ }
271
+
272
+ // Give the process a moment to exit gracefully, then force kill
273
+ const killTimeout = setTimeout(() => {
274
+ if (this._process) {
275
+ logger.warn('[ClaudeCodeBridge] Force killing process');
276
+ this._process.kill('SIGKILL');
277
+ }
278
+ }, 3000);
279
+
280
+ const onClose = () => {
281
+ clearTimeout(killTimeout);
282
+ this._process = null;
283
+ resolve();
284
+ };
285
+
286
+ proc.once('close', onClose);
287
+
288
+ // Close stdin then send SIGTERM
289
+ try {
290
+ proc.stdin.end();
291
+ } catch {
292
+ // stdin may already be closed
293
+ }
294
+ proc.kill('SIGTERM');
295
+ });
296
+ }
297
+
298
+ /**
299
+ * Check if the CLI process is alive and ready.
300
+ * @returns {boolean}
301
+ */
302
+ isReady() {
303
+ return this._ready && this._process !== null && !this._closing;
304
+ }
305
+
306
+ /**
307
+ * Check if the bridge is currently processing a message.
308
+ * @returns {boolean}
309
+ */
310
+ isBusy() {
311
+ return this._inMessage;
312
+ }
313
+
314
+ // ---------------------------------------------------------------------------
315
+ // Private methods
316
+ // ---------------------------------------------------------------------------
317
+
318
+ /**
319
+ * Build CLI arguments for the Claude process.
320
+ * @returns {string[]}
321
+ */
322
+ _buildArgs() {
323
+ const args = [
324
+ '-p', '',
325
+ '--output-format', 'stream-json',
326
+ '--input-format', 'stream-json',
327
+ '--verbose',
328
+ '--include-partial-messages',
329
+ '--allowedTools', CLAUDE_CHAT_TOOLS,
330
+ '--settings', '{"disableAllHooks":true}',
331
+ ];
332
+
333
+ if (this.resumeSessionId) {
334
+ args.unshift('--resume', this.resumeSessionId);
335
+ }
336
+
337
+ if (this.model) {
338
+ args.push('--model', this.model);
339
+ }
340
+
341
+ return args;
342
+ }
343
+
344
+ /**
345
+ * Write a JSON object as NDJSON to the process stdin.
346
+ * @param {Object} obj - The object to serialize and write
347
+ */
348
+ _write(obj) {
349
+ if (!this._process || !this._process.stdin.writable) {
350
+ throw new Error('Claude CLI process stdin is not writable');
351
+ }
352
+ this._process.stdin.write(JSON.stringify(obj) + '\n');
353
+ }
354
+
355
+ /**
356
+ * Route an incoming NDJSON message to the appropriate handler.
357
+ * @param {Object} msg - Parsed JSON message from Claude CLI stdout
358
+ */
359
+ _handleMessage(msg) {
360
+ const type = msg.type;
361
+ const subtype = msg.subtype;
362
+
363
+ switch (type) {
364
+ case 'system':
365
+ this._handleSystemMessage(msg, subtype);
366
+ break;
367
+
368
+ case 'assistant':
369
+ this.emit('status', { status: 'working' });
370
+ break;
371
+
372
+ case 'stream_event':
373
+ this._handleStreamEvent(msg);
374
+ break;
375
+
376
+ case 'user':
377
+ // tool_result content blocks signal tool execution completed
378
+ if (Array.isArray(msg.content)) {
379
+ for (const block of msg.content) {
380
+ if (block.type === 'tool_result' && block.tool_use_id) {
381
+ const toolName = this._activeTools.get(block.tool_use_id) || null;
382
+ this._activeTools.delete(block.tool_use_id);
383
+ this.emit('tool_use', {
384
+ toolCallId: block.tool_use_id,
385
+ toolName,
386
+ status: 'end',
387
+ });
388
+ }
389
+ }
390
+ }
391
+ break;
392
+
393
+ case 'tool_progress':
394
+ this.emit('tool_use', {
395
+ toolCallId: msg.tool_use_id,
396
+ toolName: msg.tool_name,
397
+ status: 'update',
398
+ });
399
+ break;
400
+
401
+ case 'result':
402
+ this._handleResult(msg, subtype);
403
+ break;
404
+
405
+ case 'keep_alive':
406
+ // Ignore keep-alive pings
407
+ break;
408
+
409
+ default:
410
+ logger.debug(`[ClaudeCodeBridge] Unhandled message type: ${type}`);
411
+ }
412
+ }
413
+
414
+ /**
415
+ * Handle system messages (init, status, etc.).
416
+ * @param {Object} msg - The system message
417
+ * @param {string} subtype - The system message subtype
418
+ */
419
+ _handleSystemMessage(msg, subtype) {
420
+ switch (subtype) {
421
+ case 'init':
422
+ // The CLI emits system/init at the start of every response turn.
423
+ // Only capture and emit on the first one.
424
+ if (!this._sessionId) {
425
+ this._sessionId = msg.session_id || null;
426
+ logger.info(`[ClaudeCodeBridge] Session initialized (session ${this._sessionId})`);
427
+ this.emit('session', { sessionId: this._sessionId });
428
+ }
429
+ break;
430
+
431
+ case 'status':
432
+ this.emit('status', { status: 'working' });
433
+ break;
434
+
435
+ default:
436
+ logger.debug(`[ClaudeCodeBridge] Unhandled system subtype: ${subtype}`);
437
+ }
438
+ }
439
+
440
+ /**
441
+ * Handle stream_event messages (content deltas, tool use starts, etc.).
442
+ * @param {Object} msg - The stream_event message
443
+ */
444
+ _handleStreamEvent(msg) {
445
+ const event = msg.event;
446
+ if (!event) return;
447
+
448
+ switch (event.type) {
449
+ case 'content_block_delta':
450
+ if (event.delta && event.delta.type === 'text_delta') {
451
+ const text = event.delta.text || '';
452
+ if (text) {
453
+ this._accumulatedText += text;
454
+ this.emit('delta', { text });
455
+ }
456
+ }
457
+ break;
458
+
459
+ case 'content_block_start':
460
+ if (event.content_block && event.content_block.type === 'tool_use') {
461
+ const { id, name } = event.content_block;
462
+ this._activeTools.set(id, name);
463
+ this.emit('tool_use', {
464
+ toolCallId: id,
465
+ toolName: name,
466
+ status: 'start',
467
+ });
468
+ }
469
+ break;
470
+
471
+ default:
472
+ logger.debug(`[ClaudeCodeBridge] Unhandled stream_event type: ${event.type}`);
473
+ }
474
+ }
475
+
476
+ /**
477
+ * Handle result messages (success or error).
478
+ * @param {Object} msg - The result message
479
+ * @param {string} subtype - The result subtype
480
+ */
481
+ _handleResult(msg, subtype) {
482
+ this._inMessage = false;
483
+ const fullText = this._accumulatedText;
484
+ this._accumulatedText = '';
485
+ this._activeTools.clear();
486
+
487
+ if (subtype && subtype !== 'success') {
488
+ const errorMessage = (Array.isArray(msg.errors) && msg.errors.length)
489
+ ? msg.errors.join('\n')
490
+ : subtype;
491
+ logger.error(`[ClaudeCodeBridge] Result error: ${errorMessage}`);
492
+ this.emit('error', { error: new Error(errorMessage) });
493
+ } else {
494
+ this.emit('complete', { fullText });
495
+ }
496
+ }
497
+ }
498
+
499
+ module.exports = ClaudeCodeBridge;