@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.
Files changed (47) hide show
  1. package/.pi/skills/review-model-guidance/SKILL.md +1 -1
  2. package/.pi/skills/review-roulette/SKILL.md +1 -1
  3. package/README.md +15 -1
  4. package/package.json +2 -1
  5. package/plugin/.claude-plugin/plugin.json +1 -1
  6. package/plugin/skills/review-requests/SKILL.md +1 -1
  7. package/plugin-code-critic/.claude-plugin/plugin.json +1 -1
  8. package/public/css/pr.css +296 -15
  9. package/public/index.html +121 -57
  10. package/public/js/components/AIPanel.js +2 -1
  11. package/public/js/components/AdvancedConfigTab.js +2 -2
  12. package/public/js/components/AnalysisConfigModal.js +2 -2
  13. package/public/js/components/ChatPanel.js +187 -28
  14. package/public/js/components/CouncilProgressModal.js +4 -7
  15. package/public/js/components/SplitButton.js +66 -1
  16. package/public/js/components/VoiceCentricConfigTab.js +2 -2
  17. package/public/js/index.js +274 -21
  18. package/public/js/modules/comment-manager.js +16 -12
  19. package/public/js/modules/file-comment-manager.js +8 -6
  20. package/public/js/pr.js +194 -5
  21. package/public/local.html +8 -1
  22. package/public/pr.html +17 -2
  23. package/src/ai/codex-provider.js +14 -2
  24. package/src/ai/copilot-provider.js +1 -10
  25. package/src/ai/cursor-agent-provider.js +1 -10
  26. package/src/ai/gemini-provider.js +8 -17
  27. package/src/chat/acp-bridge.js +442 -0
  28. package/src/chat/api-reference.js +539 -0
  29. package/src/chat/chat-providers.js +290 -0
  30. package/src/chat/claude-code-bridge.js +499 -0
  31. package/src/chat/codex-bridge.js +601 -0
  32. package/src/chat/pi-bridge.js +56 -3
  33. package/src/chat/prompt-builder.js +12 -11
  34. package/src/chat/session-manager.js +110 -29
  35. package/src/config.js +4 -2
  36. package/src/database.js +50 -2
  37. package/src/github/client.js +43 -0
  38. package/src/routes/chat.js +60 -27
  39. package/src/routes/config.js +24 -1
  40. package/src/routes/github-collections.js +126 -0
  41. package/src/routes/mcp.js +2 -1
  42. package/src/routes/pr.js +166 -2
  43. package/src/routes/reviews.js +2 -1
  44. package/src/routes/shared.js +70 -49
  45. package/src/server.js +27 -1
  46. package/src/utils/safe-parse-json.js +19 -0
  47. 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;
@@ -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
- // Response to a command (prompt, abort)
364
- if (!event.success) {
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 {