@in-the-loop-labs/pair-review 2.3.3 → 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 +287 -14
- 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/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
package/public/pr.html
CHANGED
|
@@ -10,8 +10,16 @@
|
|
|
10
10
|
// Fetch chat availability early so PanelGroup sees the correct state
|
|
11
11
|
fetch('/api/config').then(r => r.ok ? r.json() : null).then(config => {
|
|
12
12
|
if (!config) return;
|
|
13
|
+
const chatProviders = config.chat_providers || [];
|
|
13
14
|
let state = 'disabled';
|
|
14
|
-
if (config.enable_chat)
|
|
15
|
+
if (config.enable_chat) {
|
|
16
|
+
const anyAvailable = chatProviders.some(p => p.available);
|
|
17
|
+
state = anyAvailable ? 'available' : 'unavailable';
|
|
18
|
+
}
|
|
19
|
+
window.__pairReview = window.__pairReview || {};
|
|
20
|
+
window.__pairReview.chatProvider = config.chat_provider || 'pi';
|
|
21
|
+
window.__pairReview.chatProviders = chatProviders;
|
|
22
|
+
window.__pairReview.share = config.share || null;
|
|
15
23
|
document.documentElement.setAttribute('data-chat', state);
|
|
16
24
|
const shortcutsState = config.chat_enable_shortcuts === false ? 'disabled' : 'enabled';
|
|
17
25
|
document.documentElement.setAttribute('data-chat-shortcuts', shortcutsState);
|
|
@@ -85,7 +93,14 @@
|
|
|
85
93
|
</div>
|
|
86
94
|
</div>
|
|
87
95
|
<div class="header-center">
|
|
88
|
-
<
|
|
96
|
+
<div class="pr-title-wrapper">
|
|
97
|
+
<h1 class="pr-title" id="pr-title-text">Loading...</h1>
|
|
98
|
+
<button class="btn btn-icon pr-description-toggle" id="pr-description-toggle" title="View PR description" style="display: none;" aria-expanded="false" aria-haspopup="true">
|
|
99
|
+
<svg viewBox="0 0 16 16" width="14" height="14" fill="currentColor">
|
|
100
|
+
<path d="M0 8a8 8 0 1 1 16 0A8 8 0 0 1 0 8Zm8-6.5a6.5 6.5 0 1 0 0 13 6.5 6.5 0 0 0 0-13ZM6.5 7.75A.75.75 0 0 1 7.25 7h1a.75.75 0 0 1 .75.75v2.75h.25a.75.75 0 0 1 0 1.5h-2a.75.75 0 0 1 0-1.5h.25v-2h-.25a.75.75 0 0 1-.75-.75ZM8 6a1 1 0 1 1 0-2 1 1 0 0 1 0 2Z"/>
|
|
101
|
+
</svg>
|
|
102
|
+
</button>
|
|
103
|
+
</div>
|
|
89
104
|
</div>
|
|
90
105
|
<div class="header-right">
|
|
91
106
|
<div class="header-icon-group">
|
package/src/ai/codex-provider.js
CHANGED
|
@@ -61,7 +61,8 @@ class CodexProvider extends AIProvider {
|
|
|
61
61
|
* @param {string} model - Model identifier
|
|
62
62
|
* @param {Object} configOverrides - Config overrides from providers config
|
|
63
63
|
* @param {string} configOverrides.command - Custom CLI command
|
|
64
|
-
* @param {string[]} configOverrides.
|
|
64
|
+
* @param {string[]} configOverrides.args - Replace default shell env args (default: login shell + env policy)
|
|
65
|
+
* @param {string[]} configOverrides.extra_args - Additional CLI arguments (appended)
|
|
65
66
|
* @param {Object} configOverrides.env - Additional environment variables
|
|
66
67
|
* @param {Object[]} configOverrides.models - Custom model definitions
|
|
67
68
|
*/
|
|
@@ -96,6 +97,12 @@ class CodexProvider extends AIProvider {
|
|
|
96
97
|
// --full-auto: Non-interactive mode that auto-approves within sandbox bounds.
|
|
97
98
|
// Combined with workspace-write sandbox, this limits damage to the worktree only.
|
|
98
99
|
// Note: The -a flag is for interactive mode only; exec subcommand uses --full-auto.
|
|
100
|
+
//
|
|
101
|
+
// Shell environment config:
|
|
102
|
+
// - allow_login_shell=false: Prevents zsh from using -l flag, which would
|
|
103
|
+
// reconstruct PATH from scratch and lose our BIN_DIR modification.
|
|
104
|
+
// - shell_environment_policy.include_only: Whitelist PATH, HOME, USER to be
|
|
105
|
+
// inherited from the parent process, ensuring git-diff-lines is findable.
|
|
99
106
|
|
|
100
107
|
// Build args: base args + provider extra_args + model extra_args
|
|
101
108
|
// In yolo mode, bypass all sandbox restrictions and approval prompts
|
|
@@ -103,7 +110,12 @@ class CodexProvider extends AIProvider {
|
|
|
103
110
|
const sandboxArgs = configOverrides.yolo
|
|
104
111
|
? ['--dangerously-bypass-approvals-and-sandbox']
|
|
105
112
|
: ['--sandbox', 'workspace-write', '--full-auto'];
|
|
106
|
-
|
|
113
|
+
// Shell env args prevent login shell from reconstructing PATH (orthogonal to
|
|
114
|
+
// sandbox permissions). Overridable via configOverrides.args following the
|
|
115
|
+
// same two-tier pattern as chat-providers.js: args replaces, extra_args appends.
|
|
116
|
+
const defaultShellEnvArgs = ['-c', 'allow_login_shell=false', '-c', 'shell_environment_policy.include_only=["PATH","HOME","USER"]'];
|
|
117
|
+
const configArgs = configOverrides.args || defaultShellEnvArgs;
|
|
118
|
+
const baseArgs = ['exec', '-m', model, '--json', ...sandboxArgs, ...configArgs, '-'];
|
|
107
119
|
const providerArgs = configOverrides.extra_args || [];
|
|
108
120
|
const modelConfig = configOverrides.models?.find(m => m.id === model);
|
|
109
121
|
const modelArgs = modelConfig?.extra_args || [];
|
|
@@ -22,7 +22,7 @@ const BIN_DIR = path.join(__dirname, '..', '..', 'bin');
|
|
|
22
22
|
* GitHub Copilot CLI supports multiple AI models including OpenAI,
|
|
23
23
|
* Anthropic, and Google models via the --model flag.
|
|
24
24
|
* Available models (as of Feb 2026): claude-haiku-4.5, claude-sonnet-4.6,
|
|
25
|
-
* claude-sonnet-4.5,
|
|
25
|
+
* claude-sonnet-4.5, gpt-5.2-codex, gpt-5.3-codex,
|
|
26
26
|
* claude-opus-4.5, claude-opus-4.6, claude-opus-4.6-fast.
|
|
27
27
|
* Default is claude-sonnet-4.6.
|
|
28
28
|
*/
|
|
@@ -55,15 +55,6 @@ const COPILOT_MODELS = [
|
|
|
55
55
|
badge: 'Previous Gen',
|
|
56
56
|
badgeClass: 'badge-balanced'
|
|
57
57
|
},
|
|
58
|
-
{
|
|
59
|
-
id: 'gemini-3-pro-preview',
|
|
60
|
-
name: 'Gemini 3 Pro',
|
|
61
|
-
tier: 'balanced',
|
|
62
|
-
tagline: 'Strong Alternative',
|
|
63
|
-
description: "Google's most capable model—strong reasoning for cross-file analysis",
|
|
64
|
-
badge: 'Balanced',
|
|
65
|
-
badgeClass: 'badge-balanced'
|
|
66
|
-
},
|
|
67
58
|
{
|
|
68
59
|
id: 'gpt-5.2-codex',
|
|
69
60
|
name: 'GPT-5.2 Codex',
|
|
@@ -31,7 +31,7 @@ const BIN_DIR = path.join(__dirname, '..', '..', 'bin');
|
|
|
31
31
|
* Tier structure:
|
|
32
32
|
* - free (auto): Cursor's default auto-routing model
|
|
33
33
|
* - fast (composer-1, gpt-5.3-codex-fast, gemini-3-flash): Quick analysis
|
|
34
|
-
* - balanced (composer-1.5, sonnet-4.6-thinking, sonnet-4.5-thinking, gemini-3
|
|
34
|
+
* - balanced (composer-1.5, sonnet-4.6-thinking, sonnet-4.5-thinking, gemini-3.1-pro): Recommended for most reviews
|
|
35
35
|
* - thorough (gpt-5.3-codex-high, gpt-5.3-codex-xhigh, opus-4.5-thinking, opus-4.6-thinking): Deep analysis for complex code
|
|
36
36
|
*/
|
|
37
37
|
const CURSOR_AGENT_MODELS = [
|
|
@@ -99,15 +99,6 @@ const CURSOR_AGENT_MODELS = [
|
|
|
99
99
|
badge: 'Previous Gen',
|
|
100
100
|
badgeClass: 'badge-balanced'
|
|
101
101
|
},
|
|
102
|
-
{
|
|
103
|
-
id: 'gemini-3-pro',
|
|
104
|
-
name: 'Gemini 3 Pro',
|
|
105
|
-
tier: 'balanced',
|
|
106
|
-
tagline: 'Strong Alternative',
|
|
107
|
-
description: "Google's flagship model for code review—strong agentic and vibe coding capabilities",
|
|
108
|
-
badge: 'Balanced',
|
|
109
|
-
badgeClass: 'badge-balanced'
|
|
110
|
-
},
|
|
111
102
|
{
|
|
112
103
|
id: 'gemini-3.1-pro',
|
|
113
104
|
name: 'Gemini 3.1 Pro',
|
|
@@ -21,8 +21,8 @@ const BIN_DIR = path.join(__dirname, '..', '..', 'bin');
|
|
|
21
21
|
*/
|
|
22
22
|
const GEMINI_MODELS = [
|
|
23
23
|
{
|
|
24
|
-
id: 'gemini-3-flash',
|
|
25
|
-
aliases: ['gemini-3-flash
|
|
24
|
+
id: 'gemini-3-flash-preview',
|
|
25
|
+
aliases: ['gemini-3-flash'],
|
|
26
26
|
name: '3.0 Flash',
|
|
27
27
|
tier: 'fast',
|
|
28
28
|
tagline: 'Rapid Sanity Check',
|
|
@@ -41,22 +41,13 @@ const GEMINI_MODELS = [
|
|
|
41
41
|
default: true
|
|
42
42
|
},
|
|
43
43
|
{
|
|
44
|
-
id: 'gemini-3-pro',
|
|
45
|
-
aliases: ['gemini-3-pro
|
|
46
|
-
name: '3.0 Pro',
|
|
47
|
-
tier: 'thorough',
|
|
48
|
-
tagline: 'Architectural Audit',
|
|
49
|
-
description: 'Most intelligent Gemini model—advanced reasoning for deep architectural analysis',
|
|
50
|
-
badge: 'Deep Dive',
|
|
51
|
-
badgeClass: 'badge-power'
|
|
52
|
-
},
|
|
53
|
-
{
|
|
54
|
-
id: 'gemini-3.1-pro',
|
|
44
|
+
id: 'gemini-3.1-pro-preview',
|
|
45
|
+
aliases: ['gemini-3.1-pro'],
|
|
55
46
|
name: '3.1 Pro',
|
|
56
47
|
tier: 'thorough',
|
|
57
48
|
tagline: 'Latest & Greatest',
|
|
58
49
|
description: 'Newest Gemini model—cutting-edge reasoning for complex architectural reviews',
|
|
59
|
-
badge: '
|
|
50
|
+
badge: 'Deep Dive',
|
|
60
51
|
badgeClass: 'badge-power'
|
|
61
52
|
}
|
|
62
53
|
];
|
|
@@ -124,9 +115,9 @@ class GeminiProvider extends AIProvider {
|
|
|
124
115
|
// Use --output-format stream-json for JSONL streaming output (better debugging visibility)
|
|
125
116
|
let baseArgs;
|
|
126
117
|
if (configOverrides.yolo) {
|
|
127
|
-
// In yolo mode,
|
|
128
|
-
//
|
|
129
|
-
baseArgs = ['-m', model, '-o', 'stream-json', '--yolo'];
|
|
118
|
+
// In yolo mode, auto-approve all tools (including write operations and destructive shell commands)
|
|
119
|
+
// Note: --yolo is deprecated in favor of --approval-mode=yolo
|
|
120
|
+
baseArgs = ['-m', model, '-o', 'stream-json', '--approval-mode', 'yolo'];
|
|
130
121
|
} else {
|
|
131
122
|
const readOnlyTools = [
|
|
132
123
|
// File system tools (read-only)
|
|
@@ -0,0 +1,442 @@
|
|
|
1
|
+
// SPDX-License-Identifier: GPL-3.0-or-later
|
|
2
|
+
/**
|
|
3
|
+
* ACP (Agent Client Protocol) Bridge
|
|
4
|
+
*
|
|
5
|
+
* Manages a long-lived agent process using the ACP protocol for interactive
|
|
6
|
+
* chat sessions. Communicates over stdin/stdout using newline-delimited JSON-RPC.
|
|
7
|
+
* Mirrors the PiBridge EventEmitter interface so both can be used interchangeably.
|
|
8
|
+
*
|
|
9
|
+
* Emits high-level events: delta, complete, error, tool_use, status, ready, close, session.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
const { EventEmitter } = require('events');
|
|
13
|
+
const { spawn } = require('child_process');
|
|
14
|
+
const { Writable, Readable } = require('stream');
|
|
15
|
+
const logger = require('../utils/logger');
|
|
16
|
+
const { version: pkgVersion } = require('../../package.json');
|
|
17
|
+
|
|
18
|
+
// Default dependencies (overridable for testing)
|
|
19
|
+
const defaults = {
|
|
20
|
+
spawn,
|
|
21
|
+
acp: require('@agentclientprotocol/sdk'),
|
|
22
|
+
Writable,
|
|
23
|
+
Readable,
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
class AcpBridge extends EventEmitter {
|
|
27
|
+
/**
|
|
28
|
+
* @param {Object} options
|
|
29
|
+
* @param {string} [options.model] - Model ID
|
|
30
|
+
* @param {string} [options.cwd] - Working directory for agent process
|
|
31
|
+
* @param {string} [options.systemPrompt] - System prompt text
|
|
32
|
+
* @param {string} [options.acpCommand] - Agent binary (default: 'copilot')
|
|
33
|
+
* @param {string[]} [options.acpArgs] - Extra CLI args (default: ['--acp', '--stdio'])
|
|
34
|
+
* @param {Object} [options.env] - Extra env vars for subprocess
|
|
35
|
+
* @param {boolean} [options.useShell] - Use shell mode for multi-word commands
|
|
36
|
+
* @param {string} [options.resumeSessionId] - ACP session ID to resume via loadSession
|
|
37
|
+
* @param {Object} [options._deps] - Dependency injection for testing
|
|
38
|
+
*/
|
|
39
|
+
constructor(options = {}) {
|
|
40
|
+
super();
|
|
41
|
+
this.model = options.model || null;
|
|
42
|
+
this.cwd = options.cwd || process.cwd();
|
|
43
|
+
this.systemPrompt = options.systemPrompt || null;
|
|
44
|
+
this.acpCommand = options.acpCommand || 'copilot';
|
|
45
|
+
this.acpArgs = options.acpArgs || ['--acp', '--stdio'];
|
|
46
|
+
this.env = options.env || {};
|
|
47
|
+
this.useShell = options.useShell || false;
|
|
48
|
+
this.resumeSessionId = options.resumeSessionId || null;
|
|
49
|
+
|
|
50
|
+
this._deps = { ...defaults, ...options._deps };
|
|
51
|
+
this._process = null;
|
|
52
|
+
this._connection = null;
|
|
53
|
+
this._sessionId = null;
|
|
54
|
+
this._ready = false;
|
|
55
|
+
this._closing = false;
|
|
56
|
+
this._accumulatedText = '';
|
|
57
|
+
this._inMessage = false;
|
|
58
|
+
this._firstMessage = !options.resumeSessionId;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Spawn the agent subprocess, perform ACP handshake, and create a session.
|
|
63
|
+
* Resolves once the session is established and the bridge is ready.
|
|
64
|
+
* @returns {Promise<void>}
|
|
65
|
+
*/
|
|
66
|
+
async start() {
|
|
67
|
+
if (this._process) {
|
|
68
|
+
throw new Error('AcpBridge already started');
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const deps = this._deps;
|
|
72
|
+
const command = this.acpCommand;
|
|
73
|
+
const args = [...this.acpArgs];
|
|
74
|
+
const useShell = this.useShell;
|
|
75
|
+
|
|
76
|
+
// For multi-word commands (e.g. "devx gemini"), use shell mode
|
|
77
|
+
const spawnCmd = useShell ? `${command} ${args.join(' ')}` : command;
|
|
78
|
+
const spawnArgs = useShell ? [] : args;
|
|
79
|
+
|
|
80
|
+
logger.info(`[AcpBridge] Starting ACP agent: ${command} ${args.join(' ')}`);
|
|
81
|
+
|
|
82
|
+
return new Promise((resolve, reject) => {
|
|
83
|
+
const proc = deps.spawn(spawnCmd, spawnArgs, {
|
|
84
|
+
cwd: this.cwd,
|
|
85
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
86
|
+
env: { ...process.env, ...this.env },
|
|
87
|
+
shell: useShell,
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
this._process = proc;
|
|
91
|
+
|
|
92
|
+
// Handle spawn error (e.g., ENOENT)
|
|
93
|
+
proc.on('error', (err) => {
|
|
94
|
+
if (!this._ready) {
|
|
95
|
+
this._ready = false;
|
|
96
|
+
reject(new Error(`Failed to start ACP agent: ${err.message}`));
|
|
97
|
+
} else {
|
|
98
|
+
logger.error(`[AcpBridge] Process error: ${err.message}`);
|
|
99
|
+
this.emit('error', { error: err });
|
|
100
|
+
}
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
// Handle process exit
|
|
104
|
+
proc.on('close', (code, signal) => {
|
|
105
|
+
const wasReady = this._ready;
|
|
106
|
+
this._ready = false;
|
|
107
|
+
this._process = null;
|
|
108
|
+
|
|
109
|
+
if (!wasReady && !this._closing) {
|
|
110
|
+
reject(new Error(`ACP agent exited before ready (code=${code}, signal=${signal})`));
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
if (!this._closing) {
|
|
114
|
+
logger.warn(`[AcpBridge] Process exited unexpectedly (code=${code}, signal=${signal})`);
|
|
115
|
+
this.emit('error', { error: new Error(`ACP agent exited (code=${code}, signal=${signal})`) });
|
|
116
|
+
} else {
|
|
117
|
+
logger.info(`[AcpBridge] Process exited (code=${code}, signal=${signal})`);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
this.emit('close');
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
// Collect stderr for diagnostics
|
|
124
|
+
proc.stderr.on('data', (data) => {
|
|
125
|
+
const text = data.toString().trim();
|
|
126
|
+
if (text) {
|
|
127
|
+
logger.debug(`[AcpBridge] stderr: ${text}`);
|
|
128
|
+
}
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
// Handle stdin errors (e.g., EPIPE if process dies)
|
|
132
|
+
proc.stdin.on('error', (err) => {
|
|
133
|
+
logger.error(`[AcpBridge] stdin error: ${err.message}`);
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
// Set up ACP connection
|
|
137
|
+
this._initializeConnection(proc, deps)
|
|
138
|
+
.then(() => {
|
|
139
|
+
this._ready = true;
|
|
140
|
+
logger.info(`[AcpBridge] Ready (PID ${proc.pid})`);
|
|
141
|
+
this.emit('ready');
|
|
142
|
+
resolve();
|
|
143
|
+
})
|
|
144
|
+
.catch((err) => {
|
|
145
|
+
if (!this._closing) {
|
|
146
|
+
reject(new Error(`ACP initialization failed: ${err.message}`));
|
|
147
|
+
}
|
|
148
|
+
});
|
|
149
|
+
});
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Initialize the ACP connection, perform handshake, and create session.
|
|
154
|
+
* @param {ChildProcess} proc - The spawned agent process
|
|
155
|
+
* @param {Object} deps - Dependencies
|
|
156
|
+
* @returns {Promise<void>}
|
|
157
|
+
*/
|
|
158
|
+
async _initializeConnection(proc, deps) {
|
|
159
|
+
const stream = deps.acp.ndJsonStream(
|
|
160
|
+
deps.Writable.toWeb(proc.stdin),
|
|
161
|
+
deps.Readable.toWeb(proc.stdout)
|
|
162
|
+
);
|
|
163
|
+
|
|
164
|
+
const bridge = this;
|
|
165
|
+
const clientHandler = {
|
|
166
|
+
sessionUpdate(params) {
|
|
167
|
+
bridge._handleSessionUpdate(params);
|
|
168
|
+
},
|
|
169
|
+
requestPermission(params) {
|
|
170
|
+
return bridge._handlePermission(params);
|
|
171
|
+
},
|
|
172
|
+
};
|
|
173
|
+
|
|
174
|
+
this._connection = new deps.acp.ClientSideConnection(
|
|
175
|
+
(_agent) => clientHandler,
|
|
176
|
+
stream
|
|
177
|
+
);
|
|
178
|
+
|
|
179
|
+
await this._connection.initialize({
|
|
180
|
+
protocolVersion: deps.acp.PROTOCOL_VERSION,
|
|
181
|
+
clientCapabilities: {},
|
|
182
|
+
clientInfo: { name: 'pair-review', version: pkgVersion },
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
if (this.resumeSessionId) {
|
|
186
|
+
// Resume a previous session — agent restores conversation history
|
|
187
|
+
await this._connection.loadSession({
|
|
188
|
+
sessionId: this.resumeSessionId,
|
|
189
|
+
cwd: this.cwd,
|
|
190
|
+
mcpServers: [],
|
|
191
|
+
});
|
|
192
|
+
this._sessionId = this.resumeSessionId;
|
|
193
|
+
logger.info(`[AcpBridge] Session resumed: ${this.resumeSessionId}`);
|
|
194
|
+
} else {
|
|
195
|
+
const { sessionId } = await this._connection.newSession({
|
|
196
|
+
cwd: this.cwd,
|
|
197
|
+
mcpServers: [],
|
|
198
|
+
});
|
|
199
|
+
this._sessionId = sessionId;
|
|
200
|
+
logger.info(`[AcpBridge] Session created: ${sessionId}`);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// Set model via ACP protocol if configured (unstable API)
|
|
204
|
+
if (this.model && this._connection.unstable_setSessionModel) {
|
|
205
|
+
try {
|
|
206
|
+
await this._connection.unstable_setSessionModel({
|
|
207
|
+
sessionId: this._sessionId,
|
|
208
|
+
modelId: this.model,
|
|
209
|
+
});
|
|
210
|
+
logger.info(`[AcpBridge] Model set: ${this.model}`);
|
|
211
|
+
} catch (err) {
|
|
212
|
+
logger.warn(`[AcpBridge] Failed to set model (agent may not support it): ${err.message}`);
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
// Emit session info once so session-manager can store the agent_session_id
|
|
217
|
+
this.emit('session', { sessionId: this._sessionId });
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* Send a user message to the ACP agent.
|
|
222
|
+
* Fire-and-forget: returns immediately, emits events as the agent responds.
|
|
223
|
+
* @param {string} content - The message text
|
|
224
|
+
*/
|
|
225
|
+
async sendMessage(content) {
|
|
226
|
+
if (!this.isReady()) {
|
|
227
|
+
throw new Error('AcpBridge is not ready');
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
// Reset accumulated text for this new turn
|
|
231
|
+
this._accumulatedText = '';
|
|
232
|
+
this._inMessage = true;
|
|
233
|
+
|
|
234
|
+
let messageContent = content;
|
|
235
|
+
if (this.systemPrompt && this._firstMessage) {
|
|
236
|
+
messageContent = this.systemPrompt + '\n\n' + content;
|
|
237
|
+
this._firstMessage = false;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
logger.debug(`[AcpBridge] Sending prompt (${messageContent.length} chars): ${messageContent.substring(0, 100)}${messageContent.length > 100 ? '...' : ''}`);
|
|
241
|
+
|
|
242
|
+
this._connection.prompt({
|
|
243
|
+
sessionId: this._sessionId,
|
|
244
|
+
prompt: [{ type: 'text', text: messageContent }],
|
|
245
|
+
})
|
|
246
|
+
.then(() => {
|
|
247
|
+
const fullText = this._accumulatedText;
|
|
248
|
+
this._accumulatedText = '';
|
|
249
|
+
this._inMessage = false;
|
|
250
|
+
logger.debug(`[AcpBridge] Prompt completed, accumulated ${fullText.length} chars`);
|
|
251
|
+
this.emit('complete', { fullText });
|
|
252
|
+
})
|
|
253
|
+
.catch((err) => {
|
|
254
|
+
this._inMessage = false;
|
|
255
|
+
logger.error(`[AcpBridge] Prompt error: ${err.message}`);
|
|
256
|
+
this.emit('error', { error: err });
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/**
|
|
262
|
+
* Abort the current operation.
|
|
263
|
+
*/
|
|
264
|
+
abort() {
|
|
265
|
+
if (!this.isReady() || !this._sessionId) return;
|
|
266
|
+
logger.debug('[AcpBridge] Sending cancel');
|
|
267
|
+
this._connection.cancel({ sessionId: this._sessionId }).catch((err) => {
|
|
268
|
+
logger.error(`[AcpBridge] Cancel error: ${err.message}`);
|
|
269
|
+
});
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/**
|
|
273
|
+
* Gracefully shut down the ACP agent process.
|
|
274
|
+
* @returns {Promise<void>}
|
|
275
|
+
*/
|
|
276
|
+
async close() {
|
|
277
|
+
if (!this._process) return;
|
|
278
|
+
|
|
279
|
+
this._closing = true;
|
|
280
|
+
|
|
281
|
+
// Cancel any in-flight prompt before tearing down
|
|
282
|
+
if (this._connection && this._sessionId) {
|
|
283
|
+
try { await this._connection.cancel({ sessionId: this._sessionId }); } catch { /* already dead */ }
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
this.removeAllListeners();
|
|
287
|
+
|
|
288
|
+
return new Promise((resolve) => {
|
|
289
|
+
const proc = this._process;
|
|
290
|
+
if (!proc) {
|
|
291
|
+
resolve();
|
|
292
|
+
return;
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
// Give the process a moment to exit gracefully, then force kill
|
|
296
|
+
const killTimeout = setTimeout(() => {
|
|
297
|
+
if (this._process) {
|
|
298
|
+
logger.warn('[AcpBridge] Force killing process');
|
|
299
|
+
this._process.kill('SIGKILL');
|
|
300
|
+
}
|
|
301
|
+
}, 3000);
|
|
302
|
+
|
|
303
|
+
const onClose = () => {
|
|
304
|
+
clearTimeout(killTimeout);
|
|
305
|
+
resolve();
|
|
306
|
+
};
|
|
307
|
+
|
|
308
|
+
proc.once('close', onClose);
|
|
309
|
+
|
|
310
|
+
// Send SIGTERM
|
|
311
|
+
proc.kill('SIGTERM');
|
|
312
|
+
});
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
/**
|
|
316
|
+
* Check if the ACP agent process is alive and ready.
|
|
317
|
+
* @returns {boolean}
|
|
318
|
+
*/
|
|
319
|
+
isReady() {
|
|
320
|
+
return this._ready && this._process !== null && !this._closing;
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
/**
|
|
324
|
+
* Check if the bridge is currently processing a message.
|
|
325
|
+
* @returns {boolean}
|
|
326
|
+
*/
|
|
327
|
+
isBusy() {
|
|
328
|
+
return this._inMessage;
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
// ---------------------------------------------------------------------------
|
|
332
|
+
// Private methods
|
|
333
|
+
// ---------------------------------------------------------------------------
|
|
334
|
+
|
|
335
|
+
/**
|
|
336
|
+
* Handle a sessionUpdate notification from the ACP agent.
|
|
337
|
+
* @param {Object} params - { sessionId, update }
|
|
338
|
+
*/
|
|
339
|
+
_handleSessionUpdate(params) {
|
|
340
|
+
const update = params.update;
|
|
341
|
+
if (!update) return;
|
|
342
|
+
|
|
343
|
+
const type = update.sessionUpdate;
|
|
344
|
+
|
|
345
|
+
switch (type) {
|
|
346
|
+
case 'agent_message_chunk':
|
|
347
|
+
this._handleMessageChunk(update);
|
|
348
|
+
break;
|
|
349
|
+
|
|
350
|
+
case 'tool_call':
|
|
351
|
+
this.emit('tool_use', {
|
|
352
|
+
toolCallId: update.toolCallId,
|
|
353
|
+
toolName: update.title,
|
|
354
|
+
status: 'start',
|
|
355
|
+
kind: update.kind,
|
|
356
|
+
});
|
|
357
|
+
break;
|
|
358
|
+
|
|
359
|
+
case 'tool_call_update':
|
|
360
|
+
this._handleToolCallUpdate(update);
|
|
361
|
+
break;
|
|
362
|
+
|
|
363
|
+
case 'plan':
|
|
364
|
+
this.emit('status', { status: 'working' });
|
|
365
|
+
break;
|
|
366
|
+
|
|
367
|
+
default:
|
|
368
|
+
logger.debug(`[AcpBridge] Unhandled sessionUpdate type: ${type}`);
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
/**
|
|
373
|
+
* Handle an agent_message_chunk update containing streaming content.
|
|
374
|
+
* @param {Object} update - The agent_message_chunk update
|
|
375
|
+
*/
|
|
376
|
+
_handleMessageChunk(update) {
|
|
377
|
+
const content = update.content;
|
|
378
|
+
if (!content) return;
|
|
379
|
+
|
|
380
|
+
if (content.type === 'text' && content.text) {
|
|
381
|
+
// ACP sends chunks as fragments of a continuous stream,
|
|
382
|
+
// so accumulate directly without paragraph separation.
|
|
383
|
+
this._accumulatedText += content.text;
|
|
384
|
+
this.emit('delta', { text: content.text });
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
/**
|
|
389
|
+
* Handle a tool_call_update, mapping ACP status to bridge status.
|
|
390
|
+
* @param {Object} update - The tool_call_update
|
|
391
|
+
*/
|
|
392
|
+
_handleToolCallUpdate(update) {
|
|
393
|
+
let status;
|
|
394
|
+
switch (update.status) {
|
|
395
|
+
case 'completed':
|
|
396
|
+
status = 'end';
|
|
397
|
+
break;
|
|
398
|
+
case 'in_progress':
|
|
399
|
+
status = 'update';
|
|
400
|
+
break;
|
|
401
|
+
case 'failed':
|
|
402
|
+
status = 'end';
|
|
403
|
+
break;
|
|
404
|
+
default:
|
|
405
|
+
status = 'update';
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
this.emit('tool_use', {
|
|
409
|
+
toolCallId: update.toolCallId,
|
|
410
|
+
toolName: update.title,
|
|
411
|
+
status,
|
|
412
|
+
});
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
/**
|
|
416
|
+
* Handle a permission request from the ACP agent.
|
|
417
|
+
* Auto-approves by selecting the allow_once or allow_always option.
|
|
418
|
+
* @param {Object} params - { sessionId, toolCall, options }
|
|
419
|
+
* @returns {Object} - Permission outcome
|
|
420
|
+
*/
|
|
421
|
+
_handlePermission(params) {
|
|
422
|
+
const options = params.options || [];
|
|
423
|
+
|
|
424
|
+
// Prefer allow_once, fall back to allow_always
|
|
425
|
+
const allowOnce = options.find((o) => o.kind === 'allow_once');
|
|
426
|
+
if (allowOnce) {
|
|
427
|
+
logger.debug(`[AcpBridge] Auto-approving permission (allow_once): ${allowOnce.name || allowOnce.optionId}`);
|
|
428
|
+
return { outcome: { outcome: 'selected', optionId: allowOnce.optionId } };
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
const allowAlways = options.find((o) => o.kind === 'allow_always');
|
|
432
|
+
if (allowAlways) {
|
|
433
|
+
logger.debug(`[AcpBridge] Auto-approving permission (allow_always): ${allowAlways.name || allowAlways.optionId}`);
|
|
434
|
+
return { outcome: { outcome: 'selected', optionId: allowAlways.optionId } };
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
logger.warn('[AcpBridge] No allow option found, cancelling permission request');
|
|
438
|
+
return { outcome: { outcome: 'cancelled' } };
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
module.exports = AcpBridge;
|