@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.
- package/README.md +67 -38
- package/package.json +1 -1
- package/plugin/.claude-plugin/plugin.json +1 -1
- package/plugin-code-critic/.claude-plugin/plugin.json +1 -1
- package/public/index.html +270 -623
- package/public/js/index.js +1071 -0
- package/public/js/local.js +80 -0
- package/public/js/modules/analysis-history.js +5 -1
- package/public/local.html +45 -2
- package/src/ai/claude-provider.js +12 -7
- package/src/ai/codex-provider.js +9 -7
- package/src/ai/cursor-agent-provider.js +9 -6
- package/src/ai/gemini-provider.js +9 -7
- package/src/ai/index.js +1 -0
- package/src/ai/opencode-provider.js +9 -7
- package/src/ai/pi-provider.js +859 -0
- package/src/ai/provider.js +32 -8
- package/src/ai/stream-parser.js +171 -2
- package/src/config.js +1 -1
- package/src/database.js +170 -40
- package/src/local-review.js +9 -0
- package/src/routes/local.js +390 -41
- package/src/utils/json-extractor.js +129 -39
|
@@ -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;
|