@in-the-loop-labs/pair-review 1.6.2 → 2.0.1

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 (63) 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 +1962 -114
  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 +2955 -0
  12. package/public/js/components/CouncilProgressModal.js +12 -16
  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/index.js +8 -0
  17. package/public/js/local.js +17 -615
  18. package/public/js/modules/analysis-history.js +19 -68
  19. package/public/js/modules/comment-manager.js +103 -20
  20. package/public/js/modules/diff-context.js +176 -0
  21. package/public/js/modules/diff-renderer.js +30 -0
  22. package/public/js/modules/file-comment-manager.js +126 -105
  23. package/public/js/modules/file-list-merger.js +64 -0
  24. package/public/js/modules/panel-resizer.js +25 -6
  25. package/public/js/modules/suggestion-manager.js +40 -125
  26. package/public/js/pr.js +1009 -159
  27. package/public/js/repo-settings.js +36 -6
  28. package/public/js/utils/category-emoji.js +44 -0
  29. package/public/js/utils/time.js +32 -0
  30. package/public/local.html +107 -70
  31. package/public/pr.html +107 -70
  32. package/public/repo-settings.html +32 -0
  33. package/src/ai/analyzer.js +5 -1
  34. package/src/ai/copilot-provider.js +39 -9
  35. package/src/ai/cursor-agent-provider.js +45 -11
  36. package/src/ai/gemini-provider.js +17 -4
  37. package/src/ai/prompts/config.js +7 -1
  38. package/src/ai/provider-availability.js +1 -1
  39. package/src/ai/provider.js +25 -37
  40. package/src/chat/CONVENTIONS.md +18 -0
  41. package/src/chat/pi-bridge.js +491 -0
  42. package/src/chat/prompt-builder.js +272 -0
  43. package/src/chat/session-manager.js +619 -0
  44. package/src/config.js +14 -0
  45. package/src/database.js +322 -15
  46. package/src/main.js +4 -17
  47. package/src/routes/analyses.js +721 -0
  48. package/src/routes/chat.js +655 -0
  49. package/src/routes/config.js +29 -8
  50. package/src/routes/context-files.js +274 -0
  51. package/src/routes/local.js +225 -1133
  52. package/src/routes/mcp.js +39 -30
  53. package/src/routes/pr.js +424 -58
  54. package/src/routes/reviews.js +1035 -0
  55. package/src/routes/shared.js +4 -29
  56. package/src/server.js +34 -12
  57. package/src/sse/review-events.js +46 -0
  58. package/src/utils/auto-context.js +88 -0
  59. package/src/utils/category-emoji.js +33 -0
  60. package/src/utils/diff-annotator.js +75 -1
  61. package/src/utils/diff-file-list.js +57 -0
  62. package/src/routes/analysis.js +0 -1600
  63. package/src/routes/comments.js +0 -534
@@ -2,7 +2,7 @@
2
2
  /**
3
3
  * Provider Availability Module
4
4
  *
5
- * Manages background checking of AI provider availability at server startup.
5
+ * Manages checking of AI provider availability at server startup.
6
6
  * Caches results and exposes them for the /api/providers endpoint.
7
7
  */
8
8
 
@@ -10,6 +10,7 @@ const path = require('path');
10
10
  const { spawn } = require('child_process');
11
11
  const logger = require('../utils/logger');
12
12
  const { extractJSON } = require('../utils/json-extractor');
13
+ const { TIERS, TIER_ALIASES } = require('./prompts/config');
13
14
 
14
15
  // Directory containing bin scripts (git-diff-lines, etc.)
15
16
  const BIN_DIR = path.join(__dirname, '..', '..', 'bin');
@@ -345,23 +346,10 @@ function prettifyModelId(id) {
345
346
  .replace(/\b\w/g, c => c.toUpperCase()); // Capitalize each word
346
347
  }
347
348
 
348
- /**
349
- * Canonical model tiers
350
- */
351
- const CANONICAL_TIERS = ['fast', 'balanced', 'thorough'];
352
-
353
- /**
354
- * Tier aliases that map to canonical tiers
355
- */
356
- const TIER_ALIASES = {
357
- free: 'fast',
358
- premium: 'thorough'
359
- };
360
-
361
349
  /**
362
350
  * All valid tier values (canonical + aliases)
363
351
  */
