@in-the-loop-labs/pair-review 3.5.0 → 3.5.2
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 +1 -1
- package/package.json +1 -1
- package/plugin/.claude-plugin/plugin.json +1 -1
- package/plugin-code-critic/.claude-plugin/plugin.json +1 -1
- package/public/index.html +90 -0
- package/public/js/index.js +298 -25
- package/src/ai/claude-provider.js +68 -56
- package/src/ai/codex-provider.js +64 -33
- package/src/chat/api-reference.js +1 -1
- package/src/chat/chat-providers.js +26 -0
- package/src/chat/codex-bridge.js +238 -29
- package/src/chat/session-manager.js +1 -0
- package/src/main.js +3 -2
- package/src/routes/github-collections.js +168 -90
package/src/chat/codex-bridge.js
CHANGED
|
@@ -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
|
-
//
|
|
85
|
-
|
|
86
|
-
|
|
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
|
-
|
|
242
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
package/src/main.js
CHANGED
|
@@ -118,8 +118,9 @@ OPTIONS:
|
|
|
118
118
|
The web UI also starts for the human reviewer.
|
|
119
119
|
--model <name> Override the AI model. Claude Code is the default provider.
|
|
120
120
|
Available models: opus, sonnet, haiku (Claude Code);
|
|
121
|
-
also: opus-4.
|
|
122
|
-
opus-4.7-high, opus-4.
|
|
121
|
+
also: opus-4.8-xhigh, opus-4.8-high, opus-4.7-xhigh,
|
|
122
|
+
opus-4.7-high, opus-4.6-high, opus-4.6-1m, sonnet-4.6
|
|
123
|
+
(opus is Opus 4.7 XHigh, the default)
|
|
123
124
|
or use provider-specific models with Gemini/Codex
|
|
124
125
|
--use-checkout Use current directory instead of creating worktree
|
|
125
126
|
(automatic in GitHub Actions)
|