@in-the-loop-labs/pair-review 1.3.2 → 1.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.
@@ -0,0 +1,859 @@
1
+ // SPDX-License-Identifier: GPL-3.0-or-later
2
+ /**
3
+ * Pi AI Provider
4
+ *
5
+ * Implements the AI provider interface for the Pi coding agent CLI.
6
+ * Uses `pi -p --mode json` for non-interactive execution with structured output.
7
+ *
8
+ * Pi outputs JSONL with event types: session, turn_start, message_start,
9
+ * message_update, message_end, tool_execution_start/update/end, etc.
10
+ * Text content is extracted from message_end events which contain the
11
+ * complete assistant message with content blocks.
12
+ *
13
+ * Pi provides built-in analysis modes (default, multi-model) and supports
14
+ * additional models via config.providers.pi.models in ~/.pair-review/config.json.
15
+ * User-configured models can use `provider/model` format (e.g., 'google/gemini-2.5-flash')
16
+ * for cross-provider switching, which translates to `--provider <provider> --model <model>`.
17
+ */
18
+
19
+ const path = require('path');
20
+ const { spawn } = require('child_process');
21
+ const { AIProvider, registerProvider } = require('./provider');
22
+ const logger = require('../utils/logger');
23
+ const { extractJSON } = require('../utils/json-extractor');
24
+ const { CancellationError, isAnalysisCancelled } = require('../routes/shared');
25
+ const { StreamParser, parsePiLine, createPiLineParser } = require('./stream-parser');
26
+
27
+ // Directory containing bin scripts (git-diff-lines, etc.)
28
+ const BIN_DIR = path.join(__dirname, '..', '..', 'bin');
29
+
30
+ // Path to the bundled Pi task extension, which provides a generic subagent tool
31
+ // for delegating work to isolated pi subprocesses during analysis
32
+ const TASK_EXTENSION_DIR = path.join(__dirname, '..', '..', '.pi', 'extensions', 'task');
33
+
34
+ // Path to the review model guidance skill, which teaches Pi to select
35
+ // appropriate models for different review tasks (bug finding, security, etc.)
36
+ const REVIEW_SKILL_PATH = path.join(__dirname, '..', '..', '.pi', 'skills', 'review-model-guidance', 'SKILL.md');
37
+
38
+ // Path to the review roulette skill, which runs three random premium models
39
+ // in parallel for diverse multi-perspective code review
40
+ const ROULETTE_SKILL_PATH = path.join(__dirname, '..', '..', '.pi', 'skills', 'review-roulette', 'SKILL.md');
41
+
42
+ /**
43
+ * Pi model definitions
44
+ *
45
+ * Pi delegates model selection to the user's Pi configuration (~/.pi/).
46
+ * These entries define analysis modes rather than specific models:
47
+ * - 'default' uses whatever model the user has configured as their Pi default
48
+ * - 'multi-model' loads the review guidance skill, teaching Pi to autonomously
49
+ * switch between models for different review tasks
50
+ * - 'review-roulette' loads the roulette skill, running three random premium
51
+ * models in parallel for diverse multi-perspective review
52
+ *
53
+ * Users can also add specific models via config.json providers.pi.models.
54
+ * Use `provider/model` format in cli_model for cross-provider switching
55
+ * (e.g., 'google/gemini-2.5-flash' becomes --provider google --model gemini-2.5-flash).
56
+ */
57
+ const PI_MODELS = [
58
+ {
59
+ id: 'default',
60
+ cli_model: null,
61
+ name: 'Default',
62
+ tier: 'balanced',
63
+ tagline: 'Your Pi Default',
64
+ description: 'Uses your configured Pi default model',
65
+ badge: 'Default',
66
+ badgeClass: 'badge-recommended',
67
+ default: true
68
+ },
69
+ {
70
+ id: 'multi-model',
71
+ cli_model: null,
72
+ name: 'Multi-Model',
73
+ tier: 'thorough',
74
+ tagline: 'Smart Routing',
75
+ description: 'Pi autonomously selects the best model for each review task',
76
+ badge: 'Smart Routing',
77
+ badgeClass: 'badge-power',
78
+ extra_args: ['--thinking', 'high', '--skill', REVIEW_SKILL_PATH]
79
+ },
80
+ {
81
+ id: 'review-roulette',
82
+ cli_model: null,
83
+ name: 'Review Roulette',
84
+ tier: 'thorough',
85
+ tagline: '3× Reasoning',
86
+ description: 'Three random premium models review your changes in parallel',
87
+ badge: 'Surprise',
88
+ badgeClass: 'badge-power',
89
+ extra_args: ['--thinking', 'high', '--skill', ROULETTE_SKILL_PATH],
90
+ env: { PI_TASK_MAX_DEPTH: '2' }
91
+ }
92
+ ];
93
+
94
+ /**
95
+ * Extract text from assistant content, handling both array-of-blocks and
96
+ * string content. Uses a Set for dedup to avoid incorrect substring matching.
97
+ *
98
+ * @param {Array|string} content - Content from an assistant message
99
+ * @param {Set<string>} seenTexts - Set tracking already-seen text blocks
100
+ * @returns {string} Extracted text (may be empty if all blocks were duplicates)
101
+ */
102
+ function extractAssistantText(content, seenTexts) {
103
+ let text = '';
104
+ if (Array.isArray(content)) {
105
+ for (const block of content) {
106
+ if (block.type === 'text' && block.text) {
107
+ if (!seenTexts.has(block.text)) {
108
+ seenTexts.add(block.text);
109
+ text += block.text;
110
+ }
111
+ }
112
+ }
113
+ } else if (typeof content === 'string') {
114
+ if (!seenTexts.has(content)) {
115
+ seenTexts.add(content);
116
+ text += content;
117
+ }
118
+ }
119
+ return text;
120
+ }
121
+
122
+ class PiProvider extends AIProvider {
123
+ /**
124
+ * @param {string|null} [model='default'] - Model identifier or null/undefined for default mode
125
+ * @param {Object} configOverrides - Config overrides from providers config
126
+ * @param {string} configOverrides.command - Custom CLI command
127
+ * @param {string[]} configOverrides.extra_args - Additional CLI arguments
128
+ * @param {Object} configOverrides.env - Additional environment variables
129
+ * @param {Object[]} configOverrides.models - Custom model definitions
130
+ */
131
+ constructor(model, configOverrides = {}) {
132
+ super(model || 'default');
133
+
134
+ // Store config overrides early so _resolveCliModelArgs can use them
135
+ this.configOverrides = configOverrides;
136
+
137
+ // Resolve model configuration from built-in definitions and config overrides
138
+ const resolvedModel = model || 'default';
139
+ const builtIn = PI_MODELS.find(m => m.id === resolvedModel);
140
+ const configModel = configOverrides.models?.find(m => m.id === resolvedModel);
141
+
142
+ // Conditionally include --model (null = suppress, let Pi use its default)
143
+ const cliModelArgs = this._resolveCliModelArgs(resolvedModel);
144
+
145
+ // Command precedence: ENV > config > default
146
+ const envCmd = process.env.PAIR_REVIEW_PI_CMD;
147
+ const configCmd = configOverrides.command;
148
+ const piCmd = envCmd || configCmd || 'pi';
149
+
150
+ // For multi-word commands, use shell mode
151
+ this.useShell = piCmd.includes(' ');
152
+
153
+ // ============================================================================
154
+ // SECURITY: Pi CLI tool permissions
155
+ // ============================================================================
156
+ //
157
+ // Pi's --tools flag controls which built-in tools are available to the model.
158
+ // When --tools is specified, ONLY the listed tools are loaded; unlisted tools
159
+ // (edit, write) are not available at all — they cannot be requested or executed.
160
+ //
161
+ // Enabled tools: read, bash, grep, find, ls
162
+ // Excluded tools: edit, write (file modification)
163
+ //
164
+ // Task extension: The `task` tool is loaded via `-e` as a Pi extension,
165
+ // not via --tools. Subtasks spawned by the extension inherit the same
166
+ // tool restrictions from the parent process environment.
167
+ //
168
+ // LIMITATION: The `bash` tool grants arbitrary shell command execution.
169
+ // Unlike Claude (Bash(git diff*) prefixes) or Copilot (shell(git diff) prefixes),
170
+ // Pi does not support fine-grained bash command restrictions. The model could
171
+ // theoretically execute destructive commands (rm, git push, etc.).
172
+ //
173
+ // MITIGATION STRATEGY:
174
+ // 1. Prompt engineering: Analysis prompts explicitly instruct the AI to only
175
+ // use read-only operations and never modify files
176
+ // 2. Worktree isolation: Analysis runs in a git worktree, limiting blast radius
177
+ // 3. Tool exclusion: edit and write tools are not loaded at all
178
+ //
179
+ // If Pi CLI adds prefix-based bash restrictions in the future, they should
180
+ // be adopted here to match the granularity of other providers.
181
+ // ============================================================================
182
+
183
+ // pi -p --mode json --model <model> --tools read,bash,grep,find,ls <prompt-via-stdin>
184
+ // -p: Non-interactive mode (process prompt and exit)
185
+ // --mode json: Output JSONL events
186
+ // --model: Specify the model (omitted when cli_model is null to use Pi's default)
187
+ // --tools: Enable read-only tools for Level 2/3 analysis (excludes edit,write for safety).
188
+ // The task extension is loaded separately via `-e` (not part of --tools).
189
+ // --no-session: Each pi invocation is an ephemeral analysis — there's no need to
190
+ // persist session state between runs. Set PAIR_REVIEW_PI_SESSION=1
191
+ // to enable session saving for debugging (sessions saved to ~/.pi/sessions/).
192
+ // --no-skills: Skills are disabled by default to keep runs deterministic. A skill can
193
+ // still be loaded via `--skill` in model-specific `extra_args` if needed.
194
+
195
+ // Build args: base args + built-in extra_args + provider extra_args + model extra_args
196
+ // In yolo mode, omit --tools entirely to allow all tools (including edit, write)
197
+ // The task extension is loaded to give the model a subagent tool for delegating
198
+ // work to isolated subprocesses, preserving the main context window.
199
+ // --no-extensions prevents auto-discovery of other extensions.
200
+ // --no-skills and --no-prompt-templates keep the subprocess focused.
201
+ const sessionArgs = process.env.PAIR_REVIEW_PI_SESSION ? [] : ['--no-session'];
202
+ let baseArgs;
203
+ if (configOverrides.yolo) {
204
+ baseArgs = ['-p', '--mode', 'json', ...cliModelArgs, ...sessionArgs,
205
+ '--no-extensions', '--no-skills', '--no-prompt-templates',
206
+ '-e', TASK_EXTENSION_DIR];
207
+ } else {
208
+ baseArgs = ['-p', '--mode', 'json', ...cliModelArgs, '--tools', 'read,bash,grep,find,ls', ...sessionArgs,
209
+ '--no-extensions', '--no-skills', '--no-prompt-templates',
210
+ '-e', TASK_EXTENSION_DIR];
211
+ }
212
+ const builtInArgs = builtIn?.extra_args || [];
213
+ const providerArgs = configOverrides.extra_args || [];
214
+ const modelArgs = configModel?.extra_args || [];
215
+
216
+ // PI_CMD tells the task extension how to invoke pi for subtasks.
217
+ // This is essential when pi is invoked through a wrapper (e.g., 'devx pi --').
218
+ // Merge env: defaults → built-in model → provider config → per-model config.
219
+ // Later entries override earlier ones, so model-specific settings (e.g.,
220
+ // review-roulette's PI_TASK_MAX_DEPTH=2) take precedence over defaults.
221
+ this.extraEnv = {
222
+ PI_TASK_MAX_DEPTH: '1',
223
+ ...(builtIn?.env || {}),
224
+ ...(configOverrides.env || {}),
225
+ ...(configModel?.env || {}),
226
+ PI_CMD: piCmd,
227
+ };
228
+
229
+ // Store base command and args (prompt added in execute)
230
+ this.piCmd = piCmd;
231
+ this.baseArgs = [...baseArgs, ...builtInArgs, ...providerArgs, ...modelArgs];
232
+
233
+ // configOverrides already stored at top of constructor for _resolveCliModelArgs
234
+ }
235
+
236
+ /**
237
+ * Execute Pi CLI with a prompt
238
+ * @param {string} prompt - The prompt to send to Pi
239
+ * @param {Object} options - Optional configuration
240
+ * @returns {Promise<Object>} Parsed response or error
241
+ */
242
+ async execute(prompt, options = {}) {
243
+ return new Promise((resolve, reject) => {
244
+ const { cwd = process.cwd(), timeout = 300000, level = 'unknown', analysisId, registerProcess, onStreamEvent } = options;
245
+
246
+ const levelPrefix = `[Level ${level}]`;
247
+ logger.info(`${levelPrefix} Executing Pi CLI...`);
248
+ logger.info(`${levelPrefix} Writing prompt via stdin: ${prompt.length} bytes`);
249
+
250
+ // Use stdin for prompt instead of CLI argument (avoids shell escaping issues)
251
+ // Pi reads from stdin when using -p with no positional message arguments
252
+ let fullCommand;
253
+ let fullArgs;
254
+
255
+ if (this.useShell) {
256
+ fullCommand = `${this.piCmd} ${this.baseArgs.join(' ')}`;
257
+ fullArgs = [];
258
+ } else {
259
+ fullCommand = this.piCmd;
260
+ fullArgs = [...this.baseArgs];
261
+ }
262
+
263
+ const pi = spawn(fullCommand, fullArgs, {
264
+ cwd,
265
+ env: {
266
+ ...process.env,
267
+ ...this.extraEnv,
268
+ PATH: `${BIN_DIR}:${process.env.PATH}`
269
+ },
270
+ shell: this.useShell
271
+ });
272
+
273
+ const pid = pi.pid;
274
+ logger.debug(`${levelPrefix} Pi CLI command: ${fullCommand} ${fullArgs.join(' ')}`);
275
+ logger.info(`${levelPrefix} Spawned Pi CLI process: PID ${pid}`);
276
+
277
+ // Register process for cancellation tracking if analysisId provided
278
+ if (analysisId && registerProcess) {
279
+ registerProcess(analysisId, pi);
280
+ logger.info(`${levelPrefix} Registered process ${pid} for analysis ${analysisId}`);
281
+ }
282
+
283
+ let stdout = '';
284
+ let stderr = '';
285
+ let timeoutId = null;
286
+ let settled = false; // Guard against multiple resolve/reject calls
287
+ let lineBuffer = ''; // Buffer for incomplete JSONL lines
288
+ let lineCount = 0; // Count of JSONL lines received
289
+
290
+ const settle = (fn, value) => {
291
+ if (settled) return;
292
+ settled = true;
293
+ if (timeoutId) clearTimeout(timeoutId);
294
+ fn(value);
295
+ };
296
+
297
+ // Set up side-channel stream parser for live progress events.
298
+ // Use the buffered Pi line parser to accumulate text_delta fragments
299
+ // before emitting, preventing the UI from being flooded with tiny updates.
300
+ const streamParser = onStreamEvent
301
+ ? new StreamParser(createPiLineParser(), onStreamEvent, { cwd })
302
+ : null;
303
+
304
+ // Set timeout
305
+ if (timeout) {
306
+ timeoutId = setTimeout(() => {
307
+ logger.error(`${levelPrefix} Process ${pid} timed out after ${timeout}ms`);
308
+ pi.kill('SIGTERM');
309
+ settle(reject, new Error(`${levelPrefix} Pi CLI timed out after ${timeout}ms`));
310
+ }, timeout);
311
+ }
312
+
313
+ // Stream and log JSONL lines as they arrive for debugging visibility
314
+ pi.stdout.on('data', (data) => {
315
+ const chunk = data.toString();
316
+ stdout += chunk;
317
+
318
+ // Feed side-channel stream parser for live progress events
319
+ if (streamParser) {
320
+ streamParser.feed(chunk);
321
+ }
322
+
323
+ lineBuffer += chunk;
324
+
325
+ // Process complete lines (JSONL - each line is a complete JSON object)
326
+ const lines = lineBuffer.split('\n');
327
+ // Keep the last incomplete line in the buffer
328
+ lineBuffer = lines.pop() || '';
329
+
330
+ for (const line of lines) {
331
+ if (!line.trim()) continue;
332
+ lineCount++;
333
+ this.logStreamLine(line, lineCount, levelPrefix);
334
+ }
335
+ });
336
+
337
+ // Collect stderr
338
+ pi.stderr.on('data', (data) => {
339
+ stderr += data.toString();
340
+ });
341
+
342
+ // Handle completion
343
+ pi.on('close', (code) => {
344
+ if (settled) return; // Already settled by timeout or error
345
+
346
+ // Flush any remaining stream parser buffer
347
+ if (streamParser) {
348
+ streamParser.flush();
349
+ }
350
+
351
+ // Check for cancellation signals (SIGTERM=143, SIGKILL=137)
352
+ const isCancellationCode = code === 143 || code === 137;
353
+ if (isCancellationCode && analysisId && isAnalysisCancelled(analysisId)) {
354
+ logger.info(`${levelPrefix} Pi CLI terminated due to analysis cancellation (exit code ${code})`);
355
+ settle(reject, new CancellationError(`${levelPrefix} Analysis cancelled by user`));
356
+ return;
357
+ }
358
+
359
+ // Also check for cancellation even with exit code 0 (Pi CLI may handle
360
+ // SIGTERM gracefully and exit cleanly rather than with code 143)
361
+ if (analysisId && isAnalysisCancelled(analysisId)) {
362
+ logger.info(`${levelPrefix} Pi CLI exited with code ${code} but analysis was cancelled`);
363
+ settle(reject, new CancellationError(`${levelPrefix} Analysis cancelled by user`));
364
+ return;
365
+ }
366
+
367
+ // Always log stderr if present
368
+ if (stderr.trim()) {
369
+ if (code !== 0) {
370
+ logger.error(`${levelPrefix} Pi CLI stderr (exit code ${code}): ${stderr}`);
371
+ } else {
372
+ logger.warn(`${levelPrefix} Pi CLI stderr (success): ${stderr}`);
373
+ }
374
+ }
375
+
376
+ if (code !== 0) {
377
+ logger.error(`${levelPrefix} Pi CLI exited with code ${code}`);
378
+ settle(reject, new Error(`${levelPrefix} Pi CLI exited with code ${code}: ${stderr}`));
379
+ return;
380
+ }
381
+
382
+ // Process any remaining buffered line
383
+ if (lineBuffer.trim()) {
384
+ lineCount++;
385
+ this.logStreamLine(lineBuffer, lineCount, levelPrefix);
386
+ }
387
+
388
+ logger.info(`${levelPrefix} Pi CLI completed - received ${lineCount} JSONL events`);
389
+
390
+ // Parse the Pi JSONL response
391
+ const parsed = this.parsePiResponse(stdout, level);
392
+ if (parsed.success) {
393
+ logger.success(`${levelPrefix} Successfully parsed JSON response`);
394
+
395
+ // Log a summary of the response
396
+ if (parsed.data?.suggestions) {
397
+ const count = Array.isArray(parsed.data.suggestions) ? parsed.data.suggestions.length : 0;
398
+ logger.info(`${levelPrefix} [response] ${count} suggestions extracted`);
399
+ } else if (parsed.data) {
400
+ const jsonStr = JSON.stringify(parsed.data);
401
+ logger.info(`${levelPrefix} [response] ${jsonStr.length} chars of JSON data`);
402
+ }
403
+
404
+ settle(resolve, parsed.data);
405
+ } else {
406
+ // Regex extraction failed, try LLM-based extraction as fallback
407
+ logger.warn(`${levelPrefix} Regex extraction failed: ${parsed.error}`);
408
+ // Pass extracted text content to LLM fallback (not raw JSONL stdout).
409
+ // The text content is the actual LLM response text extracted from JSONL
410
+ // events and is much smaller and more relevant than the full JSONL stream.
411
+ const llmFallbackInput = parsed.textContent || stdout;
412
+ logger.info(`${levelPrefix} LLM fallback input length: ${llmFallbackInput.length} characters (${parsed.textContent ? 'text content' : 'raw stdout'})`);
413
+ logger.info(`${levelPrefix} Attempting LLM-based JSON extraction fallback...`);
414
+
415
+ // Use async IIFE to handle the async LLM extraction
416
+ (async () => {
417
+ // Guard: if already settled (by timeout, stdin error, or cancellation),
418
+ // skip the LLM extraction entirely to avoid misleading log output
419
+ if (settled) return;
420
+
421
+ try {
422
+ const llmExtracted = await this.extractJSONWithLLM(llmFallbackInput, { level, analysisId, registerProcess });
423
+ if (llmExtracted.success) {
424
+ logger.success(`${levelPrefix} LLM extraction fallback succeeded`);
425
+ settle(resolve, llmExtracted.data);
426
+ } else {
427
+ logger.warn(`${levelPrefix} LLM extraction fallback also failed: ${llmExtracted.error}`);
428
+ logger.info(`${levelPrefix} Raw response preview: ${llmFallbackInput.substring(0, 500)}...`);
429
+ settle(resolve, { raw: llmFallbackInput, parsed: false });
430
+ }
431
+ } catch (llmError) {
432
+ logger.warn(`${levelPrefix} LLM extraction fallback error: ${llmError.message}`);
433
+ settle(resolve, { raw: llmFallbackInput, parsed: false });
434
+ }
435
+ })();
436
+ }
437
+ });
438
+
439
+ // Handle errors
440
+ pi.on('error', (error) => {
441
+ if (error.code === 'ENOENT') {
442
+ logger.error(`${levelPrefix} Pi CLI not found. Please ensure Pi CLI is installed.`);
443
+ settle(reject, new Error(`${levelPrefix} Pi CLI not found. ${PiProvider.getInstallInstructions()}`));
444
+ } else {
445
+ logger.error(`${levelPrefix} Pi process error: ${error}`);
446
+ settle(reject, error);
447
+ }
448
+ });
449
+
450
+ // Handle stdin errors (e.g., EPIPE if process exits before write completes)
451
+ pi.stdin.on('error', (err) => {
452
+ logger.error(`${levelPrefix} stdin error: ${err.message}`);
453
+ });
454
+
455
+ // Send the prompt to stdin (Pi reads from stdin when using -p with no args)
456
+ pi.stdin.write(prompt, (err) => {
457
+ if (err) {
458
+ logger.error(`${levelPrefix} Failed to write prompt to stdin: ${err}`);
459
+ pi.kill('SIGTERM');
460
+ settle(reject, new Error(`${levelPrefix} Failed to write prompt to stdin: ${err}`));
461
+ }
462
+ });
463
+ pi.stdin.end();
464
+ });
465
+ }
466
+
467
+ /**
468
+ * Log a streaming JSONL line for debugging visibility
469
+ * Extracts meaningful info from each event type without being too verbose
470
+ *
471
+ * Uses logger.streamDebug() which only logs when --debug-stream flag is enabled.
472
+ *
473
+ * @param {string} line - A single JSONL line
474
+ * @param {number} lineNum - Line number for reference
475
+ * @param {string} levelPrefix - Level prefix for log messages
476
+ */
477
+ logStreamLine(line, lineNum, levelPrefix) {
478
+ // Early exit if stream debugging is disabled
479
+ if (!logger.isStreamDebugEnabled()) return;
480
+
481
+ try {
482
+ const event = JSON.parse(line);
483
+ const type = event.type || 'unknown';
484
+
485
+ // Log different event types with appropriate detail
486
+ switch (type) {
487
+ case 'session':
488
+ logger.streamDebug(`${levelPrefix} [#${lineNum}] Session started: ${event.id || 'unknown'}`);
489
+ break;
490
+
491
+ case 'turn_start':
492
+ logger.streamDebug(`${levelPrefix} [#${lineNum}] Turn started`);
493
+ break;
494
+
495
+ case 'turn_end': {
496
+ const msg = event.message;
497
+ if (msg?.role) {
498
+ logger.streamDebug(`${levelPrefix} [#${lineNum}] Turn ended (${msg.role})`);
499
+ } else {
500
+ logger.streamDebug(`${levelPrefix} [#${lineNum}] Turn ended`);
501
+ }
502
+ break;
503
+ }
504
+
505
+ case 'message_start': {
506
+ const msg = event.message;
507
+ const role = msg?.role || 'unknown';
508
+ logger.streamDebug(`${levelPrefix} [#${lineNum}] Message started (${role})`);
509
+ break;
510
+ }
511
+
512
+ case 'message_update': {
513
+ const assistantEvent = event.assistantMessageEvent;
514
+ if (assistantEvent?.type === 'text_delta' && assistantEvent?.delta) {
515
+ const preview = assistantEvent.delta.length > 60
516
+ ? assistantEvent.delta.substring(0, 60) + '...'
517
+ : assistantEvent.delta;
518
+ logger.streamDebug(`${levelPrefix} [#${lineNum}] text_delta: ${preview.replace(/\n/g, '\\n')}`);
519
+ } else if (assistantEvent?.type) {
520
+ logger.streamDebug(`${levelPrefix} [#${lineNum}] message_update: ${assistantEvent.type}`);
521
+ } else {
522
+ logger.streamDebug(`${levelPrefix} [#${lineNum}] message_update`);
523
+ }
524
+ break;
525
+ }
526
+
527
+ case 'message_end': {
528
+ const msg = event.message;
529
+ const role = msg?.role || 'unknown';
530
+ logger.streamDebug(`${levelPrefix} [#${lineNum}] Message ended (${role})`);
531
+ break;
532
+ }
533
+
534
+ case 'tool_execution_start': {
535
+ const toolName = event.toolName || 'unknown';
536
+ const toolId = event.toolCallId || '';
537
+ const idPart = toolId ? ` [${toolId.substring(0, 8)}]` : '';
538
+
539
+ let inputPreview = '';
540
+ const args = event.args;
541
+ if (args) {
542
+ if (typeof args === 'string') {
543
+ inputPreview = args.length > 60 ? args.substring(0, 60) + '...' : args;
544
+ } else if (typeof args === 'object') {
545
+ if (args.command) {
546
+ inputPreview = `cmd="${args.command.substring(0, 50)}${args.command.length > 50 ? '...' : ''}"`;
547
+ } else if (args.file_path || args.path) {
548
+ inputPreview = `path="${args.file_path || args.path}"`;
549
+ } else {
550
+ const keys = Object.keys(args);
551
+ inputPreview = `{${keys.slice(0, 3).join(', ')}${keys.length > 3 ? '...' : ''}}`;
552
+ }
553
+ }
554
+ }
555
+
556
+ const inputPart = inputPreview ? ` ${inputPreview}` : '';
557
+ logger.streamDebug(`${levelPrefix} [#${lineNum}] tool_start: ${toolName}${idPart}${inputPart}`);
558
+ break;
559
+ }
560
+
561
+ case 'tool_execution_update': {
562
+ const partial = event.partialResult || '';
563
+ if (partial) {
564
+ const preview = typeof partial === 'string'
565
+ ? (partial.length > 60 ? partial.substring(0, 60) + '...' : partial)
566
+ : JSON.stringify(partial).substring(0, 60);
567
+ logger.streamDebug(`${levelPrefix} [#${lineNum}] tool_update: ${preview.replace(/\n/g, '\\n')}`);
568
+ } else {
569
+ logger.streamDebug(`${levelPrefix} [#${lineNum}] tool_update`);
570
+ }
571
+ break;
572
+ }
573
+
574
+ case 'tool_execution_end': {
575
+ const isError = event.isError || false;
576
+ const statusPart = isError ? ' ERROR' : ' OK';
577
+ const result = event.result || '';
578
+ let resultPreview = '';
579
+ if (typeof result === 'string' && result.length > 0) {
580
+ resultPreview = result.length > 60 ? result.substring(0, 60) + '...' : result;
581
+ resultPreview = resultPreview.replace(/\n/g, '\\n');
582
+ }
583
+ const previewPart = resultPreview ? ` ${resultPreview}` : '';
584
+ logger.streamDebug(`${levelPrefix} [#${lineNum}] tool_end${statusPart}${previewPart}`);
585
+ break;
586
+ }
587
+
588
+ case 'agent_start':
589
+ logger.streamDebug(`${levelPrefix} [#${lineNum}] Agent started`);
590
+ break;
591
+
592
+ case 'agent_end':
593
+ logger.streamDebug(`${levelPrefix} [#${lineNum}] Agent ended`);
594
+ break;
595
+
596
+ default:
597
+ logger.streamDebug(`${levelPrefix} [#${lineNum}] ${type}`);
598
+ }
599
+ } catch {
600
+ // If we can't parse the line, log the full content for debugging
601
+ logger.streamDebug(`${levelPrefix} [#${lineNum}] (unparseable): ${line}`);
602
+ }
603
+ }
604
+
605
+ /**
606
+ * Parse Pi CLI JSONL response
607
+ * Pi with --mode json outputs JSONL with structured events.
608
+ * Text content is in message_end events with content blocks,
609
+ * and in message_update events with text_delta.
610
+ *
611
+ * @param {string} stdout - Raw stdout from Pi CLI (JSONL format)
612
+ * @param {string|number} level - Analysis level for logging
613
+ * @returns {{success: boolean, data?: Object, error?: string}}
614
+ */
615
+ parsePiResponse(stdout, level) {
616
+ const levelPrefix = `[Level ${level}]`;
617
+
618
+ try {
619
+ // Split by newlines and parse each JSON line
620
+ const lines = stdout.trim().split('\n').filter(line => line.trim());
621
+ let textContent = '';
622
+ const seenTexts = new Set();
623
+
624
+ for (const line of lines) {
625
+ try {
626
+ const event = JSON.parse(line);
627
+
628
+ // Extract text from message_end events (complete assistant messages)
629
+ // These contain the full message with content blocks
630
+ if (event.type === 'message_end' && event.message?.role === 'assistant') {
631
+ textContent += extractAssistantText(event.message.content, seenTexts);
632
+ }
633
+
634
+ // Also collect text from turn_end events which include the message
635
+ // (dedup handled by the shared seenTexts Set)
636
+ if (event.type === 'turn_end' && event.message?.role === 'assistant') {
637
+ textContent += extractAssistantText(event.message.content, seenTexts);
638
+ }
639
+
640
+ // Fallback: agent_end events contain the full messages array
641
+ if (event.type === 'agent_end' && Array.isArray(event.messages)) {
642
+ for (const msg of event.messages) {
643
+ if (msg.role === 'assistant') {
644
+ textContent += extractAssistantText(msg.content, seenTexts);
645
+ }
646
+ }
647
+ }
648
+ } catch (lineError) {
649
+ // Skip malformed lines
650
+ logger.debug(`${levelPrefix} Skipping malformed JSONL line: ${line.substring(0, 100)}`);
651
+ }
652
+ }
653
+
654
+ if (textContent) {
655
+ // Try to extract JSON from the accumulated text content
656
+ const extracted = extractJSON(textContent, level);
657
+ if (extracted.success) {
658
+ return extracted;
659
+ }
660
+
661
+ // If no JSON found, return with textContent so the caller can
662
+ // pass it (not raw JSONL stdout) to the LLM extraction fallback
663
+ logger.warn(`${levelPrefix} Text content is not JSON, treating as raw text`);
664
+ return { success: false, error: 'Text content is not valid JSON', textContent };
665
+ }
666
+
667
+ // No text content found, try extracting JSON directly from stdout
668
+ const extracted = extractJSON(stdout, level);
669
+ return extracted;
670
+
671
+ } catch (parseError) {
672
+ // stdout might not be valid JSONL at all, try extracting JSON from it
673
+ const extracted = extractJSON(stdout, level);
674
+ if (extracted.success) {
675
+ return extracted;
676
+ }
677
+
678
+ return { success: false, error: `JSONL parse error: ${parseError.message}` };
679
+ }
680
+ }
681
+
682
+ /**
683
+ * Resolve the --model (and optionally --provider) CLI arguments for a given model ID.
684
+ * Checks config model overrides, then built-in definitions, then falls back to the raw ID.
685
+ * Returns an empty array when cli_model is null (Pi uses its configured default).
686
+ * Supports `provider/model` format (e.g., 'google/gemini-2.5-flash') which produces
687
+ * ['--provider', 'google', '--model', 'gemini-2.5-flash'] for cross-provider switching.
688
+ *
689
+ * @param {string|null} modelId - Model identifier
690
+ * @returns {string[]} CLI arguments (e.g., ['--model', 'x'], ['--provider', 'p', '--model', 'm'], or [])
691
+ */
692
+ _resolveCliModelArgs(modelId) {
693
+ const builtIn = PI_MODELS.find(m => m.id === modelId);
694
+ const configModel = this.configOverrides?.models?.find(m => m.id === modelId);
695
+ const resolvedCliModel = configModel?.cli_model !== undefined
696
+ ? configModel.cli_model
697
+ : (builtIn?.cli_model !== undefined ? builtIn.cli_model : modelId);
698
+ if (resolvedCliModel === null) return [];
699
+ // Support provider/model format (e.g., 'google/gemini-2.5-flash')
700
+ if (typeof resolvedCliModel === 'string' && resolvedCliModel.includes('/')) {
701
+ const [provider, ...rest] = resolvedCliModel.split('/');
702
+ return ['--provider', provider, '--model', rest.join('/')];
703
+ }
704
+ return ['--model', resolvedCliModel];
705
+ }
706
+
707
+ /**
708
+ * Build args for Pi CLI execution, applying provider and model extra_args.
709
+ * This ensures consistent arg construction for both execute() and getExtractionConfig().
710
+ *
711
+ * @param {string} model - The model identifier to use
712
+ * @returns {string[]} Complete args array for the CLI
713
+ */
714
+ buildArgsForModel(model) {
715
+ const cliModelArgs = this._resolveCliModelArgs(model);
716
+
717
+ // Note: built-in extra_args (e.g., --skill for multi-model) are intentionally
718
+ // excluded for extraction. Extraction is a simple JSON-parsing task that doesn't
719
+ // need skills or other analysis-specific configuration.
720
+
721
+ // Base args for pi non-interactive JSON mode (extraction only -- no tools needed)
722
+ const sessionArgs = process.env.PAIR_REVIEW_PI_SESSION ? [] : ['--no-session'];
723
+ const baseArgs = ['-p', '--mode', 'json', ...cliModelArgs, '--no-tools', ...sessionArgs];
724
+ const configModel = this.configOverrides?.models?.find(m => m.id === model);
725
+ const providerArgs = this.configOverrides?.extra_args || [];
726
+ const modelArgs = configModel?.extra_args || [];
727
+ return [...baseArgs, ...providerArgs, ...modelArgs];
728
+ }
729
+
730
+ /**
731
+ * Get CLI configuration for LLM extraction
732
+ * @param {string} model - The model to use for extraction
733
+ * @returns {Object} Configuration for spawning extraction process
734
+ */
735
+ getExtractionConfig(model) {
736
+ // Use the already-resolved command from the constructor (this.piCmd)
737
+ // which respects: ENV > config > default precedence
738
+ const piCmd = this.piCmd;
739
+ const useShell = this.useShell;
740
+
741
+ // Build args consistently using the shared method, applying provider and model extra_args
742
+ const args = this.buildArgsForModel(model);
743
+
744
+ // For extraction, we pass the prompt via stdin
745
+ // Pi reads from stdin when using -p with no positional message arguments
746
+ if (useShell) {
747
+ return {
748
+ command: `${piCmd} ${args.join(' ')}`,
749
+ args: [],
750
+ useShell: true,
751
+ promptViaStdin: true,
752
+ env: this.extraEnv
753
+ };
754
+ }
755
+ return {
756
+ command: piCmd,
757
+ args,
758
+ useShell: false,
759
+ promptViaStdin: true,
760
+ env: this.extraEnv
761
+ };
762
+ }
763
+
764
+ /**
765
+ * Test if Pi CLI is available
766
+ * Uses the command configured in the instance (respects ENV > config > default precedence)
767
+ * @returns {Promise<boolean>}
768
+ */
769
+ async testAvailability() {
770
+ return new Promise((resolve) => {
771
+ // For availability test, we just need to check --version
772
+ // Use the already-resolved command from the constructor (this.piCmd)
773
+ // which respects: ENV > config > default precedence
774
+ const useShell = this.useShell;
775
+ const command = useShell ? `${this.piCmd} --version` : this.piCmd;
776
+ const args = useShell ? [] : ['--version'];
777
+
778
+ // Log the actual command for debugging config/override issues
779
+ const fullCmd = useShell ? command : `${command} ${args.join(' ')}`;
780
+ logger.debug(`Pi availability check: ${fullCmd}`);
781
+
782
+ const pi = spawn(command, args, {
783
+ env: {
784
+ ...process.env,
785
+ ...this.extraEnv,
786
+ PATH: `${BIN_DIR}:${process.env.PATH}`
787
+ },
788
+ shell: useShell
789
+ });
790
+
791
+ logger.debug(`Pi CLI spawn: ${command} ${args.join(' ')}`);
792
+
793
+ let stdout = '';
794
+ let settled = false;
795
+
796
+ // Timeout guard: if the CLI hangs, resolve false
797
+ const availabilityTimeout = setTimeout(() => {
798
+ if (settled) return;
799
+ settled = true;
800
+ logger.warn('Pi CLI availability check timed out after 10s');
801
+ try { pi.kill(); } catch { /* ignore */ }
802
+ resolve(false);
803
+ }, 10000);
804
+
805
+ pi.stdout.on('data', (data) => {
806
+ stdout += data.toString();
807
+ });
808
+
809
+ pi.on('close', (code) => {
810
+ if (settled) return;
811
+ settled = true;
812
+ clearTimeout(availabilityTimeout);
813
+ if (code === 0) {
814
+ logger.info(`Pi CLI available: ${stdout.trim()}`);
815
+ resolve(true);
816
+ } else {
817
+ logger.warn('Pi CLI not available or returned unexpected output');
818
+ resolve(false);
819
+ }
820
+ });
821
+
822
+ pi.on('error', (error) => {
823
+ if (settled) return;
824
+ settled = true;
825
+ clearTimeout(availabilityTimeout);
826
+ logger.warn(`Pi CLI not available: ${error.message}`);
827
+ resolve(false);
828
+ });
829
+ });
830
+ }
831
+
832
+ static getProviderName() {
833
+ return 'Pi';
834
+ }
835
+
836
+ static getProviderId() {
837
+ return 'pi';
838
+ }
839
+
840
+ static getModels() {
841
+ return PI_MODELS;
842
+ }
843
+
844
+ static getDefaultModel() {
845
+ const defaultModel = PI_MODELS.find(m => m.default);
846
+ return defaultModel ? defaultModel.id : null;
847
+ }
848
+
849
+ static getInstallInstructions() {
850
+ return 'Install Pi: npm install -g @mariozechner/pi-coding-agent\n' +
851
+ 'Or visit: https://github.com/badlogic/pi-mono';
852
+ }
853
+ }
854
+
855
+ // Register this provider
856
+ registerProvider('pi', PiProvider);
857
+
858
+ module.exports = PiProvider;
859
+ module.exports._extractAssistantText = extractAssistantText;