@in-the-loop-labs/pair-review 1.6.1 → 2.0.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 (68) hide show
  1. package/README.md +77 -4
  2. package/package.json +1 -1
  3. package/plugin/.claude-plugin/plugin.json +1 -1
  4. package/plugin/skills/review-requests/SKILL.md +4 -1
  5. package/plugin-code-critic/.claude-plugin/plugin.json +1 -1
  6. package/plugin-code-critic/skills/analyze/SKILL.md +4 -3
  7. package/public/css/pr.css +1875 -144
  8. package/public/js/CONVENTIONS.md +16 -0
  9. package/public/js/components/AIPanel.js +66 -0
  10. package/public/js/components/AnalysisConfigModal.js +2 -2
  11. package/public/js/components/ChatPanel.js +2952 -0
  12. package/public/js/components/CouncilProgressModal.js +28 -18
  13. package/public/js/components/KeyboardShortcuts.js +3 -0
  14. package/public/js/components/PanelGroup.js +723 -0
  15. package/public/js/components/PreviewModal.js +3 -8
  16. package/public/js/components/StatusIndicator.js +2 -2
  17. package/public/js/components/Toast.js +22 -1
  18. package/public/js/components/VoiceCentricConfigTab.js +2 -2
  19. package/public/js/index.js +8 -0
  20. package/public/js/local.js +25 -682
  21. package/public/js/modules/analysis-history.js +19 -66
  22. package/public/js/modules/comment-manager.js +57 -19
  23. package/public/js/modules/diff-context.js +176 -0
  24. package/public/js/modules/diff-renderer.js +30 -0
  25. package/public/js/modules/file-comment-manager.js +126 -105
  26. package/public/js/modules/file-list-merger.js +64 -0
  27. package/public/js/modules/panel-resizer.js +25 -6
  28. package/public/js/modules/suggestion-manager.js +40 -125
  29. package/public/js/pr.js +974 -178
  30. package/public/js/repo-settings.js +36 -6
  31. package/public/js/utils/category-emoji.js +44 -0
  32. package/public/js/utils/time.js +32 -0
  33. package/public/local.html +107 -71
  34. package/public/pr.html +107 -71
  35. package/public/repo-settings.html +32 -0
  36. package/src/ai/analyzer.js +8 -4
  37. package/src/ai/claude-provider.js +22 -11
  38. package/src/ai/copilot-provider.js +39 -9
  39. package/src/ai/cursor-agent-provider.js +36 -7
  40. package/src/ai/gemini-provider.js +17 -4
  41. package/src/ai/prompts/config.js +7 -1
  42. package/src/ai/provider-availability.js +1 -1
  43. package/src/ai/provider.js +25 -37
  44. package/src/ai/stream-parser.js +1 -1
  45. package/src/chat/CONVENTIONS.md +18 -0
  46. package/src/chat/pi-bridge.js +491 -0
  47. package/src/chat/prompt-builder.js +262 -0
  48. package/src/chat/session-manager.js +619 -0
  49. package/src/config.js +14 -0
  50. package/src/database.js +322 -15
  51. package/src/main.js +4 -17
  52. package/src/routes/analyses.js +721 -0
  53. package/src/routes/chat.js +655 -0
  54. package/src/routes/config.js +29 -8
  55. package/src/routes/context-files.js +223 -0
  56. package/src/routes/local.js +225 -1133
  57. package/src/routes/mcp.js +39 -30
  58. package/src/routes/pr.js +410 -52
  59. package/src/routes/reviews.js +1035 -0
  60. package/src/routes/shared.js +5 -30
  61. package/src/server.js +34 -12
  62. package/src/sse/review-events.js +46 -0
  63. package/src/utils/auto-context.js +88 -0
  64. package/src/utils/category-emoji.js +33 -0
  65. package/src/utils/diff-file-list.js +57 -0
  66. package/public/js/components/ProgressModal.js +0 -705
  67. package/src/routes/analysis.js +0 -1600
  68. package/src/routes/comments.js +0 -534