364
- const VALID_TIERS = [...CANONICAL_TIERS, ...Object.keys(TIER_ALIASES)];
352
+ const VALID_TIERS = [...TIERS, ...Object.keys(TIER_ALIASES)];
365
353
 
366
354
  /**
367
355
  * Normalize a tier to its canonical form
@@ -603,27 +591,6 @@ function createProvider(providerId, model = null) {
603
591
  return new ProviderClass(actualModel, { ...(overrides || {}), yolo: yoloMode });
604
592
  }
605
593
 
606
- /**
607
- * Get tier for a specific model from a provider
608
- * Queries the provider's model definitions (or config overrides) to find the tier
609
- * @param {string} providerId - Provider ID (e.g., 'claude', 'gemini')
610
- * @param {string} modelId - Model ID (e.g., 'sonnet', 'gemini-2.5-pro')
611
- * @returns {string|null} Tier name or null if provider or model not found
612
- */
613
- function getTierForModel(providerId, modelId) {
614
- const ProviderClass = providerRegistry.get(providerId);
615
- if (!ProviderClass) {
616
- return null;
617
- }
618
-
619
- // Merge config models with built-in models
620
- const overrides = providerConfigOverrides.get(providerId);
621
- const models = mergeModels(ProviderClass.getModels(), overrides?.models);
622
-
623
- const model = models.find(m => m.id === modelId);
624
- return model?.tier || null;
625
- }
626
-
627
594
  /**
628
595
  * Test availability of a provider with timeout
629
596
  * @param {string} providerId - Provider ID
@@ -656,6 +623,27 @@ async function testProviderAvailability(providerId, timeout = 10000) {
656
623
  }
657
624
  }
658
625
 
626
+ /**
627
+ * Get tier for a specific model from a provider
628
+ * Queries the provider's model definitions (or config overrides) to find the tier
629
+ * @param {string} providerId - Provider ID (e.g., 'claude', 'gemini')
630
+ * @param {string} modelId - Model ID (e.g., 'sonnet', 'gemini-2.5-pro')
631
+ * @returns {string|null} Tier name or null if provider or model not found
632
+ */
633
+ function getTierForModel(providerId, modelId) {
634
+ const ProviderClass = providerRegistry.get(providerId);
635
+ if (!ProviderClass) {
636
+ return null;
637
+ }
638
+
639
+ // Merge config models with built-in models
640
+ const overrides = providerConfigOverrides.get(providerId);
641
+ const models = mergeModels(ProviderClass.getModels(), overrides?.models);
642
+
643
+ const model = models.find(m => m.id === modelId);
644
+ return model?.tier || null;
645
+ }
646
+
659
647
  module.exports = {
660
648
  AIProvider,
661
649
  MODEL_TIERS,
@@ -665,12 +653,12 @@ module.exports = {
665
653
  getRegisteredProviderIds,
666
654
  getAllProvidersInfo,
667
655
  createProvider,
668
- getTierForModel,
669
656
  testProviderAvailability,
670
657
  // Config override support
671
658
  applyConfigOverrides,
672
659
  getProviderConfigOverrides,
673
660
  inferModelDefaults,
674
661
  resolveDefaultModel,
675
- prettifyModelId
662
+ prettifyModelId,
663
+ getTierForModel
676
664
  };
@@ -0,0 +1,18 @@
1
+ # Chat Module Conventions
2
+
3
+ ## Action handler pattern (ChatPanel.js)
4
+
5
+ All action handlers (adopt, update, dismiss) follow identical structure:
6
+ 1. Guard: if streaming or no `_contextItemId`, return early
7
+ 2. Set `_pendingActionContext` with `{ type, itemId }`
8
+ 3. Set `inputEl.value` to clean, human-readable text (NO item IDs)
9
+ 4. Call `sendMessage()`
10
+
11
+ See `_handleAdoptClick` as the canonical example. New action handlers must follow this pattern.
12
+
13
+ ## Action metadata separation
14
+
15
+ Item IDs never appear in user-visible message text. They flow through:
16
+ `_pendingActionContext` (frontend) → `actionContext` (API payload) → `[Action: ...]` hint (agent-facing message in session-manager.js)
17
+
18
+ This keeps the chat transcript clean while giving the agent structured metadata it can parse reliably.
@@ -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;