@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.
Files changed (45) 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 +287 -14
  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/pr.js +194 -5
  19. package/public/local.html +8 -1
  20. package/public/pr.html +17 -2
  21. package/src/ai/codex-provider.js +14 -2
  22. package/src/ai/copilot-provider.js +1 -10
  23. package/src/ai/cursor-agent-provider.js +1 -10
  24. package/src/ai/gemini-provider.js +8 -17
  25. package/src/chat/acp-bridge.js +442 -0
  26. package/src/chat/api-reference.js +539 -0
  27. package/src/chat/chat-providers.js +290 -0
  28. package/src/chat/claude-code-bridge.js +499 -0
  29. package/src/chat/codex-bridge.js +601 -0
  30. package/src/chat/pi-bridge.js +56 -3
  31. package/src/chat/prompt-builder.js +12 -11
  32. package/src/chat/session-manager.js +110 -29
  33. package/src/config.js +4 -2
  34. package/src/database.js +50 -2
  35. package/src/github/client.js +43 -0
  36. package/src/routes/chat.js +60 -27
  37. package/src/routes/config.js +24 -1
  38. package/src/routes/github-collections.js +126 -0
  39. package/src/routes/mcp.js +2 -1
  40. package/src/routes/pr.js +166 -2
  41. package/src/routes/reviews.js +2 -1
  42. package/src/routes/shared.js +70 -49
  43. package/src/server.js +27 -1
  44. package/src/utils/safe-parse-json.js +19 -0
  45. 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) state = config.pi_available ? 'available' : 'unavailable';
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
- <h1 class="pr-title" id="pr-title-text">Loading...</h1>
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">
@@ -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.extra_args - Additional CLI arguments
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
- const baseArgs = ['exec', '-m', model, '--json', ...sandboxArgs, '-'];
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, gemini-3-pro-preview, gpt-5.2-codex, gpt-5.3-codex,
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-pro, gemini-3.1-pro): Recommended for most reviews
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-preview'],
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-preview'],
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: 'Latest',
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, use Gemini's --yolo flag to auto-approve all tools
128
- // (including write operations and destructive shell commands)
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;