@in-the-loop-labs/pair-review 2.3.2 → 2.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/.pi/skills/review-model-guidance/SKILL.md +1 -1
- package/.pi/skills/review-roulette/SKILL.md +1 -1
- package/README.md +15 -1
- package/package.json +2 -1
- package/plugin/.claude-plugin/plugin.json +1 -1
- package/plugin/skills/review-requests/SKILL.md +1 -1
- package/plugin-code-critic/.claude-plugin/plugin.json +1 -1
- package/public/css/pr.css +296 -15
- package/public/index.html +121 -57
- package/public/js/components/AIPanel.js +2 -1
- package/public/js/components/AdvancedConfigTab.js +2 -2
- package/public/js/components/AnalysisConfigModal.js +2 -2
- package/public/js/components/ChatPanel.js +187 -28
- package/public/js/components/CouncilProgressModal.js +4 -7
- package/public/js/components/SplitButton.js +66 -1
- package/public/js/components/VoiceCentricConfigTab.js +2 -2
- package/public/js/index.js +274 -21
- package/public/js/modules/comment-manager.js +16 -12
- package/public/js/modules/file-comment-manager.js +8 -6
- package/public/js/pr.js +194 -5
- package/public/local.html +8 -1
- package/public/pr.html +17 -2
- package/src/ai/codex-provider.js +14 -2
- package/src/ai/copilot-provider.js +1 -10
- package/src/ai/cursor-agent-provider.js +1 -10
- package/src/ai/gemini-provider.js +8 -17
- package/src/chat/acp-bridge.js +442 -0
- package/src/chat/api-reference.js +539 -0
- package/src/chat/chat-providers.js +290 -0
- package/src/chat/claude-code-bridge.js +499 -0
- package/src/chat/codex-bridge.js +601 -0
- package/src/chat/pi-bridge.js +56 -3
- package/src/chat/prompt-builder.js +12 -11
- package/src/chat/session-manager.js +110 -29
- package/src/config.js +4 -2
- package/src/database.js +50 -2
- package/src/github/client.js +43 -0
- package/src/routes/chat.js +60 -27
- package/src/routes/config.js +24 -1
- package/src/routes/github-collections.js +126 -0
- package/src/routes/mcp.js +2 -1
- package/src/routes/pr.js +166 -2
- package/src/routes/reviews.js +2 -1
- package/src/routes/shared.js +70 -49
- package/src/server.js +27 -1
- package/src/utils/safe-parse-json.js +19 -0
- package/.pi/skills/pair-review-api/SKILL.md +0 -448
|
@@ -0,0 +1,601 @@
|
|
|
1
|
+
// SPDX-License-Identifier: GPL-3.0-or-later
|
|
2
|
+
/**
|
|
3
|
+
* Codex App-Server Bridge
|
|
4
|
+
*
|
|
5
|
+
* Manages a long-lived Codex agent process using the "app-server" protocol
|
|
6
|
+
* (bidirectional JSON-RPC 2.0 over JSONL/stdio) for interactive chat sessions.
|
|
7
|
+
* Mirrors the PiBridge/AcpBridge EventEmitter interface so all three can be
|
|
8
|
+
* used interchangeably.
|
|
9
|
+
*
|
|
10
|
+
* Emits high-level events: delta, complete, error, tool_use, status, ready, close, session.
|
|
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
|
+
const { version: pkgVersion } = require('../../package.json');
|
|
18
|
+
|
|
19
|
+
// Default dependencies (overridable for testing)
|
|
20
|
+
const defaults = {
|
|
21
|
+
spawn,
|
|
22
|
+
createInterface,
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
class CodexBridge extends EventEmitter {
|
|
26
|
+
/**
|
|
27
|
+
* @param {Object} options
|
|
28
|
+
* @param {string} [options.model] - Model ID
|
|
29
|
+
* @param {string} [options.cwd] - Working directory for agent process
|
|
30
|
+
* @param {string} [options.systemPrompt] - System prompt text
|
|
31
|
+
* @param {string} [options.codexCommand] - Codex binary (default: 'codex')
|
|
32
|
+
* @param {string[]} [options.codexArgs] - Args for Codex binary (default: ['app-server']); chat-providers.js adds shell env config
|
|
33
|
+
* @param {Object} [options.env] - Extra env vars for subprocess
|
|
34
|
+
* @param {boolean} [options.useShell] - Use shell mode for multi-word commands
|
|
35
|
+
* @param {string} [options.resumeThreadId] - Thread ID to resume
|
|
36
|
+
* @param {Object} [options._deps] - Dependency injection for testing
|
|
37
|
+
*/
|
|
38
|
+
constructor(options = {}) {
|
|
39
|
+
super();
|
|
40
|
+
this.model = options.model || null;
|
|
41
|
+
this.cwd = options.cwd || process.cwd();
|
|
42
|
+
this.systemPrompt = options.systemPrompt || null;
|
|
43
|
+
this.env = options.env || {};
|
|
44
|
+
this.useShell = options.useShell || false;
|
|
45
|
+
this.resumeThreadId = options.resumeThreadId || null;
|
|
46
|
+
|
|
47
|
+
// Command resolution: constructor option → env var → default
|
|
48
|
+
this.codexCommand = options.codexCommand
|
|
49
|
+
|| process.env.PAIR_REVIEW_CODEX_CMD
|
|
50
|
+
|| 'codex';
|
|
51
|
+
this.codexArgs = options.codexArgs || ['app-server'];
|
|
52
|
+
|
|
53
|
+
this._deps = { ...defaults, ...options._deps };
|
|
54
|
+
this._process = null;
|
|
55
|
+
this._threadId = null;
|
|
56
|
+
this._turnId = null;
|
|
57
|
+
this._ready = false;
|
|
58
|
+
this._closing = false;
|
|
59
|
+
this._accumulatedText = '';
|
|
60
|
+
this._inMessage = false;
|
|
61
|
+
this._firstMessage = !options.resumeThreadId;
|
|
62
|
+
|
|
63
|
+
// JSON-RPC state
|
|
64
|
+
this._nextId = 1;
|
|
65
|
+
this._pendingRequests = new Map(); // id -> { resolve, reject, timeout }
|
|
66
|
+
this._requestTimeoutMs = 30_000;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Spawn the codex app-server process, perform handshake, and create a thread.
|
|
71
|
+
* Resolves once the thread is established and the bridge is ready.
|
|
72
|
+
* @returns {Promise<void>}
|
|
73
|
+
*/
|
|
74
|
+
async start() {
|
|
75
|
+
if (this._process) {
|
|
76
|
+
throw new Error('CodexBridge already started');
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const deps = this._deps;
|
|
80
|
+
const command = this.codexCommand;
|
|
81
|
+
const args = [...this.codexArgs];
|
|
82
|
+
const useShell = this.useShell;
|
|
83
|
+
|
|
84
|
+
// Append model flag if configured
|
|
85
|
+
if (this.model) {
|
|
86
|
+
args.push('--model', this.model);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// For multi-word commands (e.g. "devx codex"), use shell mode
|
|
90
|
+
const spawnCmd = useShell ? `${command} ${args.join(' ')}` : command;
|
|
91
|
+
const spawnArgs = useShell ? [] : args;
|
|
92
|
+
|
|
93
|
+
logger.info(`[CodexBridge] Starting Codex agent: ${command} ${args.join(' ')}`);
|
|
94
|
+
|
|
95
|
+
return new Promise((resolve, reject) => {
|
|
96
|
+
const proc = deps.spawn(spawnCmd, spawnArgs, {
|
|
97
|
+
cwd: this.cwd,
|
|
98
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
99
|
+
env: { ...process.env, ...this.env },
|
|
100
|
+
shell: useShell,
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
this._process = proc;
|
|
104
|
+
|
|
105
|
+
// Handle spawn error (e.g., ENOENT)
|
|
106
|
+
proc.on('error', (err) => {
|
|
107
|
+
if (!this._ready) {
|
|
108
|
+
this._ready = false;
|
|
109
|
+
reject(new Error(`Failed to start Codex agent: ${err.message}`));
|
|
110
|
+
} else {
|
|
111
|
+
logger.error(`[CodexBridge] Process error: ${err.message}`);
|
|
112
|
+
this.emit('error', { error: err });
|
|
113
|
+
}
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
// Handle process exit
|
|
117
|
+
proc.on('close', (code, signal) => {
|
|
118
|
+
const wasReady = this._ready;
|
|
119
|
+
this._ready = false;
|
|
120
|
+
this._process = null;
|
|
121
|
+
|
|
122
|
+
// Reject all pending requests and clear their timeouts
|
|
123
|
+
for (const [id, pending] of this._pendingRequests) {
|
|
124
|
+
if (pending.timeout) clearTimeout(pending.timeout);
|
|
125
|
+
pending.reject(new Error(`Process exited while awaiting response for request ${id}`));
|
|
126
|
+
}
|
|
127
|
+
this._pendingRequests.clear();
|
|
128
|
+
|
|
129
|
+
if (!wasReady && !this._closing) {
|
|
130
|
+
reject(new Error(`Codex agent exited before ready (code=${code}, signal=${signal})`));
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
if (!this._closing) {
|
|
134
|
+
logger.warn(`[CodexBridge] Process exited unexpectedly (code=${code}, signal=${signal})`);
|
|
135
|
+
this.emit('error', { error: new Error(`Codex agent exited (code=${code}, signal=${signal})`) });
|
|
136
|
+
} else {
|
|
137
|
+
logger.info(`[CodexBridge] Process exited (code=${code}, signal=${signal})`);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
this.emit('close');
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
// Collect stderr for diagnostics
|
|
144
|
+
proc.stderr.on('data', (data) => {
|
|
145
|
+
const text = data.toString().trim();
|
|
146
|
+
if (text) {
|
|
147
|
+
logger.debug(`[CodexBridge] stderr: ${text}`);
|
|
148
|
+
}
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
// Handle stdin errors (e.g., EPIPE if process dies)
|
|
152
|
+
proc.stdin.on('error', (err) => {
|
|
153
|
+
logger.error(`[CodexBridge] stdin error: ${err.message}`);
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
// Set up JSONL readline on stdout
|
|
157
|
+
const rl = deps.createInterface({ input: proc.stdout, crlfDelay: Infinity });
|
|
158
|
+
rl.on('line', (line) => this._handleLine(line));
|
|
159
|
+
|
|
160
|
+
// Perform handshake
|
|
161
|
+
this._initializeThread()
|
|
162
|
+
.then(() => {
|
|
163
|
+
this._ready = true;
|
|
164
|
+
logger.info(`[CodexBridge] Ready (PID ${proc.pid})`);
|
|
165
|
+
this.emit('ready');
|
|
166
|
+
resolve();
|
|
167
|
+
})
|
|
168
|
+
.catch((err) => {
|
|
169
|
+
if (!this._closing) {
|
|
170
|
+
reject(new Error(`Codex initialization failed: ${err.message}`));
|
|
171
|
+
}
|
|
172
|
+
});
|
|
173
|
+
});
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* Perform the JSON-RPC handshake and create or resume a thread.
|
|
178
|
+
* @returns {Promise<void>}
|
|
179
|
+
*/
|
|
180
|
+
async _initializeThread() {
|
|
181
|
+
// 1. Send initialize request
|
|
182
|
+
await this._sendRequest('initialize', {
|
|
183
|
+
clientInfo: { name: 'pair-review', version: pkgVersion },
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
// 2. Send initialized notification
|
|
187
|
+
this._sendNotification('initialized');
|
|
188
|
+
|
|
189
|
+
// 3. Start or resume thread
|
|
190
|
+
if (this.resumeThreadId) {
|
|
191
|
+
const result = await this._sendRequest('thread/resume', {
|
|
192
|
+
threadId: this.resumeThreadId,
|
|
193
|
+
});
|
|
194
|
+
this._threadId = result.thread?.id || result.threadId || this.resumeThreadId;
|
|
195
|
+
logger.info(`[CodexBridge] Thread resumed: ${this._threadId}`);
|
|
196
|
+
} else {
|
|
197
|
+
const result = await this._sendRequest('thread/start', {});
|
|
198
|
+
this._threadId = result.thread?.id || result.threadId;
|
|
199
|
+
if (!this._threadId) {
|
|
200
|
+
throw new Error('thread/start response missing thread ID');
|
|
201
|
+
}
|
|
202
|
+
logger.info(`[CodexBridge] Thread created: ${this._threadId}`);
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
// Emit session info so session-manager can store the threadId
|
|
206
|
+
this.emit('session', { threadId: this._threadId });
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* Send a user message to the Codex agent.
|
|
211
|
+
* Fire-and-forget: returns immediately, emits events as the agent responds.
|
|
212
|
+
* @param {string} content - The message text
|
|
213
|
+
*/
|
|
214
|
+
async sendMessage(content) {
|
|
215
|
+
if (!this.isReady()) {
|
|
216
|
+
throw new Error('CodexBridge is not ready');
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
// Reset accumulated text for this new turn
|
|
220
|
+
this._accumulatedText = '';
|
|
221
|
+
this._inMessage = true;
|
|
222
|
+
|
|
223
|
+
let messageContent = content;
|
|
224
|
+
if (this.systemPrompt && this._firstMessage) {
|
|
225
|
+
messageContent = this.systemPrompt + '\n\n' + content;
|
|
226
|
+
this._firstMessage = false;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
logger.debug(`[CodexBridge] Sending message (${messageContent.length} chars): ${messageContent.substring(0, 100)}${messageContent.length > 100 ? '...' : ''}`);
|
|
230
|
+
|
|
231
|
+
// Send turn/start — completion is driven by turn/completed notification,
|
|
232
|
+
// not by this response. Store turnId for abort support.
|
|
233
|
+
// Codex app-server expects `input` as an array of typed objects, not a
|
|
234
|
+
// plain string. See https://developers.openai.com/codex/app-server/
|
|
235
|
+
this._sendRequest('turn/start', {
|
|
236
|
+
threadId: this._threadId,
|
|
237
|
+
input: [{ type: 'text', text: messageContent }],
|
|
238
|
+
approvalPolicy: 'never',
|
|
239
|
+
})
|
|
240
|
+
.then((result) => {
|
|
241
|
+
if (result && result.turnId) {
|
|
242
|
+
this._turnId = result.turnId;
|
|
243
|
+
}
|
|
244
|
+
})
|
|
245
|
+
.catch((err) => {
|
|
246
|
+
this._inMessage = false;
|
|
247
|
+
logger.error(`[CodexBridge] turn/start error: ${err.message}`);
|
|
248
|
+
this.emit('error', { error: err });
|
|
249
|
+
});
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/**
|
|
253
|
+
* Abort the current turn.
|
|
254
|
+
*/
|
|
255
|
+
abort() {
|
|
256
|
+
if (!this.isReady() || !this._threadId || !this._turnId) return;
|
|
257
|
+
logger.debug('[CodexBridge] Sending turn/interrupt');
|
|
258
|
+
this._sendRequest('turn/interrupt', {
|
|
259
|
+
threadId: this._threadId,
|
|
260
|
+
turnId: this._turnId,
|
|
261
|
+
}).catch((err) => {
|
|
262
|
+
logger.error(`[CodexBridge] turn/interrupt error: ${err.message}`);
|
|
263
|
+
});
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
/**
|
|
267
|
+
* Gracefully shut down the Codex agent process.
|
|
268
|
+
* @returns {Promise<void>}
|
|
269
|
+
*/
|
|
270
|
+
async close() {
|
|
271
|
+
if (!this._process) return;
|
|
272
|
+
|
|
273
|
+
this._closing = true;
|
|
274
|
+
|
|
275
|
+
// Reject all pending requests and clear their timeouts
|
|
276
|
+
for (const [id, pending] of this._pendingRequests) {
|
|
277
|
+
if (pending.timeout) clearTimeout(pending.timeout);
|
|
278
|
+
pending.reject(new Error(`Bridge closing, rejecting pending request ${id}`));
|
|
279
|
+
}
|
|
280
|
+
this._pendingRequests.clear();
|
|
281
|
+
|
|
282
|
+
// Attempt to interrupt any active turn
|
|
283
|
+
if (this._threadId && this._turnId) {
|
|
284
|
+
try {
|
|
285
|
+
this._sendNotification('turn/interrupt', {
|
|
286
|
+
threadId: this._threadId,
|
|
287
|
+
turnId: this._turnId,
|
|
288
|
+
});
|
|
289
|
+
} catch {
|
|
290
|
+
/* process may already be dead */
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
this.removeAllListeners();
|
|
295
|
+
|
|
296
|
+
return new Promise((resolve) => {
|
|
297
|
+
const proc = this._process;
|
|
298
|
+
if (!proc) {
|
|
299
|
+
resolve();
|
|
300
|
+
return;
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
// Give the process a moment to exit gracefully, then force kill
|
|
304
|
+
const killTimeout = setTimeout(() => {
|
|
305
|
+
if (this._process) {
|
|
306
|
+
logger.warn('[CodexBridge] Force killing process');
|
|
307
|
+
this._process.kill('SIGKILL');
|
|
308
|
+
}
|
|
309
|
+
}, 3000);
|
|
310
|
+
|
|
311
|
+
const onClose = () => {
|
|
312
|
+
clearTimeout(killTimeout);
|
|
313
|
+
resolve();
|
|
314
|
+
};
|
|
315
|
+
|
|
316
|
+
proc.once('close', onClose);
|
|
317
|
+
|
|
318
|
+
// Send SIGTERM
|
|
319
|
+
proc.kill('SIGTERM');
|
|
320
|
+
});
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
/**
|
|
324
|
+
* Check if the Codex agent process is alive and ready.
|
|
325
|
+
* @returns {boolean}
|
|
326
|
+
*/
|
|
327
|
+
isReady() {
|
|
328
|
+
return this._ready && this._process !== null && !this._closing;
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
/**
|
|
332
|
+
* Check if the bridge is currently processing a message.
|
|
333
|
+
* @returns {boolean}
|
|
334
|
+
*/
|
|
335
|
+
isBusy() {
|
|
336
|
+
return this._inMessage;
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
// ---------------------------------------------------------------------------
|
|
340
|
+
// JSON-RPC 2.0 transport
|
|
341
|
+
// ---------------------------------------------------------------------------
|
|
342
|
+
|
|
343
|
+
/**
|
|
344
|
+
* Send a JSON-RPC request (expects a response).
|
|
345
|
+
* @param {string} method
|
|
346
|
+
* @param {Object} [params]
|
|
347
|
+
* @returns {Promise<Object>} The result from the response
|
|
348
|
+
*/
|
|
349
|
+
_sendRequest(method, params) {
|
|
350
|
+
const id = this._nextId++;
|
|
351
|
+
const message = { jsonrpc: '2.0', method, id };
|
|
352
|
+
if (params !== undefined) {
|
|
353
|
+
message.params = params;
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
return new Promise((resolve, reject) => {
|
|
357
|
+
const timeout = setTimeout(() => {
|
|
358
|
+
this._pendingRequests.delete(id);
|
|
359
|
+
reject(new Error(`Request ${method} (id=${id}) timed out after ${this._requestTimeoutMs}ms`));
|
|
360
|
+
}, this._requestTimeoutMs);
|
|
361
|
+
|
|
362
|
+
// Wrap resolve/reject to clear the timeout on normal completion
|
|
363
|
+
const wrappedResolve = (value) => { clearTimeout(timeout); resolve(value); };
|
|
364
|
+
const wrappedReject = (err) => { clearTimeout(timeout); reject(err); };
|
|
365
|
+
|
|
366
|
+
this._pendingRequests.set(id, { resolve: wrappedResolve, reject: wrappedReject, timeout });
|
|
367
|
+
if (!this._writeLine(message)) {
|
|
368
|
+
this._pendingRequests.delete(id);
|
|
369
|
+
clearTimeout(timeout);
|
|
370
|
+
reject(new Error('Cannot write to Codex process — stdin not writable'));
|
|
371
|
+
}
|
|
372
|
+
});
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
/**
|
|
376
|
+
* Send a JSON-RPC notification (no response expected).
|
|
377
|
+
* @param {string} method
|
|
378
|
+
* @param {Object} [params]
|
|
379
|
+
*/
|
|
380
|
+
_sendNotification(method, params) {
|
|
381
|
+
const message = { jsonrpc: '2.0', method };
|
|
382
|
+
if (params !== undefined) {
|
|
383
|
+
message.params = params;
|
|
384
|
+
}
|
|
385
|
+
this._writeLine(message);
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
/**
|
|
389
|
+
* Write a JSON line to the process stdin.
|
|
390
|
+
* @param {Object} obj
|
|
391
|
+
*/
|
|
392
|
+
_writeLine(obj) {
|
|
393
|
+
if (!this._process || !this._process.stdin.writable) {
|
|
394
|
+
logger.warn('[CodexBridge] Cannot write — stdin not writable');
|
|
395
|
+
return false;
|
|
396
|
+
}
|
|
397
|
+
const line = JSON.stringify(obj) + '\n';
|
|
398
|
+
this._process.stdin.write(line);
|
|
399
|
+
return true;
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
/**
|
|
403
|
+
* Handle a single JSONL line from stdout.
|
|
404
|
+
* Dispatches to pending request handlers, notification handler, or server request handler.
|
|
405
|
+
* @param {string} line
|
|
406
|
+
*/
|
|
407
|
+
_handleLine(line) {
|
|
408
|
+
const trimmed = line.trim();
|
|
409
|
+
if (!trimmed) return;
|
|
410
|
+
|
|
411
|
+
let msg;
|
|
412
|
+
try {
|
|
413
|
+
msg = JSON.parse(trimmed);
|
|
414
|
+
} catch (err) {
|
|
415
|
+
logger.debug(`[CodexBridge] Non-JSON line: ${trimmed.substring(0, 200)}`);
|
|
416
|
+
return;
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
// Response to a pending request (has id, has result or error, no method)
|
|
420
|
+
if (msg.id !== undefined && msg.id !== null && !msg.method) {
|
|
421
|
+
const pending = this._pendingRequests.get(msg.id);
|
|
422
|
+
if (pending) {
|
|
423
|
+
this._pendingRequests.delete(msg.id);
|
|
424
|
+
if (msg.error) {
|
|
425
|
+
pending.reject(new Error(msg.error.message || JSON.stringify(msg.error)));
|
|
426
|
+
} else {
|
|
427
|
+
pending.resolve(msg.result || {});
|
|
428
|
+
}
|
|
429
|
+
} else {
|
|
430
|
+
logger.debug(`[CodexBridge] Response for unknown request id=${msg.id}`);
|
|
431
|
+
}
|
|
432
|
+
return;
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
// Server request (has method AND id) — needs a response
|
|
436
|
+
if (msg.method && msg.id !== undefined && msg.id !== null) {
|
|
437
|
+
this._handleServerRequest(msg);
|
|
438
|
+
return;
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
// Server notification (has method, no id)
|
|
442
|
+
if (msg.method) {
|
|
443
|
+
this._handleNotification(msg);
|
|
444
|
+
return;
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
logger.debug(`[CodexBridge] Unrecognized message: ${trimmed.substring(0, 200)}`);
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
// ---------------------------------------------------------------------------
|
|
451
|
+
// Notification handling
|
|
452
|
+
// ---------------------------------------------------------------------------
|
|
453
|
+
|
|
454
|
+
/**
|
|
455
|
+
* Handle a server notification (no id, has method).
|
|
456
|
+
* @param {Object} msg - Parsed JSON-RPC notification
|
|
457
|
+
*/
|
|
458
|
+
_handleNotification(msg) {
|
|
459
|
+
const { method, params } = msg;
|
|
460
|
+
|
|
461
|
+
switch (method) {
|
|
462
|
+
case 'item/agentMessage/delta':
|
|
463
|
+
this._handleDelta(params);
|
|
464
|
+
break;
|
|
465
|
+
|
|
466
|
+
case 'turn/completed':
|
|
467
|
+
this._handleTurnCompleted(params);
|
|
468
|
+
break;
|
|
469
|
+
|
|
470
|
+
case 'turn/started':
|
|
471
|
+
this.emit('status', { status: 'working' });
|
|
472
|
+
break;
|
|
473
|
+
|
|
474
|
+
case 'item/started':
|
|
475
|
+
this._handleItemStarted(params);
|
|
476
|
+
break;
|
|
477
|
+
|
|
478
|
+
case 'item/completed':
|
|
479
|
+
this._handleItemCompleted(params);
|
|
480
|
+
break;
|
|
481
|
+
|
|
482
|
+
default:
|
|
483
|
+
logger.debug(`[CodexBridge] Unhandled notification: ${method}`);
|
|
484
|
+
}
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
/**
|
|
488
|
+
* Handle streaming text delta.
|
|
489
|
+
* @param {Object} params
|
|
490
|
+
*/
|
|
491
|
+
_handleDelta(params) {
|
|
492
|
+
if (!params) return;
|
|
493
|
+
const text = params.delta || params.text;
|
|
494
|
+
if (text) {
|
|
495
|
+
this._accumulatedText += text;
|
|
496
|
+
this.emit('delta', { text });
|
|
497
|
+
}
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
/**
|
|
501
|
+
* Handle turn completion.
|
|
502
|
+
* @param {Object} params
|
|
503
|
+
*/
|
|
504
|
+
_handleTurnCompleted(params) {
|
|
505
|
+
const status = params?.status;
|
|
506
|
+
|
|
507
|
+
if (status === 'failed') {
|
|
508
|
+
this._inMessage = false;
|
|
509
|
+
this._turnId = null;
|
|
510
|
+
const errorMsg = params.error?.message || params.reason || 'Turn failed';
|
|
511
|
+
logger.error(`[CodexBridge] Turn failed: ${errorMsg}`);
|
|
512
|
+
this.emit('error', { error: new Error(errorMsg) });
|
|
513
|
+
return;
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
// status === 'completed' or any other terminal status
|
|
517
|
+
const fullText = this._accumulatedText;
|
|
518
|
+
this._accumulatedText = '';
|
|
519
|
+
this._inMessage = false;
|
|
520
|
+
this._turnId = null;
|
|
521
|
+
logger.debug(`[CodexBridge] Turn completed, accumulated ${fullText.length} chars`);
|
|
522
|
+
this.emit('complete', { fullText });
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
/**
|
|
526
|
+
* Handle item/started — emit tool_use for command-type items.
|
|
527
|
+
* @param {Object} params
|
|
528
|
+
*/
|
|
529
|
+
_handleItemStarted(params) {
|
|
530
|
+
if (!params) return;
|
|
531
|
+
const type = params.type || params.itemType;
|
|
532
|
+
if (type === 'command' || type === 'tool_call' || type === 'function_call') {
|
|
533
|
+
this.emit('tool_use', {
|
|
534
|
+
toolCallId: params.itemId || params.id,
|
|
535
|
+
toolName: params.name || params.title || params.command || type,
|
|
536
|
+
status: 'start',
|
|
537
|
+
});
|
|
538
|
+
}
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
/**
|
|
542
|
+
* Handle item/completed — emit tool_use end for command-type items.
|
|
543
|
+
* @param {Object} params
|
|
544
|
+
*/
|
|
545
|
+
_handleItemCompleted(params) {
|
|
546
|
+
if (!params) return;
|
|
547
|
+
const type = params.type || params.itemType;
|
|
548
|
+
if (type === 'command' || type === 'tool_call' || type === 'function_call') {
|
|
549
|
+
this.emit('tool_use', {
|
|
550
|
+
toolCallId: params.itemId || params.id,
|
|
551
|
+
toolName: params.name || params.title || params.command || type,
|
|
552
|
+
status: 'end',
|
|
553
|
+
});
|
|
554
|
+
}
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
// ---------------------------------------------------------------------------
|
|
558
|
+
// Server request handling (requests from the server that need a response)
|
|
559
|
+
// ---------------------------------------------------------------------------
|
|
560
|
+
|
|
561
|
+
/**
|
|
562
|
+
* Handle a server-initiated request (has method AND id).
|
|
563
|
+
* @param {Object} msg - Parsed JSON-RPC request
|
|
564
|
+
*/
|
|
565
|
+
_handleServerRequest(msg) {
|
|
566
|
+
const { method, id, params } = msg;
|
|
567
|
+
|
|
568
|
+
if (method === 'requestApproval') {
|
|
569
|
+
// Safety net — approvalPolicy 'never' should prevent these, but auto-accept
|
|
570
|
+
// in case the server sends one anyway.
|
|
571
|
+
logger.debug(`[CodexBridge] Auto-approving requestApproval (id=${id})`);
|
|
572
|
+
this._sendResponse(id, { decision: 'accept' });
|
|
573
|
+
return;
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
// Unknown server request — respond with error to avoid hangs
|
|
577
|
+
logger.warn(`[CodexBridge] Unknown server request: ${method} (id=${id})`);
|
|
578
|
+
this._sendErrorResponse(id, -32601, `Method not found: ${method}`);
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
/**
|
|
582
|
+
* Send a JSON-RPC success response.
|
|
583
|
+
* @param {number|string} id - Request ID
|
|
584
|
+
* @param {Object} result
|
|
585
|
+
*/
|
|
586
|
+
_sendResponse(id, result) {
|
|
587
|
+
this._writeLine({ jsonrpc: '2.0', id, result });
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
/**
|
|
591
|
+
* Send a JSON-RPC error response.
|
|
592
|
+
* @param {number|string} id - Request ID
|
|
593
|
+
* @param {number} code - Error code
|
|
594
|
+
* @param {string} message - Error message
|
|
595
|
+
*/
|
|
596
|
+
_sendErrorResponse(id, code, message) {
|
|
597
|
+
this._writeLine({ jsonrpc: '2.0', id, error: { code, message } });
|
|
598
|
+
}
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
module.exports = CodexBridge;
|
package/src/chat/pi-bridge.js
CHANGED
|
@@ -53,6 +53,8 @@ class PiBridge extends EventEmitter {
|
|
|
53
53
|
// Accumulate text across streaming deltas for each turn
|
|
54
54
|
this._accumulatedText = '';
|
|
55
55
|
this._inMessage = false;
|
|
56
|
+
// Pending callbacks for RPC responses keyed by command type
|
|
57
|
+
this._pendingCallbacks = new Map();
|
|
56
58
|
}
|
|
57
59
|
|
|
58
60
|
/**
|
|
@@ -145,7 +147,7 @@ class PiBridge extends EventEmitter {
|
|
|
145
147
|
resolve();
|
|
146
148
|
}
|
|
147
149
|
});
|
|
148
|
-
});
|
|
150
|
+
}).then(() => this._querySessionFile());
|
|
149
151
|
}
|
|
150
152
|
|
|
151
153
|
/**
|
|
@@ -282,6 +284,53 @@ class PiBridge extends EventEmitter {
|
|
|
282
284
|
return args;
|
|
283
285
|
}
|
|
284
286
|
|
|
287
|
+
/**
|
|
288
|
+
* Query Pi's get_state command to discover the session file path.
|
|
289
|
+
* Pi's RPC protocol does not emit a 'session' event on its own, so we
|
|
290
|
+
* must explicitly ask for the state after startup. The response contains
|
|
291
|
+
* a `sessionFile` field that we store and emit as a 'session' event so
|
|
292
|
+
* the session-manager can persist the agent_session_id.
|
|
293
|
+
* @returns {Promise<void>}
|
|
294
|
+
*/
|
|
295
|
+
_querySessionFile() {
|
|
296
|
+
if (!this.isReady()) return Promise.resolve();
|
|
297
|
+
|
|
298
|
+
return new Promise((resolve) => {
|
|
299
|
+
let timeout;
|
|
300
|
+
// Register a callback for the get_state response
|
|
301
|
+
this._pendingCallbacks.set('get_state', (event) => {
|
|
302
|
+
clearTimeout(timeout);
|
|
303
|
+
if (event.success && event.data && event.data.sessionFile) {
|
|
304
|
+
this.sessionPath = event.data.sessionFile;
|
|
305
|
+
this.emit('session', { sessionFile: event.data.sessionFile });
|
|
306
|
+
logger.info(`[PiBridge] Discovered session file: ${event.data.sessionFile}`);
|
|
307
|
+
} else {
|
|
308
|
+
logger.debug('[PiBridge] get_state did not return a sessionFile');
|
|
309
|
+
}
|
|
310
|
+
resolve();
|
|
311
|
+
});
|
|
312
|
+
|
|
313
|
+
// Safety timeout — don't block startup forever if Pi never responds
|
|
314
|
+
timeout = setTimeout(() => {
|
|
315
|
+
if (this._pendingCallbacks.has('get_state')) {
|
|
316
|
+
this._pendingCallbacks.delete('get_state');
|
|
317
|
+
logger.debug('[PiBridge] get_state timed out');
|
|
318
|
+
resolve();
|
|
319
|
+
}
|
|
320
|
+
}, 5000);
|
|
321
|
+
// Don't let this timer keep the process alive
|
|
322
|
+
if (timeout.unref) timeout.unref();
|
|
323
|
+
|
|
324
|
+
try {
|
|
325
|
+
this._write(JSON.stringify({ type: 'get_state' }));
|
|
326
|
+
} catch (err) {
|
|
327
|
+
this._pendingCallbacks.delete('get_state');
|
|
328
|
+
logger.debug(`[PiBridge] Failed to send get_state: ${err.message}`);
|
|
329
|
+
resolve();
|
|
330
|
+
}
|
|
331
|
+
});
|
|
332
|
+
}
|
|
333
|
+
|
|
285
334
|
/**
|
|
286
335
|
* Write a JSON command line to the process stdin.
|
|
287
336
|
* @param {string} jsonLine - The JSON string (without trailing newline)
|
|
@@ -360,8 +409,12 @@ class PiBridge extends EventEmitter {
|
|
|
360
409
|
break;
|
|
361
410
|
|
|
362
411
|
case 'response':
|
|
363
|
-
//
|
|
364
|
-
if (
|
|
412
|
+
// Route to pending callback if one exists for this command
|
|
413
|
+
if (event.command && this._pendingCallbacks.has(event.command)) {
|
|
414
|
+
const callback = this._pendingCallbacks.get(event.command);
|
|
415
|
+
this._pendingCallbacks.delete(event.command);
|
|
416
|
+
callback(event);
|
|
417
|
+
} else if (!event.success) {
|
|
365
418
|
logger.error(`[PiBridge] Command failed: ${event.error}`);
|
|
366
419
|
this.emit('error', { error: new Error(event.error || 'Unknown command error') });
|
|
367
420
|
} else {
|