@@ -0,0 +1,491 @@
1
+ // SPDX-License-Identifier: GPL-3.0-or-later
2
+ /**
3
+ * Pi RPC Bridge
4
+ *
5
+ * Manages a long-lived Pi process in RPC mode for interactive chat sessions.
6
+ * Communicates over stdin/stdout using Pi's JSONL RPC protocol:
7
+ * - Sends JSON commands (prompt, abort, get_state) on stdin
8
+ * - Receives JSONL events (message_update, agent_end, etc.) on stdout
9
+ *
10
+ * Emits high-level events: delta, complete, error, tool_use, ready, close.
11
+ */
12
+
13
+ const { EventEmitter } = require('events');
14
+ const { spawn } = require('child_process');
15
+ const { createInterface } = require('readline');
16
+ const logger = require('../utils/logger');
17
+
18
+ /**
19
+ * Dialog methods in extension_ui_request that expect a response.
20
+ * We auto-cancel these since there's no interactive UI in the bridge.
21
+ */
22
+ const DIALOG_METHODS = new Set(['select', 'confirm', 'input', 'editor']);
23
+
24
+ class PiBridge extends EventEmitter {
25
+ /**
26
+ * @param {Object} options
27
+ * @param {string} [options.model] - Model ID (e.g., 'claude-sonnet-4')
28
+ * @param {string} [options.provider] - Provider name (e.g., 'anthropic')
29
+ * @param {string} [options.cwd] - Working directory for Pi process
30
+ * @param {string} [options.systemPrompt] - System prompt text
31
+ * @param {string} [options.tools] - Comma-separated tool list (default: 'read,grep,find,ls')
32
+ * @param {string} [options.piCommand] - Override Pi command (default: 'pi')
33
+ * @param {string[]} [options.skills] - Array of skill file paths to load via --skill
34
+ * @param {string[]} [options.extensions] - Array of extension directory paths to load via -e
35
+ * @param {string} [options.sessionPath] - Path to a session file for resumption
36
+ */
37
+ constructor(options = {}) {
38
+ super();
39
+ this.model = options.model || null;
40
+ this.provider = options.provider || null;
41
+ this.cwd = options.cwd || process.cwd();
42
+ this.systemPrompt = options.systemPrompt || null;
43
+ this.tools = options.tools || 'read,grep,find,ls';
44
+ this.piCommand = options.piCommand || process.env.PAIR_REVIEW_PI_CMD || 'pi';
45
+ this.skills = options.skills || [];
46
+ this.extensions = options.extensions || [];
47
+ this.sessionPath = options.sessionPath || null;
48
+
49
+ this._process = null;
50
+ this._readline = null;
51
+ this._ready = false;
52
+ this._closing = false;
53
+ // Accumulate text across streaming deltas for each turn
54
+ this._accumulatedText = '';
55
+ this._inMessage = false;
56
+ }
57
+
58
+ /**
59
+ * Spawn the Pi RPC process and wait for it to be ready.
60
+ * Resolves once the process is started and the readline interface is set up.
61
+ * @returns {Promise<void>}
62
+ */
63
+ async start() {
64
+ if (this._process) {
65
+ throw new Error('PiBridge already started');
66
+ }
67
+
68
+ const args = this._buildArgs();
69
+ const command = this.piCommand;
70
+ const spawnArgs = args;
71
+
72
+ logger.info(`[PiBridge] Starting Pi RPC: ${command} ${spawnArgs.join(' ')}`);
73
+
74
+ return new Promise((resolve, reject) => {
75
+ const proc = spawn(command, spawnArgs, {
76
+ cwd: this.cwd,
77
+ env: { ...process.env },
78
+ stdio: ['pipe', 'pipe', 'pipe']
79
+ });
80
+
81
+ this._process = proc;
82
+
83
+ // Handle spawn error (e.g., ENOENT)
84
+ proc.on('error', (err) => {
85
+ if (!this._ready) {
86
+ this._ready = false;
87
+ reject(new Error(`Failed to start Pi RPC: ${err.message}`));
88
+ } else {
89
+ logger.error(`[PiBridge] Process error: ${err.message}`);
90
+ this.emit('error', { error: err });
91
+ }
92
+ });
93
+
94
+ // Handle process exit
95
+ proc.on('close', (code, signal) => {
96
+ const wasReady = this._ready;
97
+ this._ready = false;
98
+ this._process = null;
99
+
100
+ if (!wasReady && !this._closing) {
101
+ reject(new Error(`Pi RPC exited before ready (code=${code}, signal=${signal})`));
102
+ }
103
+
104
+ if (!this._closing) {
105
+ logger.warn(`[PiBridge] Process exited unexpectedly (code=${code}, signal=${signal})`);
106
+ this.emit('error', { error: new Error(`Pi process exited (code=${code}, signal=${signal})`) });
107
+ } else {
108
+ logger.info(`[PiBridge] Process exited (code=${code}, signal=${signal})`);
109
+ }
110
+
111
+ this.emit('close');
112
+ });
113
+
114
+ // Collect stderr for diagnostics
115
+ proc.stderr.on('data', (data) => {
116
+ const text = data.toString().trim();
117
+ if (text) {
118
+ logger.debug(`[PiBridge] stderr: ${text}`);
119
+ }
120
+ });
121
+
122
+ // Set up line-by-line parsing of stdout
123
+ this._readline = createInterface({
124
+ input: proc.stdout,
125
+ crlfDelay: Infinity
126
+ });
127
+
128
+ this._readline.on('line', (line) => {
129
+ this._handleLine(line);
130
+ });
131
+
132
+ // Handle stdin errors (e.g., EPIPE if process dies)
133
+ proc.stdin.on('error', (err) => {
134
+ logger.error(`[PiBridge] stdin error: ${err.message}`);
135
+ });
136
+
137
+ // Pi RPC doesn't emit a specific "ready" event, so we consider it ready
138
+ // once the process is spawned and stdout is being read. We give it a
139
+ // small tick to detect immediate spawn failures.
140
+ setImmediate(() => {
141
+ if (this._process && !this._ready) {
142
+ this._ready = true;
143
+ logger.info(`[PiBridge] Ready (PID ${proc.pid})`);
144
+ this.emit('ready');
145
+ resolve();
146
+ }
147
+ });
148
+ });
149
+ }
150
+
151
+ /**
152
+ * Send a user message to the Pi RPC process.
153
+ * @param {string} content - The message text
154
+ * @returns {Promise<void>}
155
+ */
156
+ async sendMessage(content) {
157
+ if (!this.isReady()) {
158
+ throw new Error('PiBridge is not ready');
159
+ }
160
+
161
+ // Reset accumulated text for this new turn
162
+ this._accumulatedText = '';
163
+ this._inMessage = false;
164
+
165
+ const command = JSON.stringify({ type: 'prompt', message: content });
166
+ logger.debug(`[PiBridge] Sending prompt (${content.length} chars): ${content.substring(0, 100)}${content.length > 100 ? '...' : ''}`);
167
+ this._write(command);
168
+ logger.debug(`[PiBridge] Prompt written to stdin (${command.length} bytes)`);
169
+ }
170
+
171
+ /**
172
+ * Abort the current operation.
173
+ */
174
+ abort() {
175
+ if (!this.isReady()) return;
176
+ const command = JSON.stringify({ type: 'abort' });
177
+ logger.debug('[PiBridge] Sending abort');
178
+ this._write(command);
179
+ }
180
+
181
+ /**
182
+ * Gracefully shut down the Pi RPC process.
183
+ * @returns {Promise<void>}
184
+ */
185
+ async close() {
186
+ if (!this._process) return;
187
+
188
+ this._closing = true;
189
+ this.removeAllListeners();
190
+
191
+ // Try to abort any in-flight work first
192
+ try {
193
+ const command = JSON.stringify({ type: 'abort' });
194
+ this._write(command);
195
+ } catch {
196
+ // Process may already be dead
197
+ }
198
+
199
+ return new Promise((resolve) => {
200
+ const proc = this._process;
201
+ if (!proc) {
202
+ resolve();
203
+ return;
204
+ }
205
+
206
+ // Give the process a moment to exit gracefully, then force kill
207
+ const killTimeout = setTimeout(() => {
208
+ if (this._process) {
209
+ logger.warn('[PiBridge] Force killing process');
210
+ this._process.kill('SIGKILL');
211
+ }
212
+ }, 3000);
213
+
214
+ const onClose = () => {
215
+ clearTimeout(killTimeout);
216
+ resolve();
217
+ };
218
+
219
+ proc.once('close', onClose);
220
+
221
+ // Send SIGTERM
222
+ proc.kill('SIGTERM');
223
+ });
224
+ }
225
+
226
+ /**
227
+ * Check if the RPC process is alive and ready.
228
+ * @returns {boolean}
229
+ */
230
+ isReady() {
231
+ return this._ready && this._process !== null && !this._closing;
232
+ }
233
+
234
+ /**
235
+ * Check if the bridge is currently processing a message.
236
+ * @returns {boolean}
237
+ */
238
+ isBusy() {
239
+ return this._inMessage;
240
+ }
241
+
242
+ // ---------------------------------------------------------------------------
243
+ // Private methods
244
+ // ---------------------------------------------------------------------------
245
+
246
+ /**
247
+ * Build CLI arguments for the Pi RPC process.
248
+ * @returns {string[]}
249
+ */
250
+ _buildArgs() {
251
+ const args = ['--mode', 'rpc', '--tools', this.tools];
252
+
253
+ if (this.sessionPath) {
254
+ args.push('--session', this.sessionPath);
255
+ }
256
+
257
+ if (this.provider) {
258
+ args.push('--provider', this.provider);
259
+ }
260
+
261
+ if (this.model) {
262
+ args.push('--model', this.model);
263
+ }
264
+
265
+ if (this.systemPrompt) {
266
+ args.push('--append-system-prompt', this.systemPrompt);
267
+ }
268
+
269
+ for (const skill of this.skills) {
270
+ args.push('--skill', skill);
271
+ }
272
+
273
+ // Load extensions via -e (e.g., task extension for subagent delegation).
274
+ // --no-extensions prevents auto-discovery; only explicitly listed ones load.
275
+ if (this.extensions.length > 0) {
276
+ args.push('--no-extensions');
277
+ for (const ext of this.extensions) {
278
+ args.push('-e', ext);
279
+ }
280
+ }
281
+
282
+ return args;
283
+ }
284
+
285
+ /**
286
+ * Write a JSON command line to the process stdin.
287
+ * @param {string} jsonLine - The JSON string (without trailing newline)
288
+ */
289
+ _write(jsonLine) {
290
+ if (!this._process || !this._process.stdin.writable) {
291
+ throw new Error('Pi RPC process stdin is not writable');
292
+ }
293
+ this._process.stdin.write(jsonLine + '\n');
294
+ }
295
+
296
+ /**
297
+ * Handle a single JSONL line from Pi RPC stdout.
298
+ * @param {string} line - A single line of output
299
+ */
300
+ _handleLine(line) {
301
+ if (!line.trim()) return;
302
+
303
+ let event;
304
+ try {
305
+ event = JSON.parse(line);
306
+ } catch {
307
+ logger.debug(`[PiBridge] Ignoring unparseable line: ${line.substring(0, 100)}`);
308
+ return;
309
+ }
310
+
311
+ const type = event.type;
312
+
313
+ switch (type) {
314
+ case 'message_start':
315
+ this._inMessage = true;
316
+ break;
317
+
318
+ case 'message_update':
319
+ this._handleMessageUpdate(event);
320
+ break;
321
+
322
+ case 'message_end':
323
+ this._inMessage = false;
324
+ break;
325
+
326
+ case 'agent_end':
327
+ this._handleAgentEnd(event);
328
+ break;
329
+
330
+ case 'tool_execution_start':
331
+ this.emit('tool_use', {
332
+ toolCallId: event.toolCallId,
333
+ toolName: event.toolName,
334
+ args: event.args,
335
+ status: 'start'
336
+ });
337
+ break;
338
+
339
+ case 'tool_execution_update':
340
+ this.emit('tool_use', {
341
+ toolCallId: event.toolCallId,
342
+ toolName: event.toolName,
343
+ status: 'update',
344
+ partialResult: event.partialResult
345
+ });
346
+ break;
347
+
348
+ case 'tool_execution_end':
349
+ this.emit('tool_use', {
350
+ toolCallId: event.toolCallId,
351
+ toolName: event.toolName,
352
+ status: 'end',
353
+ result: event.result,
354
+ isError: event.isError || false
355
+ });
356
+ break;
357
+
358
+ case 'extension_ui_request':
359
+ this._handleExtensionUiRequest(event);
360
+ break;
361
+
362
+ case 'response':
363
+ // Response to a command (prompt, abort)
364
+ if (!event.success) {
365
+ logger.error(`[PiBridge] Command failed: ${event.error}`);
366
+ this.emit('error', { error: new Error(event.error || 'Unknown command error') });
367
+ } else {
368
+ logger.debug(`[PiBridge] Command acknowledged: ${JSON.stringify(event).substring(0, 200)}`);
369
+ }
370
+ break;
371
+
372
+ case 'agent_start':
373
+ case 'turn_start':
374
+ logger.debug(`[PiBridge] ${type}`);
375
+ this.emit('status', { status: 'working' });
376
+ break;
377
+ case 'turn_end':
378
+ logger.debug(`[PiBridge] ${type}`);
379
+ this.emit('status', { status: 'turn_complete' });
380
+ break;
381
+ case 'session':
382
+ logger.debug(`[PiBridge] ${type}: ${JSON.stringify(event).substring(0, 200)}`);
383
+ if (event.sessionFile) {
384
+ this.sessionPath = event.sessionFile;
385
+ }
386
+ this.emit('session', event);
387
+ break;
388
+
389
+ default:
390
+ logger.debug(`[PiBridge] Unhandled event type: ${type}`);
391
+ }
392
+ }
393
+
394
+ /**
395
+ * Handle a message_update event containing streaming content deltas.
396
+ * @param {Object} event - The message_update event
397
+ */
398
+ _handleMessageUpdate(event) {
399
+ const assistantEvent = event.assistantMessageEvent;
400
+ if (!assistantEvent) return;
401
+
402
+ switch (assistantEvent.type) {
403
+ case 'text_delta': {
404
+ const text = assistantEvent.delta || '';
405
+ if (text) {
406
+ this._accumulatedText += text;
407
+ this.emit('delta', { text });
408
+ }
409
+ break;
410
+ }
411
+ case 'text_start':
412
+ // When a new text block starts and we already have accumulated text
413
+ // from a previous block, inject paragraph separation so the markdown
414
+ // renderer doesn't smash the blocks together (e.g., "it.Done").
415
+ if (this._accumulatedText) {
416
+ this._accumulatedText += '\n\n';
417
+ this.emit('delta', { text: '\n\n' });
418
+ }
419
+ break;
420
+ case 'text_end':
421
+ // Boundary marker - no action needed
422
+ break;
423
+ case 'thinking_start':
424
+ case 'thinking_delta':
425
+ case 'thinking_end':
426
+ // Thinking events - could be extended later
427
+ break;
428
+ case 'toolcall_start':
429
+ case 'toolcall_delta':
430
+ case 'toolcall_end':
431
+ // Tool call deltas within message_update - the actual execution
432
+ // events (tool_execution_start/end) are more useful for our purposes
433
+ break;
434
+ case 'done':
435
+ // Message streaming is complete
436
+ break;
437
+ case 'error': {
438
+ const errorMsg = assistantEvent.error || assistantEvent.delta || 'Unknown streaming error';
439
+ logger.error(`[PiBridge] Streaming error: ${errorMsg}`);
440
+ this.emit('error', { error: new Error(errorMsg) });
441
+ break;
442
+ }
443
+ default:
444
+ logger.debug(`[PiBridge] Unhandled assistantMessageEvent type: ${assistantEvent.type}`);
445
+ }
446
+ }
447
+
448
+ /**
449
+ * Handle agent_end event which signals the completion of the agent's work.
450
+ * Emits 'complete' with the full accumulated text.
451
+ * @param {Object} _event - The agent_end event
452
+ */
453
+ _handleAgentEnd(_event) {
454
+ const fullText = this._accumulatedText;
455
+ this._accumulatedText = '';
456
+ this._inMessage = false;
457
+
458
+ logger.debug(`[PiBridge] Agent ended, accumulated ${fullText.length} chars`);
459
+ this.emit('complete', { fullText });
460
+ }
461
+
462
+ /**
463
+ * Handle extension_ui_request events.
464
+ * Dialog methods (select, confirm, input, editor) are auto-cancelled.
465
+ * Fire-and-forget methods are ignored.
466
+ * @param {Object} event - The extension_ui_request event
467
+ */
468
+ _handleExtensionUiRequest(event) {
469
+ const method = event.method;
470
+ const id = event.id;
471
+
472
+ if (DIALOG_METHODS.has(method) && id) {
473
+ logger.debug(`[PiBridge] Auto-cancelling dialog: ${method} (${id})`);
474
+ // Respond with cancellation
475
+ const response = JSON.stringify({
476
+ type: 'extension_ui_response',
477
+ id,
478
+ cancelled: true
479
+ });
480
+ try {
481
+ this._write(response);
482
+ } catch {
483
+ // Process may be dead
484
+ }
485
+ } else {
486
+ logger.debug(`[PiBridge] Ignoring extension_ui_request: ${method}`);
487
+ }
488
+ }
489
+ }
490
+
491
+ module.exports = PiBridge;