@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.
- package/README.md +77 -4
- package/package.json +1 -1
- package/plugin/.claude-plugin/plugin.json +1 -1
- package/plugin/skills/review-requests/SKILL.md +4 -1
- package/plugin-code-critic/.claude-plugin/plugin.json +1 -1
- package/plugin-code-critic/skills/analyze/SKILL.md +4 -3
- package/public/css/pr.css +1962 -114
- package/public/js/CONVENTIONS.md +16 -0
- package/public/js/components/AIPanel.js +66 -0
- package/public/js/components/AnalysisConfigModal.js +2 -2
- package/public/js/components/ChatPanel.js +2955 -0
- package/public/js/components/CouncilProgressModal.js +12 -16
- package/public/js/components/KeyboardShortcuts.js +3 -0
- package/public/js/components/PanelGroup.js +723 -0
- package/public/js/components/PreviewModal.js +3 -8
- package/public/js/index.js +8 -0
- package/public/js/local.js +17 -615
- package/public/js/modules/analysis-history.js +19 -68
- package/public/js/modules/comment-manager.js +103 -20
- package/public/js/modules/diff-context.js +176 -0
- package/public/js/modules/diff-renderer.js +30 -0
- package/public/js/modules/file-comment-manager.js +126 -105
- package/public/js/modules/file-list-merger.js +64 -0
- package/public/js/modules/panel-resizer.js +25 -6
- package/public/js/modules/suggestion-manager.js +40 -125
- package/public/js/pr.js +1009 -159
- package/public/js/repo-settings.js +36 -6
- package/public/js/utils/category-emoji.js +44 -0
- package/public/js/utils/time.js +32 -0
- package/public/local.html +107 -70
- package/public/pr.html +107 -70
- package/public/repo-settings.html +32 -0
- package/src/ai/analyzer.js +5 -1
- package/src/ai/copilot-provider.js +39 -9
- package/src/ai/cursor-agent-provider.js +45 -11
- package/src/ai/gemini-provider.js +17 -4
- package/src/ai/prompts/config.js +7 -1
- package/src/ai/provider-availability.js +1 -1
- package/src/ai/provider.js +25 -37
- package/src/chat/CONVENTIONS.md +18 -0
- package/src/chat/pi-bridge.js +491 -0
- package/src/chat/prompt-builder.js +272 -0
- package/src/chat/session-manager.js +619 -0
- package/src/config.js +14 -0
- package/src/database.js +322 -15
- package/src/main.js +4 -17
- package/src/routes/analyses.js +721 -0
- package/src/routes/chat.js +655 -0
- package/src/routes/config.js +29 -8
- package/src/routes/context-files.js +274 -0
- package/src/routes/local.js +225 -1133
- package/src/routes/mcp.js +39 -30
- package/src/routes/pr.js +424 -58
- package/src/routes/reviews.js +1035 -0
- package/src/routes/shared.js +4 -29
- package/src/server.js +34 -12
- package/src/sse/review-events.js +46 -0
- package/src/utils/auto-context.js +88 -0
- package/src/utils/category-emoji.js +33 -0
- package/src/utils/diff-annotator.js +75 -1
- package/src/utils/diff-file-list.js +57 -0
- package/src/routes/analysis.js +0 -1600
- package/src/routes/comments.js +0 -534
package/src/ai/provider.js
CHANGED
|
@@ -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 = [...
|
|
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;
|