@in-the-loop-labs/pair-review 3.4.1 → 3.5.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.
@@ -13,6 +13,7 @@
13
13
  const { EventEmitter } = require('events');
14
14
  const { spawn } = require('child_process');
15
15
  const { createInterface } = require('readline');
16
+ const { quoteShellArgs } = require('../ai/provider');
16
17
  const logger = require('../utils/logger');
17
18
  const { version: pkgVersion } = require('../../package.json');
18
19
 
@@ -22,6 +23,34 @@ const defaults = {
22
23
  createInterface,
23
24
  };
24
25
 
26
+ const DEFAULT_APPROVAL_POLICY = 'never';
27
+ const DEFAULT_SANDBOX_MODE = 'workspace-write';
28
+ const ACTIVE_TURN_STATUSES = new Set(['inProgress', 'running', 'working']);
29
+ const TERMINAL_TURN_STATUSES = new Set(['completed', 'failed', 'interrupted', 'cancelled', 'canceled']);
30
+
31
+ function buildSandboxPolicy(sandbox = DEFAULT_SANDBOX_MODE) {
32
+ if (sandbox === 'read-only') {
33
+ return {
34
+ type: 'readOnly',
35
+ networkAccess: true,
36
+ };
37
+ }
38
+
39
+ return {
40
+ type: 'workspaceWrite',
41
+ writableRoots: [],
42
+ networkAccess: true,
43
+ excludeTmpdirEnvVar: false,
44
+ excludeSlashTmp: false,
45
+ };
46
+ }
47
+
48
+ function compactParams(params) {
49
+ return Object.fromEntries(
50
+ Object.entries(params).filter(([, value]) => value !== undefined && value !== null)
51
+ );
52
+ }
53
+
25
54
  class CodexBridge extends EventEmitter {
26
55
  /**
27
56
  * @param {Object} options
@@ -33,6 +62,8 @@ class CodexBridge extends EventEmitter {
33
62
  * @param {Object} [options.env] - Extra env vars for subprocess
34
63
  * @param {boolean} [options.useShell] - Use shell mode for multi-word commands
35
64
  * @param {string} [options.resumeThreadId] - Thread ID to resume
65
+ * @param {string|null} [options.sandbox] - Thread sandbox mode (default: 'workspace-write')
66
+ * @param {Object|null} [options.sandboxPolicy] - Turn sandbox policy override for tests
36
67
  * @param {Object} [options._deps] - Dependency injection for testing
37
68
  */
38
69
  constructor(options = {}) {
@@ -43,6 +74,11 @@ class CodexBridge extends EventEmitter {
43
74
  this.env = options.env || {};
44
75
  this.useShell = options.useShell || false;
45
76
  this.resumeThreadId = options.resumeThreadId || null;
77
+ this.approvalPolicy = DEFAULT_APPROVAL_POLICY;
78
+ this.sandbox = options.sandbox !== undefined ? options.sandbox : DEFAULT_SANDBOX_MODE;
79
+ this.sandboxPolicy = options.sandboxPolicy !== undefined
80
+ ? options.sandboxPolicy
81
+ : buildSandboxPolicy(this.sandbox);
46
82
 
47
83
  // Command resolution: constructor option → env var → default
48
84
  this.codexCommand = options.codexCommand
@@ -81,13 +117,9 @@ class CodexBridge extends EventEmitter {
81
117
  const args = [...this.codexArgs];
82
118
  const useShell = this.useShell;
83
119
 
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;
120
+ // For multi-word commands (e.g. "devx codex"), use shell mode. Quote args
121
+ // so TOML config values like include_only=["PATH","HOME"] survive the shell.
122
+ const spawnCmd = useShell ? `${command} ${quoteShellArgs(args).join(' ')}` : command;
91
123
  const spawnArgs = useShell ? [] : args;
92
124
 
93
125
  logger.info(`[CodexBridge] Starting Codex agent: ${command} ${args.join(' ')}`);
@@ -188,13 +220,13 @@ class CodexBridge extends EventEmitter {
188
220
 
189
221
  // 3. Start or resume thread
190
222
  if (this.resumeThreadId) {
191
- const result = await this._sendRequest('thread/resume', {
223
+ const result = await this._sendRequest('thread/resume', this._buildThreadParams({
192
224
  threadId: this.resumeThreadId,
193
- });
225
+ }));
194
226
  this._threadId = result.thread?.id || result.threadId || this.resumeThreadId;
195
227
  logger.info(`[CodexBridge] Thread resumed: ${this._threadId}`);
196
228
  } else {
197
- const result = await this._sendRequest('thread/start', {});
229
+ const result = await this._sendRequest('thread/start', this._buildThreadParams());
198
230
  this._threadId = result.thread?.id || result.threadId;
199
231
  if (!this._threadId) {
200
232
  throw new Error('thread/start response missing thread ID');
@@ -206,6 +238,41 @@ class CodexBridge extends EventEmitter {
206
238
  this.emit('session', { threadId: this._threadId });
207
239
  }
208
240
 
241
+ /**
242
+ * Build thread start/resume settings that keep Codex chat able to call the
243
+ * pair-review API from the review worktree.
244
+ * @param {Object} [extra] - Additional params, e.g. threadId for resume.
245
+ * @returns {Object}
246
+ */
247
+ _buildThreadParams(extra = {}) {
248
+ return compactParams({
249
+ ...extra,
250
+ cwd: this.cwd,
251
+ model: this.model,
252
+ approvalPolicy: this.approvalPolicy,
253
+ // thread/start uses the same sandbox enum as the Codex CLI, while
254
+ // turn/start.sandboxPolicy uses the v2 camelCase policy object.
255
+ sandbox: this.sandbox,
256
+ });
257
+ }
258
+
259
+ /**
260
+ * Build turn/start params. App-server uses the v2 camelCase SandboxPolicy
261
+ * shape here, not the `codex exec --sandbox workspace-write` CLI flag.
262
+ * @param {Array<Object>} input
263
+ * @returns {Object}
264
+ */
265
+ _buildTurnStartParams(input) {
266
+ return compactParams({
267
+ threadId: this._threadId,
268
+ input,
269
+ cwd: this.cwd,
270
+ model: this.model,
271
+ approvalPolicy: this.approvalPolicy,
272
+ sandboxPolicy: this.sandboxPolicy,
273
+ });
274
+ }
275
+
209
276
  /**
210
277
  * Send a user message to the Codex agent.
211
278
  * Fire-and-forget: returns immediately, emits events as the agent responds.
@@ -232,14 +299,11 @@ class CodexBridge extends EventEmitter {
232
299
  // not by this response. Store turnId for abort support.
233
300
  // Codex app-server expects `input` as an array of typed objects, not a
234
301
  // 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
- })
302
+ this._sendRequest('turn/start', this._buildTurnStartParams([{ type: 'text', text: messageContent }]))
240
303
  .then((result) => {
241
- if (result && result.turnId) {
242
- this._turnId = result.turnId;
304
+ const turnId = this._extractTurnId(result);
305
+ if (turnId) {
306
+ this._turnId = turnId;
243
307
  }
244
308
  })
245
309
  .catch((err) => {
@@ -468,7 +532,14 @@ class CodexBridge extends EventEmitter {
468
532
  break;
469
533
 
470
534
  case 'turn/started':
471
- this.emit('status', { status: 'working' });
535
+ this._handleTurnStarted(params);
536
+ break;
537
+
538
+ case 'turn/statusChanged':
539
+ this._handleTurnStatusChanged(params);
540
+ break;
541
+
542
+ case 'remoteControl/status/changed':
472
543
  break;
473
544
 
474
545
  case 'item/started':
@@ -490,13 +561,64 @@ class CodexBridge extends EventEmitter {
490
561
  */
491
562
  _handleDelta(params) {
492
563
  if (!params) return;
493
- const text = params.delta || params.text;
564
+ let text = params.delta || params.text;
494
565
  if (text) {
566
+ text = this._normalizeDeltaBoundary(text);
495
567
  this._accumulatedText += text;
496
568
  this.emit('delta', { text });
497
569
  }
498
570
  }
499
571
 
572
+ /**
573
+ * Preserve readable boundaries when app-server splits prose deltas without
574
+ * carrying the whitespace between adjacent chunks.
575
+ * @param {string} text
576
+ * @returns {string}
577
+ */
578
+ _normalizeDeltaBoundary(text) {
579
+ const previous = this._accumulatedText;
580
+ if (
581
+ previous &&
582
+ /[.!?]$/.test(previous) &&
583
+ /^[A-Z]/.test(text)
584
+ ) {
585
+ return ` ${text}`;
586
+ }
587
+ return text;
588
+ }
589
+
590
+ /**
591
+ * Handle turn started notifications and capture the active turn id.
592
+ * @param {Object} params
593
+ */
594
+ _handleTurnStarted(params) {
595
+ const turnId = this._extractTurnId(params);
596
+ if (turnId) {
597
+ this._turnId = turnId;
598
+ }
599
+ this.emit('status', { status: 'working' });
600
+ }
601
+
602
+ /**
603
+ * Handle turn status changes without reviving a completed turn.
604
+ * @param {Object} params
605
+ */
606
+ _handleTurnStatusChanged(params) {
607
+ const status = params?.status || params?.turn?.status;
608
+
609
+ if (ACTIVE_TURN_STATUSES.has(status)) {
610
+ this._handleTurnStarted(params);
611
+ return;
612
+ }
613
+
614
+ if (TERMINAL_TURN_STATUSES.has(status)) {
615
+ this._turnId = null;
616
+ if (status === 'failed') {
617
+ this._inMessage = false;
618
+ }
619
+ }
620
+ }
621
+
500
622
  /**
501
623
  * Handle turn completion.
502
624
  * @param {Object} params
@@ -530,14 +652,40 @@ class CodexBridge extends EventEmitter {
530
652
  if (!params) return;
531
653
  const type = params.type || params.itemType;
532
654
  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
- });
655
+ this.emit('tool_use', this._buildToolUseEvent(params, 'start'));
538
656
  }
539
657
  }
540
658
 
659
+ /**
660
+ * Build the normalized tool event shape consumed by the chat broadcaster.
661
+ * Command items are represented as bash calls so internal pair-review API
662
+ * curls can be suppressed consistently across providers.
663
+ * @param {Object} params
664
+ * @param {'start'|'end'} status
665
+ * @returns {Object}
666
+ */
667
+ _buildToolUseEvent(params, status) {
668
+ const type = params.type || params.itemType;
669
+ const toolCallId = params.itemId || params.id;
670
+ if (type === 'command') {
671
+ const event = {
672
+ toolCallId,
673
+ toolName: 'bash',
674
+ status,
675
+ };
676
+ if (params.command) {
677
+ event.args = { command: params.command };
678
+ }
679
+ return event;
680
+ }
681
+
682
+ return {
683
+ toolCallId,
684
+ toolName: params.name || params.title || params.command || type,
685
+ status,
686
+ };
687
+ }
688
+
541
689
  /**
542
690
  * Handle item/completed — emit tool_use end for command-type items.
543
691
  * @param {Object} params
@@ -546,11 +694,7 @@ class CodexBridge extends EventEmitter {
546
694
  if (!params) return;
547
695
  const type = params.type || params.itemType;
548
696
  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
- });
697
+ this.emit('tool_use', this._buildToolUseEvent(params, 'end'));
554
698
  }
555
699
  }
556
700
 
@@ -573,11 +717,76 @@ class CodexBridge extends EventEmitter {
573
717
  return;
574
718
  }
575
719
 
720
+ if (method === 'item/commandExecution/requestApproval') {
721
+ logger.debug(`[CodexBridge] Auto-approving command execution request (id=${id})`);
722
+ this._sendResponse(id, { decision: 'accept' });
723
+ return;
724
+ }
725
+
726
+ if (method === 'execCommandApproval') {
727
+ logger.debug(`[CodexBridge] Auto-approving execCommandApproval request (id=${id})`);
728
+ this._sendResponse(id, { decision: 'approved' });
729
+ return;
730
+ }
731
+
732
+ if (method === 'item/permissions/requestApproval') {
733
+ logger.debug(`[CodexBridge] Granting requested network permissions (id=${id})`);
734
+ this._sendResponse(id, this._buildPermissionsApproval(params));
735
+ return;
736
+ }
737
+
738
+ if (method === 'item/fileChange/requestApproval') {
739
+ logger.debug(`[CodexBridge] Declining file change approval request (id=${id})`);
740
+ this._sendResponse(id, { decision: 'decline' });
741
+ return;
742
+ }
743
+
744
+ if (method === 'applyPatchApproval') {
745
+ logger.debug(`[CodexBridge] Denying applyPatchApproval request (id=${id})`);
746
+ this._sendResponse(id, { decision: 'denied' });
747
+ return;
748
+ }
749
+
576
750
  // Unknown server request — respond with error to avoid hangs
577
751
  logger.warn(`[CodexBridge] Unknown server request: ${method} (id=${id})`);
578
752
  this._sendErrorResponse(id, -32601, `Method not found: ${method}`);
579
753
  }
580
754
 
755
+ /**
756
+ * Build a response for Codex v2 permission requests. For pair-review chat we
757
+ * grant network permission so localhost API `curl` calls can proceed, while
758
+ * avoiding broad file-system permission grants beyond the configured sandbox.
759
+ * @param {Object} params
760
+ * @returns {Object}
761
+ */
762
+ _buildPermissionsApproval(params = {}) {
763
+ const requested = params.permissions || {};
764
+ const permissions = {};
765
+
766
+ if (requested.network) {
767
+ permissions.network = requested.network;
768
+ } else {
769
+ // Codex chat needs localhost network access to call pair-review's API.
770
+ // Grant it even if app-server requests permissions without a network body.
771
+ permissions.network = { enabled: true };
772
+ }
773
+
774
+ return {
775
+ permissions,
776
+ scope: 'session',
777
+ strictAutoReview: false,
778
+ };
779
+ }
780
+
781
+ /**
782
+ * Extract a turn id from legacy and current app-server shapes.
783
+ * @param {Object} value
784
+ * @returns {string|null}
785
+ */
786
+ _extractTurnId(value) {
787
+ return value?.turn?.id || value?.turnId || value?.id || null;
788
+ }
789
+
581
790
  /**
582
791
  * Send a JSON-RPC success response.
583
792
  * @param {number|string} id - Request ID
@@ -571,6 +571,7 @@ class ChatSessionManager {
571
571
  codexArgs: def?.args,
572
572
  env: def?.env,
573
573
  useShell: def?.useShell,
574
+ sandbox: def?.sandbox,
574
575
  });
575
576
  }
576
577
  // Pi provider — resolve config overrides (command, model, env) from provider def.
package/src/config.js CHANGED
@@ -39,7 +39,8 @@ const DEFAULT_CONFIG = {
39
39
  assisted_by_url: "https://github.com/in-the-loop-labs/pair-review", // URL for "Review assisted by" footer link
40
40
  hooks: {}, // Hook commands per event: { "review.started": { "my_hook": { "command": "..." } } }
41
41
  enable_graphite: false, // When true, shows Graphite links alongside GitHub links
42
- skip_update_notifier: false // When true, suppresses the "update available" notification on exit
42
+ skip_update_notifier: false, // When true, suppresses the "update available" notification on exit
43
+ external_comments: false // Opt-in: set to true to enable GitHub PR review-comment sync (External segment, refresh button, /api/reviews/*/external-comments routes)
43
44
  };
44
45
 
45
46
  /**