@in-the-loop-labs/pair-review 3.1.3 → 3.1.4

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@in-the-loop-labs/pair-review",
3
- "version": "3.1.3",
3
+ "version": "3.1.4",
4
4
  "description": "Your AI-powered code review partner - Close the feedback loop with AI coding agents",
5
5
  "main": "src/server.js",
6
6
  "bin": {
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pair-review",
3
- "version": "3.1.3",
3
+ "version": "3.1.4",
4
4
  "description": "pair-review app integration — Open PRs and local changes in the pair-review web UI, run server-side AI analysis, and address review feedback. Requires the pair-review MCP server.",
5
5
  "author": {
6
6
  "name": "in-the-loop-labs",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "code-critic",
3
- "version": "3.1.3",
3
+ "version": "3.1.4",
4
4
  "description": "AI-powered code review analysis — Run three-level AI analysis and implement-review-fix loops directly in your coding agent. Works standalone, no server required.",
5
5
  "author": {
6
6
  "name": "in-the-loop-labs",
package/public/css/pr.css CHANGED
@@ -7468,6 +7468,90 @@ body.resizing * {
7468
7468
  display: table-row;
7469
7469
  }
7470
7470
 
7471
+ /* When minimize mode is active, hide file-level comment cards */
7472
+ .comments-minimized .file-comment-card {
7473
+ display: none;
7474
+ }
7475
+
7476
+ /* Per-file expansion override — clicking a file-header indicator reveals that file's cards */
7477
+ .comments-minimized .file-comments-zone.file-comments-expanded .file-comment-card {
7478
+ display: block;
7479
+ }
7480
+
7481
+ /* File-header comment indicator — matches header button sizing, only visible when minimized */
7482
+ .file-comment-indicator {
7483
+ display: none;
7484
+ }
7485
+
7486
+ .comments-minimized .file-comment-indicator {
7487
+ display: inline-flex;
7488
+ align-items: center;
7489
+ justify-content: center;
7490
+ gap: 3px;
7491
+ width: 28px;
7492
+ height: 28px;
7493
+ padding: 0;
7494
+ background: transparent;
7495
+ border: 1px solid transparent;
7496
+ border-radius: 6px;
7497
+ cursor: pointer;
7498
+ color: var(--color-text-secondary, #656d76);
7499
+ transition: background-color 0.15s, color 0.15s, border-color 0.15s;
7500
+ flex-shrink: 0;
7501
+ font-size: 11px;
7502
+ line-height: 1;
7503
+ }
7504
+
7505
+ .comments-minimized .file-comment-indicator:has(.indicator-user):hover,
7506
+ .comments-minimized .file-comment-indicator:has(.indicator-adopted):hover {
7507
+ background-color: rgba(130, 80, 223, 0.1);
7508
+ border-color: var(--comment-primary, #8250df);
7509
+ }
7510
+
7511
+ .comments-minimized .file-comment-indicator:has(.indicator-ai):hover {
7512
+ background-color: rgba(217, 119, 6, 0.1);
7513
+ border-color: var(--color-accent-ai, #d97706);
7514
+ }
7515
+
7516
+ .comments-minimized .file-comment-indicator:has(.indicator-user),
7517
+ .comments-minimized .file-comment-indicator:has(.indicator-adopted) {
7518
+ border-color: var(--comment-primary, #8250df);
7519
+ color: var(--comment-primary, #8250df);
7520
+ }
7521
+
7522
+ .comments-minimized .file-comment-indicator:has(.indicator-ai) {
7523
+ border-color: var(--color-accent-ai, #d97706);
7524
+ color: var(--color-accent-ai, #d97706);
7525
+ }
7526
+
7527
+ .comments-minimized .file-comment-indicator.expanded {
7528
+ border-width: 2px;
7529
+ }
7530
+
7531
+ [data-theme="dark"] .comments-minimized .file-comment-indicator:has(.indicator-user),
7532
+ [data-theme="dark"] .comments-minimized .file-comment-indicator:has(.indicator-adopted) {
7533
+ border-color: var(--comment-primary, #a371f7);
7534
+ color: var(--comment-primary, #a371f7);
7535
+ }
7536
+
7537
+ [data-theme="dark"] .comments-minimized .file-comment-indicator:has(.indicator-ai) {
7538
+ border-color: var(--color-accent-ai, #fbbf24);
7539
+ color: var(--color-accent-ai, #fbbf24);
7540
+ }
7541
+
7542
+ [data-theme="dark"] .comments-minimized .file-comment-indicator:has(.indicator-user):hover,
7543
+ [data-theme="dark"] .comments-minimized .file-comment-indicator:has(.indicator-adopted):hover {
7544
+ background-color: rgba(163, 113, 247, 0.15);
7545
+ }
7546
+
7547
+ [data-theme="dark"] .comments-minimized .file-comment-indicator:has(.indicator-ai):hover {
7548
+ background-color: rgba(251, 191, 36, 0.15);
7549
+ }
7550
+
7551
+ [data-theme="dark"] .comments-minimized .file-comment-indicator.expanded {
7552
+ border-width: 2px;
7553
+ }
7554
+
7471
7555
  /* Indicator button on the right edge of diff code cells */
7472
7556
  .comment-indicator {
7473
7557
  position: absolute;
@@ -7530,11 +7614,11 @@ body.resizing * {
7530
7614
  }
7531
7615
 
7532
7616
  .comment-indicator .indicator-ai {
7533
- color: var(--ai-accent, #d97706);
7617
+ color: var(--color-accent-ai, #d97706);
7534
7618
  }
7535
7619
 
7536
7620
  .comment-indicator:has(.indicator-ai) {
7537
- border-color: var(--ai-accent, #d97706);
7621
+ border-color: var(--color-accent-ai, #d97706);
7538
7622
  background: rgba(217, 119, 6, 0.06);
7539
7623
  }
7540
7624
 
@@ -807,8 +807,9 @@ class AIPanel {
807
807
  const finding = this.findings.find(f => f.id === findingId);
808
808
  if (finding) {
809
809
  suggestionContext = {
810
+ suggestionId: findingId ? String(findingId) : null,
810
811
  title: finding.title || title,
811
- body: finding.body || '',
812
+ body: finding.formattedBody || finding.body || '',
812
813
  type: finding.type || '',
813
814
  file: finding.file || file,
814
815
  line_start: finding.line_start || null,
@@ -1002,6 +1003,8 @@ class AIPanel {
1002
1003
  if (targetSuggestion) {
1003
1004
  const minimizer = window.prManager?.commentMinimizer;
1004
1005
  if (minimizer?.active) {
1006
+ // Expand file-level comments so the target becomes visible
1007
+ minimizer.expandForElement(targetSuggestion);
1005
1008
  // Comments are minimized — scroll to the parent diff line instead
1006
1009
  const diffRow = minimizer.findDiffRowFor(targetSuggestion);
1007
1010
  (diffRow || targetSuggestion).scrollIntoView({ behavior: 'smooth', block: 'center' });
@@ -1069,9 +1072,9 @@ class AIPanel {
1069
1072
 
1070
1073
  if (targetElement) {
1071
1074
  const minimizer = window.prManager?.commentMinimizer;
1072
- if (minimizer?.active && !isFileLevel) {
1073
- // Comments are minimized — scroll to the parent diff line instead
1074
- const diffRow = minimizer.findDiffRowFor(targetElement);
1075
+ if (minimizer?.active) {
1076
+ minimizer.expandForElement(targetElement);
1077
+ const diffRow = isFileLevel ? null : minimizer.findDiffRowFor(targetElement);
1075
1078
  (diffRow || targetElement).scrollIntoView({ behavior: 'smooth', block: 'center' });
1076
1079
  } else {
1077
1080
  targetElement.scrollIntoView({ behavior: 'smooth', block: 'center' });
@@ -32,6 +32,7 @@ class ChatPanel {
32
32
  this._pendingContext = [];
33
33
  this._pendingContextData = [];
34
34
  this._pendingDiffStateNotifications = [];
35
+ this._pendingUserActionHints = [];
35
36
  this._contextSource = null; // 'suggestion' or 'user' — set when opened with context
36
37
  this._contextItemId = null; // suggestion ID or comment ID from context
37
38
  this._contextLineMeta = null; // { file, line_start, line_end } — set when opened with line context
@@ -667,6 +668,7 @@ class ChatPanel {
667
668
  this._pendingContext = [];
668
669
  this._pendingContextData = [];
669
670
  this._pendingDiffStateNotifications = [];
671
+ this._pendingUserActionHints = [];
670
672
  this._contextSource = null;
671
673
  this._contextItemId = null;
672
674
  this._contextLineMeta = null;
@@ -1030,6 +1032,7 @@ class ChatPanel {
1030
1032
  this._pendingContext = [];
1031
1033
  this._pendingContextData = [];
1032
1034
  this._pendingDiffStateNotifications = [];
1035
+ this._pendingUserActionHints = [];
1033
1036
  this._contextSource = null;
1034
1037
  this._contextItemId = null;
1035
1038
  this._contextLineMeta = null;
@@ -1269,12 +1272,28 @@ class ChatPanel {
1269
1272
  this._pendingDiffStateNotifications = [];
1270
1273
  }
1271
1274
 
1275
+ // Snapshot user-action-hints queue for error recovery (invisible to user, no UI cards)
1276
+ const savedUserActionHints = this._pendingUserActionHints.slice();
1277
+ let userActionPrefix = '';
1278
+ if (this._pendingUserActionHints.length > 0) {
1279
+ userActionPrefix = '[User Action Hints]\n' + this._pendingUserActionHints.join('\n');
1280
+ this._pendingUserActionHints = [];
1281
+ }
1282
+
1283
+ // Combine invisible prefixes (diff state + user action hints)
1284
+ let invisiblePrefix = '';
1285
+ if (diffStatePrefix && userActionPrefix) {
1286
+ invisiblePrefix = diffStatePrefix + '\n\n' + userActionPrefix;
1287
+ } else {
1288
+ invisiblePrefix = diffStatePrefix || userActionPrefix;
1289
+ }
1290
+
1272
1291
  const savedContext = this._pendingContext;
1273
1292
  const savedContextData = this._pendingContextData;
1274
1293
  if (this._pendingContext.length > 0) {
1275
1294
  const userContext = this._pendingContext.join('\n\n');
1276
- payload.context = diffStatePrefix
1277
- ? diffStatePrefix + '\n\n' + userContext
1295
+ payload.context = invisiblePrefix
1296
+ ? invisiblePrefix + '\n\n' + userContext
1278
1297
  : userContext;
1279
1298
  payload.contextData = this._pendingContextData;
1280
1299
  this._pendingContext = [];
@@ -1287,8 +1306,8 @@ class ChatPanel {
1287
1306
  if (btn) btn.remove();
1288
1307
  delete card.dataset.contextIndex;
1289
1308
  });
1290
- } else if (diffStatePrefix) {
1291
- payload.context = diffStatePrefix;
1309
+ } else if (invisiblePrefix) {
1310
+ payload.context = invisiblePrefix;
1292
1311
  }
1293
1312
 
1294
1313
  // Lock analysis context card (not indexed, handled separately from pending context)
@@ -1353,6 +1372,7 @@ class ChatPanel {
1353
1372
  this._pendingContext = savedContext;
1354
1373
  this._pendingContextData = savedContextData;
1355
1374
  this._pendingDiffStateNotifications = [...savedDiffState, ...this._pendingDiffStateNotifications];
1375
+ this._pendingUserActionHints = [...savedUserActionHints, ...this._pendingUserActionHints];
1356
1376
  // Restore removability on context cards that were locked before the failed send
1357
1377
  this._restoreRemovableCards();
1358
1378
  console.error('[ChatPanel] Error sending message:', error);
@@ -1371,6 +1391,16 @@ class ChatPanel {
1371
1391
  this._pendingDiffStateNotifications.push(message);
1372
1392
  }
1373
1393
 
1394
+ /**
1395
+ * Queue an invisible user-action hint for the chat agent.
1396
+ * Like diff-state notifications, these do NOT render UI cards and survive panel close.
1397
+ * Drained into the context parameter on the next sendMessage() call.
1398
+ * @param {string} message - Description of the user action (e.g., "[User Action: adopted suggestion 42]")
1399
+ */
1400
+ queueUserActionHint(message) {
1401
+ this._pendingUserActionHints.push(message);
1402
+ }
1403
+
1374
1404
  /**
1375
1405
  * Store pending context and render a compact context card in the UI.
1376
1406
  * Called when the user clicks "Ask about this" on a suggestion.
@@ -1312,6 +1312,9 @@ class CouncilProgressModal {
1312
1312
  <span class="council-voice-icon pending">\u25CB</span>
1313
1313
  <span class="council-voice-label">Consolidation</span>
1314
1314
  <span class="council-voice-status pending">Pending</span>
1315
+ <div class="council-voice-detail">
1316
+ <div class="council-voice-snippet" style="display: none;"></div>
1317
+ </div>
1315
1318
  </div>
1316
1319
  `;
1317
1320
  }
@@ -371,6 +371,8 @@ class SuggestionNavigator {
371
371
  if (suggestionEl) {
372
372
  const minimizer = window.prManager?.commentMinimizer;
373
373
  if (minimizer?.active) {
374
+ // Expand file-level comments so the target becomes visible
375
+ minimizer.expandForElement(suggestionEl);
374
376
  // Comments are minimized — scroll to the parent diff line instead
375
377
  const diffRow = minimizer.findDiffRowFor(suggestionEl);
376
378
  if (diffRow) {
@@ -503,8 +503,15 @@ class CommentManager {
503
503
  // Refresh minimize-mode indicators so the new comment is reflected
504
504
  if (window.prManager?.commentMinimizer) {
505
505
  window.prManager.commentMinimizer.refreshIndicators();
506
+ // Auto-expand so the new comment stays visible in minimize mode
507
+ const newRow = document.querySelector(`.user-comment-row[data-comment-id="${commentData.id}"]`);
508
+ if (newRow) {
509
+ window.prManager.commentMinimizer.expandForElement(newRow);
510
+ }
506
511
  }
507
512
 
513
+ window.chatPanel?.queueUserActionHint(`[User Action: created comment ${result.commentId}]`);
514
+
508
515
  } catch (error) {
509
516
  console.error('Error saving comment:', error);
510
517
  alert('Failed to save comment');
@@ -7,7 +7,10 @@
7
7
  * are injected on the right edge of each diff line that has comments, showing
8
8
  * a person icon (user comments) or sparkles icon (AI suggestions).
9
9
  *
10
- * Clicking an indicator toggles visibility of that line's comments only.
10
+ * File-level comments (.file-comment-card inside .file-comments-zone) are also
11
+ * hidden, with an indicator button injected into the file header bar.
12
+ *
13
+ * Clicking an indicator toggles visibility of that line's or file's comments.
11
14
  */
12
15
 
13
16
  class CommentMinimizer {
@@ -24,6 +27,8 @@ class CommentMinimizer {
24
27
  this._active = false;
25
28
  // Track which diff lines have been expanded by the user (Set of diff row elements)
26
29
  this._expandedLines = new Set();
30
+ // Track which file-comments-zones have been expanded (Set of zone elements)
31
+ this._expandedFiles = new Set();
27
32
  }
28
33
 
29
34
  /** @returns {boolean} Whether minimize mode is active */
@@ -38,6 +43,7 @@ class CommentMinimizer {
38
43
  setMinimized(minimized) {
39
44
  this._active = minimized;
40
45
  this._expandedLines.clear();
46
+ this._expandedFiles.clear();
41
47
 
42
48
  const diffContainer = document.getElementById('diff-container');
43
49
  if (!diffContainer) return;
@@ -50,6 +56,8 @@ class CommentMinimizer {
50
56
  this._removeAllIndicators();
51
57
  // Remove any per-line expansion overrides
52
58
  document.querySelectorAll('.comment-expanded').forEach(el => el.classList.remove('comment-expanded'));
59
+ // Remove any per-file expansion overrides
60
+ document.querySelectorAll('.file-comments-expanded').forEach(el => el.classList.remove('file-comments-expanded'));
53
61
  }
54
62
  }
55
63
 
@@ -103,10 +111,13 @@ class CommentMinimizer {
103
111
  lineMap.set(diffRow, entry);
104
112
  }
105
113
 
106
- // Inject indicators
114
+ // Inject line-level indicators
107
115
  for (const [diffRow, info] of lineMap) {
108
116
  this._injectIndicator(diffRow, info);
109
117
  }
118
+
119
+ // Scan file-comments-zones and inject file-header indicators
120
+ this._refreshFileIndicators();
110
121
  }
111
122
 
112
123
  /**
@@ -266,7 +277,22 @@ class CommentMinimizer {
266
277
  expandForElement(element) {
267
278
  if (!this._active) return;
268
279
 
269
- // Find the containing comment/suggestion row
280
+ // Check if this element is inside a file-comments-zone (file-level comment)
281
+ const zone = element.closest('.file-comments-zone');
282
+ if (zone) {
283
+ if (this._expandedFiles.has(zone)) return; // already expanded
284
+ this._expandedFiles.add(zone);
285
+ zone.classList.add('file-comments-expanded');
286
+ // Update the file-header indicator button
287
+ const wrapper = zone.closest('.d2h-file-wrapper');
288
+ const btn = wrapper?.querySelector('.d2h-file-header .file-comment-indicator');
289
+ if (btn) {
290
+ btn.classList.add('expanded');
291
+ }
292
+ return;
293
+ }
294
+
295
+ // Line-level: find the containing comment/suggestion row
270
296
  const commentRow = element.closest('.user-comment-row, .ai-suggestion-row') || element;
271
297
  if (!commentRow.classList.contains('user-comment-row') && !commentRow.classList.contains('ai-suggestion-row')) {
272
298
  return;
@@ -290,9 +316,130 @@ class CommentMinimizer {
290
316
  }
291
317
  }
292
318
 
293
- /** Remove all indicator buttons from the DOM. */
319
+ // ---------------------------------------------------------------------------
320
+ // File-level comment indicators
321
+ // ---------------------------------------------------------------------------
322
+
323
+ /**
324
+ * Scan all file-comments-zones and inject indicator buttons into file headers.
325
+ */
326
+ _refreshFileIndicators() {
327
+ const zones = document.querySelectorAll('.file-comments-zone');
328
+ for (const zone of zones) {
329
+ const cards = zone.querySelectorAll('.file-comment-card');
330
+ if (cards.length === 0) continue;
331
+
332
+ // Count comment types
333
+ const info = { hasUser: false, hasAI: false, hasAdopted: false, userCount: 0, aiCount: 0, adoptedCount: 0 };
334
+ for (const card of cards) {
335
+ // Skip collapsed cards (adopted/dismissed originals remain in DOM)
336
+ if (card.classList.contains('collapsed')) continue;
337
+
338
+ if (card.classList.contains('ai-suggestion')) {
339
+ info.hasAI = true;
340
+ info.aiCount++;
341
+ } else if (card.classList.contains('user-comment')) {
342
+ if (card.classList.contains('adopted-comment')) {
343
+ info.hasAdopted = true;
344
+ info.adoptedCount++;
345
+ } else {
346
+ info.hasUser = true;
347
+ info.userCount++;
348
+ }
349
+ }
350
+ }
351
+
352
+ if (info.userCount + info.aiCount + info.adoptedCount === 0) continue;
353
+
354
+ // Find the file header — zone and header are siblings inside .d2h-file-wrapper
355
+ const wrapper = zone.closest('.d2h-file-wrapper');
356
+ const header = wrapper?.querySelector('.d2h-file-header');
357
+ if (!header) continue;
358
+
359
+ this._injectFileIndicator(header, zone, info);
360
+ }
361
+ }
362
+
363
+ /**
364
+ * Inject an indicator button into a file header, positioned before the comment button.
365
+ * @param {HTMLElement} header - The .d2h-file-header element
366
+ * @param {HTMLElement} zone - The .file-comments-zone element
367
+ * @param {Object} info - { hasUser, hasAI, hasAdopted, userCount, aiCount, adoptedCount }
368
+ */
369
+ _injectFileIndicator(header, zone, info) {
370
+ // Don't double-inject
371
+ if (header.querySelector('.file-comment-indicator')) return;
372
+
373
+ const btn = document.createElement('button');
374
+ btn.className = 'file-comment-indicator';
375
+ btn.type = 'button';
376
+
377
+ // Build icon — pick the dominant type icon
378
+ const icons = [];
379
+ if (info.hasUser) {
380
+ icons.push(`<span class="indicator-icon indicator-user">${CommentMinimizer.PERSON_ICON}</span>`);
381
+ }
382
+ if (info.hasAdopted) {
383
+ icons.push(`<span class="indicator-icon indicator-adopted">${CommentMinimizer.AI_COMMENT_ICON}</span>`);
384
+ }
385
+ if (info.hasAI) {
386
+ icons.push(`<span class="indicator-icon indicator-ai">${CommentMinimizer.SPARKLES_ICON}</span>`);
387
+ }
388
+
389
+ const total = info.userCount + info.adoptedCount + info.aiCount;
390
+ const countBadge = total > 1 ? `<span class="indicator-count">${total}</span>` : '';
391
+
392
+ btn.innerHTML = icons.join('') + countBadge;
393
+
394
+ const totalLabel = [];
395
+ if (info.userCount) totalLabel.push(`${info.userCount} file comment${info.userCount !== 1 ? 's' : ''}`);
396
+ if (info.adoptedCount) totalLabel.push(`${info.adoptedCount} adopted`);
397
+ if (info.aiCount) totalLabel.push(`${info.aiCount} suggestion${info.aiCount !== 1 ? 's' : ''}`);
398
+ btn.title = totalLabel.join(', ');
399
+
400
+ // Restore expanded state
401
+ if (this._expandedFiles.has(zone)) {
402
+ btn.classList.add('expanded');
403
+ }
404
+
405
+ btn.addEventListener('click', (e) => {
406
+ e.stopPropagation();
407
+ e.preventDefault();
408
+ this._toggleFileComments(zone, btn);
409
+ });
410
+
411
+ // Insert before the file-header-comment-btn if present, otherwise append
412
+ const commentBtn = header.querySelector('.file-header-comment-btn');
413
+ if (commentBtn) {
414
+ header.insertBefore(btn, commentBtn);
415
+ } else {
416
+ header.appendChild(btn);
417
+ }
418
+ }
419
+
420
+ /**
421
+ * Toggle visibility of file-level comments for a specific file.
422
+ * @param {HTMLElement} zone - The .file-comments-zone element
423
+ * @param {HTMLElement} btn - The indicator button
424
+ */
425
+ _toggleFileComments(zone, btn) {
426
+ const isExpanded = this._expandedFiles.has(zone);
427
+
428
+ if (isExpanded) {
429
+ this._expandedFiles.delete(zone);
430
+ btn.classList.remove('expanded');
431
+ zone.classList.remove('file-comments-expanded');
432
+ } else {
433
+ this._expandedFiles.add(zone);
434
+ btn.classList.add('expanded');
435
+ zone.classList.add('file-comments-expanded');
436
+ }
437
+ }
438
+
439
+ /** Remove all indicator buttons (both line-level and file-level) from the DOM. */
294
440
  _removeAllIndicators() {
295
441
  document.querySelectorAll('.comment-indicator').forEach(btn => btn.remove());
442
+ document.querySelectorAll('.file-comment-indicator').forEach(btn => btn.remove());
296
443
  }
297
444
  }
298
445
 
@@ -17,12 +17,15 @@ class FileCommentManager {
17
17
  if (chatBtn && window.chatPanel) {
18
18
  e.stopPropagation();
19
19
  const suggestionCard = chatBtn.closest('.ai-suggestion');
20
- const bodyText = suggestionCard?.dataset?.originalBody
21
- ? JSON.parse(suggestionCard.dataset.originalBody) : '';
20
+ const bodyText = suggestionCard?.dataset?.formattedBody
21
+ ? JSON.parse(suggestionCard.dataset.formattedBody)
22
+ : suggestionCard?.dataset?.originalBody
23
+ ? JSON.parse(suggestionCard.dataset.originalBody) : '';
22
24
  window.chatPanel.open({
23
25
  reviewId: this.prManager?.currentPR?.id,
24
26
  suggestionId: chatBtn.dataset.suggestionId,
25
27
  suggestionContext: {
28
+ suggestionId: chatBtn.dataset.suggestionId || null,
26
29
  title: chatBtn.dataset.title || '',
27
30
  body: bodyText,
28
31
  type: suggestionCard?.querySelector('.ai-suggestion-badge')?.dataset?.type || '',
@@ -312,6 +315,16 @@ class FileCommentManager {
312
315
  // Update count badge
313
316
  this.updateCommentCount(zone);
314
317
 
318
+ // Refresh minimize-mode indicators so file-header counts stay current
319
+ if (this.prManager?.commentMinimizer) {
320
+ this.prManager.commentMinimizer.refreshIndicators();
321
+ // Auto-expand so the new comment stays visible in minimize mode
322
+ const newCard = zone.querySelector(`.file-comment-card[data-comment-id="${commentData.id}"]`);
323
+ if (newCard) {
324
+ this.prManager.commentMinimizer.expandForElement(newCard);
325
+ }
326
+ }
327
+
315
328
  // Notify AI Panel if available
316
329
  if (window.aiPanel?.addComment) {
317
330
  window.aiPanel.addComment(commentData);
@@ -322,6 +335,8 @@ class FileCommentManager {
322
335
  this.prManager.updateCommentCount();
323
336
  }
324
337
 
338
+ window.chatPanel?.queueUserActionHint(`[User Action: created comment ${result.commentId}]`);
339
+
325
340
  } catch (error) {
326
341
  console.error('Error saving file-level comment:', error);
327
342
  if (window.toast) {
@@ -432,6 +447,7 @@ class FileCommentManager {
432
447
  // Store original markdown body for adopt functionality via extractSuggestionData
433
448
  // Use JSON.stringify to preserve newlines and special characters (matches line-level suggestions)
434
449
  card.dataset.originalBody = JSON.stringify(suggestion.body || '');
450
+ card.dataset.formattedBody = JSON.stringify(suggestion.formattedBody || '');
435
451
 
436
452
  // Store target info on the card for reliable retrieval in getFileAndLineInfo
437
453
  // File-level suggestions don't have line numbers, just the file name
@@ -604,6 +620,16 @@ class FileCommentManager {
604
620
  this.displayUserComment(zone, commentData);
605
621
  this.updateCommentCount(zone);
606
622
 
623
+ // Refresh minimize-mode indicators so file-header counts stay current
624
+ if (this.prManager?.commentMinimizer) {
625
+ this.prManager.commentMinimizer.refreshIndicators();
626
+ // Auto-expand so the new comment stays visible in minimize mode
627
+ const newCard = zone.querySelector(`.file-comment-card[data-comment-id="${commentData.id}"]`);
628
+ if (newCard) {
629
+ this.prManager.commentMinimizer.expandForElement(newCard);
630
+ }
631
+ }
632
+
607
633
  // Update parent comment count for Preview button
608
634
  if (this.prManager?.updateCommentCount) {
609
635
  this.prManager.updateCommentCount();
@@ -619,6 +645,8 @@ class FileCommentManager {
619
645
  window.aiPanel.updateFindingStatus(suggestion.id, 'adopted');
620
646
  }
621
647
 
648
+ window.chatPanel?.queueUserActionHint(`[User Action: adopted suggestion ${suggestion.id}]`);
649
+
622
650
  } catch (error) {
623
651
  console.error('Error adopting suggestion:', error);
624
652
  if (window.toast) {
@@ -657,11 +685,18 @@ class FileCommentManager {
657
685
 
658
686
  this.updateCommentCount(zone);
659
687
 
688
+ // Refresh minimize-mode indicators so file-header counts stay current
689
+ if (this.prManager?.commentMinimizer) {
690
+ this.prManager.commentMinimizer.refreshIndicators();
691
+ }
692
+
660
693
  // Update finding status in AI Panel (mark suggestion as dismissed)
661
694
  if (window.aiPanel?.updateFindingStatus) {
662
695
  window.aiPanel.updateFindingStatus(suggestionId, 'dismissed');
663
696
  }
664
697
 
698
+ window.chatPanel?.queueUserActionHint(`[User Action: dismissed suggestion ${suggestionId}]`);
699
+
665
700
  } catch (error) {
666
701
  console.error('Error dismissing suggestion:', error);
667
702
  if (window.toast) {
@@ -703,6 +738,13 @@ class FileCommentManager {
703
738
  // Update comment count (for consistency with dismissAISuggestion)
704
739
  this.updateCommentCount(zone);
705
740
 
741
+ // Refresh minimize-mode indicators so file-header counts stay current
742
+ if (this.prManager?.commentMinimizer) {
743
+ this.prManager.commentMinimizer.refreshIndicators();
744
+ }
745
+
746
+ window.chatPanel?.queueUserActionHint(`[User Action: restored suggestion ${suggestionId}]`);
747
+
706
748
  } catch (error) {
707
749
  console.error('Error restoring suggestion:', error);
708
750
  if (window.toast) {
@@ -848,6 +890,16 @@ class FileCommentManager {
848
890
  this.displayUserComment(zone, commentData);
849
891
  this.updateCommentCount(zone);
850
892
 
893
+ // Refresh minimize-mode indicators so file-header counts stay current
894
+ if (this.prManager?.commentMinimizer) {
895
+ this.prManager.commentMinimizer.refreshIndicators();
896
+ // Auto-expand so the new comment stays visible in minimize mode
897
+ const newCard = zone.querySelector(`.file-comment-card[data-comment-id="${commentData.id}"]`);
898
+ if (newCard) {
899
+ this.prManager.commentMinimizer.expandForElement(newCard);
900
+ }
901
+ }
902
+
851
903
  // Update parent comment count for Preview button
852
904
  if (this.prManager?.updateCommentCount) {
853
905
  this.prManager.updateCommentCount();
@@ -863,6 +915,8 @@ class FileCommentManager {
863
915
  window.aiPanel.updateFindingStatus(suggestion.id, 'adopted');
864
916
  }
865
917
 
918
+ window.chatPanel?.queueUserActionHint(`[User Action: adopted suggestion ${suggestion.id}]`);
919
+
866
920
  } catch (error) {
867
921
  console.error('Error adopting suggestion with edit:', error);
868
922
  if (window.toast) {
@@ -1012,6 +1066,11 @@ class FileCommentManager {
1012
1066
 
1013
1067
  this.updateCommentCount(zone);
1014
1068
 
1069
+ // Refresh minimize-mode indicators so file-header counts stay current
1070
+ if (this.prManager?.commentMinimizer) {
1071
+ this.prManager.commentMinimizer.refreshIndicators();
1072
+ }
1073
+
1015
1074
  // Update parent comment count
1016
1075
  if (this.prManager?.updateCommentCount) {
1017
1076
  this.prManager.updateCommentCount();
@@ -1029,6 +1088,11 @@ class FileCommentManager {
1029
1088
  window.aiPanel.updateFindingStatus(apiResult.dismissedSuggestionId, 'dismissed');
1030
1089
  }
1031
1090
 
1091
+ if (apiResult.dismissedSuggestionId) {
1092
+ window.chatPanel?.queueUserActionHint(`[User Action: dismissed suggestion ${apiResult.dismissedSuggestionId}]`);
1093
+ }
1094
+ window.chatPanel?.queueUserActionHint(`[User Action: dismissed comment ${commentId}]`);
1095
+
1032
1096
  } catch (error) {
1033
1097
  console.error('Error deleting comment:', error);
1034
1098
  if (window.toast) {
@@ -24,8 +24,9 @@ class SuggestionManager {
24
24
  reviewId: this.prManager?.currentPR?.id,
25
25
  suggestionId: chatBtn.dataset.suggestionId,
26
26
  suggestionContext: {
27
+ suggestionId: chatBtn.dataset.suggestionId || null,
27
28
  title: chatBtn.dataset.title || suggestionData.suggestionTitle || '',
28
- body: suggestionData.suggestionText || '',
29
+ body: suggestionData.formattedBody || suggestionData.suggestionText || '',
29
30
  type: suggestionData.suggestionType || '',
30
31
  file: chatBtn.dataset.file || '',
31
32
  line_start: suggestionDiv?.dataset?.lineNumber ? parseInt(suggestionDiv.dataset.lineNumber) : null,
package/public/js/pr.js CHANGED
@@ -2542,6 +2542,11 @@ class PRManager {
2542
2542
  if (window.toast) {
2543
2543
  window.toast.showSuccess('Comment dismissed');
2544
2544
  }
2545
+
2546
+ if (apiResult.dismissedSuggestionId) {
2547
+ window.chatPanel?.queueUserActionHint(`[User Action: dismissed suggestion ${apiResult.dismissedSuggestionId}]`);
2548
+ }
2549
+ window.chatPanel?.queueUserActionHint(`[User Action: dismissed comment ${commentId}]`);
2545
2550
  } catch (error) {
2546
2551
  console.error('Error deleting comment:', error);
2547
2552
  if (window.toast) {
@@ -2570,6 +2575,8 @@ class PRManager {
2570
2575
  if (window.toast) {
2571
2576
  window.toast.showSuccess('Comment restored');
2572
2577
  }
2578
+
2579
+ window.chatPanel?.queueUserActionHint(`[User Action: restored comment ${commentId}]`);
2573
2580
  } catch (error) {
2574
2581
  console.error('Error restoring comment:', error);
2575
2582
  if (window.toast) {
@@ -3050,6 +3057,7 @@ class PRManager {
3050
3057
  });
3051
3058
  this.displayUserComment(newComment, suggestionRow);
3052
3059
  this._notifyAdoption(suggestionId, newComment);
3060
+ window.chatPanel?.queueUserActionHint(`[User Action: adopted suggestion ${suggestionId}]`);
3053
3061
  } catch (error) {
3054
3062
  console.error('Error saving edited suggestion:', error);
3055
3063
  alert(`Failed to save suggestion: ${error.message}`);
@@ -3095,6 +3103,7 @@ class PRManager {
3095
3103
 
3096
3104
  this.displayUserComment(result.newComment, result.suggestionRow);
3097
3105
  this._notifyAdoption(suggestionId, result.newComment);
3106
+ window.chatPanel?.queueUserActionHint(`[User Action: adopted suggestion ${suggestionId}]`);
3098
3107
  } catch (error) {
3099
3108
  console.error('Error adopting suggestion:', error);
3100
3109
  alert(`Failed to adopt suggestion: ${error.message}`);
@@ -3158,6 +3167,8 @@ class PRManager {
3158
3167
  if (this.commentMinimizer) {
3159
3168
  this.commentMinimizer.refreshIndicators();
3160
3169
  }
3170
+
3171
+ window.chatPanel?.queueUserActionHint(`[User Action: dismissed suggestion ${suggestionId}]`);
3161
3172
  } catch (error) {
3162
3173
  console.error('Error dismissing suggestion:', error);
3163
3174
  alert('Failed to dismiss suggestion');
@@ -3213,6 +3224,8 @@ class PRManager {
3213
3224
  if (this.commentMinimizer) {
3214
3225
  this.commentMinimizer.refreshIndicators();
3215
3226
  }
3227
+
3228
+ window.chatPanel?.queueUserActionHint(`[User Action: restored suggestion ${suggestionId}]`);
3216
3229
  } catch (error) {
3217
3230
  console.error('Error restoring suggestion:', error);
3218
3231
  alert('Failed to restore suggestion');
@@ -22,23 +22,13 @@ const BIN_DIR = path.join(__dirname, '..', '..', 'bin');
22
22
  const CLAUDE_MODELS = [
23
23
  {
24
24
  id: 'haiku',
25
- name: 'Haiku 4.5',
25
+ name: 'Haiku 4.6',
26
26
  tier: 'fast',
27
27
  tagline: 'Lightning Fast',
28
28
  description: 'Quick analysis for simple changes',
29
29
  badge: 'Fastest',
30
30
  badgeClass: 'badge-speed'
31
31
  },
32
- {
33
- id: 'sonnet-4.5',
34
- cli_model: 'claude-sonnet-4.5',
35
- name: 'Sonnet 4.5',
36
- tier: 'balanced',
37
- tagline: 'Previous Gen',
38
- description: 'Sonnet 4.5 — previous generation balanced model',
39
- badge: 'Previous Gen',
40
- badgeClass: 'badge-balanced'
41
- },
42
32
  {
43
33
  id: 'sonnet-4.6',
44
34
  cli_model: 'claude-sonnet-4-6',
@@ -21,27 +21,29 @@ const BIN_DIR = path.join(__dirname, '..', '..', 'bin');
21
21
  * Codex model definitions with tier mappings
22
22
  *
23
23
  * Based on OpenAI Codex Models guide (developers.openai.com/codex/models)
24
- * - gpt-5.1-codex-mini: Smaller, cost-effective variant for quick scans
25
- * - gpt-5.2-codex: Advanced coding model for everyday reviews, good reasoning/cost balance
26
- * - gpt-5.3-codex: Capable agentic coding model with frontier performance and reasoning
27
- * - gpt-5.4: Latest generation with enhanced reasoning depth
24
+ * - gpt-5.4-nano: Cheapest model ($0.20/$1.25 per MTok), good for surface scans
25
+ * - gpt-5.4-mini: Fast with 400k context ($0.75/$4.50 per MTok)
26
+ * - gpt-5.4: Flagship model combining coding, reasoning, and agentic workflows
27
+ * - gpt-5.3-codex: Industry-leading coding model for complex engineering tasks
28
+ *
29
+ * Deprecated (April 2026): gpt-5.1-codex-mini, gpt-5.1-codex-max, gpt-5.1-codex
28
30
  */
29
31
  const CODEX_MODELS = [
30
32
  {
31
- id: 'gpt-5.1-codex-mini',
32
- name: 'GPT-5.1 Mini',
33
+ id: 'gpt-5.4-nano',
34
+ name: 'GPT-5.4 Nano',
33
35
  tier: 'fast',
34
- tagline: 'Blazing Fast',
35
- description: 'Quick, low-cost reviews for style issues, obvious bugs, and lint-level feedback.',
36
- badge: 'Fastest',
36
+ tagline: 'Cheapest',
37
+ description: 'Ultra-low-cost surface scans for style issues, obvious bugs, and lint-level feedback.',
38
+ badge: 'Cheapest',
37
39
  badgeClass: 'badge-speed'
38
40
  },
39
41
  {
40
- id: 'gpt-5.2-codex',
41
- name: 'GPT-5.2 Codex',
42
+ id: 'gpt-5.4-mini',
43
+ name: 'GPT-5.4 Mini',
42
44
  tier: 'balanced',
43
45
  tagline: 'Best Balance',
44
- description: 'Strong everyday reviewer—good reasoning and code understanding for PR-sized changes without top-tier cost.',
46
+ description: 'Fast reviews with 400k context—good balance of speed and capability for everyday PR review.',
45
47
  badge: 'Recommended',
46
48
  badgeClass: 'badge-recommended',
47
49
  default: true
@@ -51,7 +53,7 @@ const CODEX_MODELS = [
51
53
  name: 'GPT-5.3 Codex',
52
54
  tier: 'thorough',
53
55
  tagline: 'Deep Review',
54
- description: 'Capable agentic coding model—combines frontier coding performance with strong reasoning for cross-file analysis.',
56
+ description: 'Industry-leading coding model—frontier performance with strong reasoning for cross-file analysis.',
55
57
  badge: 'Thorough',
56
58
  badgeClass: 'badge-power'
57
59
  },
@@ -60,7 +62,7 @@ const CODEX_MODELS = [
60
62
  name: 'GPT-5.4',
61
63
  tier: 'thorough',
62
64
  tagline: 'Latest Gen',
63
- description: 'Latest generation model with enhanced reasoning depth for complex architectural reviews.',
65
+ description: 'Flagship model combining coding, reasoning, and agentic workflows for complex architectural reviews.',
64
66
  badge: 'Most Thorough',
65
67
  badgeClass: 'badge-power'
66
68
  }
@@ -76,7 +78,7 @@ class CodexProvider extends AIProvider {
76
78
  * @param {Object} configOverrides.env - Additional environment variables
77
79
  * @param {Object[]} configOverrides.models - Custom model definitions
78
80
  */
79
- constructor(model = 'gpt-5.2-codex', configOverrides = {}) {
81
+ constructor(model = 'gpt-5.4-mini', configOverrides = {}) {
80
82
  super(model);
81
83
 
82
84
  // Command precedence: ENV > config > default
@@ -698,7 +700,7 @@ class CodexProvider extends AIProvider {
698
700
  }
699
701
 
700
702
  static getDefaultModel() {
701
- return 'gpt-5.2-codex';
703
+ return 'gpt-5.4-mini';
702
704
  }
703
705
 
704
706
  static getInstallInstructions() {
@@ -21,21 +21,30 @@ const BIN_DIR = path.join(__dirname, '..', '..', 'bin');
21
21
  *
22
22
  * GitHub Copilot CLI supports multiple AI models including OpenAI,
23
23
  * Anthropic, and Google models via the --model flag.
24
- * Available models (as of Feb 2026): claude-haiku-4.5, claude-sonnet-4.6,
25
- * claude-sonnet-4.5, gpt-5.2-codex, gpt-5.3-codex,
24
+ * Available models (as of April 2026): claude-haiku-4.6, claude-sonnet-4.6,
25
+ * claude-sonnet-4.5, gpt-5.4, gpt-5.4-mini, 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
  */
29
29
  const COPILOT_MODELS = [
30
30
  {
31
- id: 'claude-haiku-4.5',
32
- name: 'Claude Haiku 4.5',
31
+ id: 'claude-haiku-4.6',
32
+ name: 'Claude Haiku 4.6',
33
33
  tier: 'fast',
34
34
  tagline: 'Quick Scan',
35
35
  description: 'Rapid feedback for obvious issues, style checks, and simple logic errors',
36
36
  badge: 'Speedy',
37
37
  badgeClass: 'badge-speed'
38
38
  },
39
+ {
40
+ id: 'gpt-5.4-mini',
41
+ name: 'GPT-5.4 Mini',
42
+ tier: 'fast',
43
+ tagline: 'Fast & Cheap',
44
+ description: 'Low-cost fast reviews with solid reasoning—included at no premium cost',
45
+ badge: 'Fast',
46
+ badgeClass: 'badge-speed'
47
+ },
39
48
  {
40
49
  id: 'claude-sonnet-4.6',
41
50
  name: 'Claude Sonnet 4.6',
@@ -47,29 +56,20 @@ const COPILOT_MODELS = [
47
56
  default: true
48
57
  },
49
58
  {
50
- id: 'claude-sonnet-4.5',
51
- name: 'Claude Sonnet 4.5',
52
- tier: 'balanced',
53
- tagline: 'Previous Gen',
54
- description: 'Previous generation Sonnet—strong code understanding with excellent quality-to-cost ratio',
55
- badge: 'Previous Gen',
56
- badgeClass: 'badge-balanced'
57
- },
58
- {
59
- id: 'gpt-5.2-codex',
60
- name: 'GPT-5.2 Codex',
61
- tier: 'balanced',
62
- tagline: 'Alternative View',
63
- description: 'OpenAI code-specialized model—different perspective for cross-file analysis',
64
- badge: 'Balanced',
65
- badgeClass: 'badge-balanced'
59
+ id: 'gpt-5.4',
60
+ name: 'GPT-5.4',
61
+ tier: 'thorough',
62
+ tagline: 'Latest OpenAI',
63
+ description: 'Flagship OpenAI model combining coding, reasoning, and agentic workflows',
64
+ badge: 'Latest',
65
+ badgeClass: 'badge-power'
66
66
  },
67
67
  {
68
68
  id: 'gpt-5.3-codex',
69
69
  name: 'GPT-5.3 Codex',
70
70
  tier: 'thorough',
71
71
  tagline: 'Deep Code Analysis',
72
- description: 'Most capable OpenAI coding model—frontier performance for complex multi-file reviews',
72
+ description: 'Industry-leading coding model—frontier performance for complex multi-file reviews',
73
73
  badge: 'Thorough',
74
74
  badgeClass: 'badge-power'
75
75
  },
@@ -20,6 +20,16 @@ const BIN_DIR = path.join(__dirname, '..', '..', 'bin');
20
20
  * Gemini model definitions with tier mappings
21
21
  */
22
22
  const GEMINI_MODELS = [
23
+ {
24
+ id: 'gemini-3.1-flash-lite-preview',
25
+ aliases: ['gemini-3.1-flash-lite'],
26
+ name: '3.1 Flash Lite',
27
+ tier: 'fast',
28
+ tagline: 'Cheapest',
29
+ description: 'Ultra-efficient model for high-volume cost-conscious scans',
30
+ badge: 'Cheapest',
31
+ badgeClass: 'badge-speed'
32
+ },
23
33
  {
24
34
  id: 'gemini-3-flash-preview',
25
35
  aliases: ['gemini-3-flash'],
@@ -16,7 +16,10 @@
16
16
  * for cross-provider switching, which translates to `--provider <provider> --model <model>`.
17
17
  */
18
18
 
19
+ const crypto = require('crypto');
19
20
  const path = require('path');
21
+ const os = require('os');
22
+ const fs = require('fs');
20
23
  const { spawn } = require('child_process');
21
24
  const { AIProvider, registerProvider, quoteShellArgs } = require('./provider');
22
25
  const logger = require('../utils/logger');
@@ -244,19 +247,23 @@ class PiProvider extends AIProvider {
244
247
 
245
248
  const levelPrefix = logPrefix || `[Level ${level}]`;
246
249
  logger.info(`${levelPrefix} Executing Pi CLI...`);
247
- logger.info(`${levelPrefix} Writing prompt via stdin: ${prompt.length} bytes`);
250
+ logger.info(`${levelPrefix} Prompt: ${prompt.length} bytes`);
251
+
252
+ // Write prompt to a temp file and use Pi's @file syntax as a positional arg.
253
+ // This bypasses devx stdin interference that breaks --mode json output.
254
+ const tmpFile = path.join(os.tmpdir(), `pair-review-prompt-${Date.now()}-${process.pid}-${crypto.randomUUID()}.txt`);
255
+ fs.writeFileSync(tmpFile, prompt);
256
+ const cleanupTmpFile = () => { try { fs.unlinkSync(tmpFile); } catch { /* ignore */ } };
248
257
 
249
- // Use stdin for prompt instead of CLI argument (avoids shell escaping issues)
250
- // Pi reads from stdin when using -p with no positional message arguments
251
258
  let fullCommand;
252
259
  let fullArgs;
253
260
 
254
261
  if (this.useShell) {
255
- fullCommand = `${this.piCmd} ${quoteShellArgs(this.baseArgs).join(' ')}`;
262
+ fullCommand = `${this.piCmd} ${quoteShellArgs([...this.baseArgs, `@${tmpFile}`]).join(' ')}`;
256
263
  fullArgs = [];
257
264
  } else {
258
265
  fullCommand = this.piCmd;
259
- fullArgs = [...this.baseArgs];
266
+ fullArgs = [...this.baseArgs, `@${tmpFile}`];
260
267
  }
261
268
 
262
269
  const pi = spawn(fullCommand, fullArgs, {
@@ -269,6 +276,10 @@ class PiProvider extends AIProvider {
269
276
  shell: this.useShell
270
277
  });
271
278
 
279
+ // Close stdin immediately — prompt is delivered via @file, but some
280
+ // wrappers (e.g., devx) keep the process alive until stdin is closed.
281
+ pi.stdin.end();
282
+
272
283
  const pid = pi.pid;
273
284
  logger.debug(`${levelPrefix} Pi CLI command: ${fullCommand} ${fullArgs.join(' ')}`);
274
285
  logger.info(`${levelPrefix} Spawned Pi CLI process: PID ${pid}`);
@@ -340,6 +351,7 @@ class PiProvider extends AIProvider {
340
351
 
341
352
  // Handle completion
342
353
  pi.on('close', (code) => {
354
+ cleanupTmpFile();
343
355
  if (settled) return; // Already settled by timeout or error
344
356
 
345
357
  // Flush any remaining stream parser buffer
@@ -413,7 +425,7 @@ class PiProvider extends AIProvider {
413
425
 
414
426
  // Use async IIFE to handle the async LLM extraction
415
427
  (async () => {
416
- // Guard: if already settled (by timeout, stdin error, or cancellation),
428
+ // Guard: if already settled (by timeout, process error, or cancellation),
417
429
  // skip the LLM extraction entirely to avoid misleading log output
418
430
  if (settled) return;
419
431
 
@@ -437,6 +449,7 @@ class PiProvider extends AIProvider {
437
449
 
438
450
  // Handle errors
439
451
  pi.on('error', (error) => {
452
+ cleanupTmpFile();
440
453
  if (error.code === 'ENOENT') {
441
454
  logger.error(`${levelPrefix} Pi CLI not found. Please ensure Pi CLI is installed.`);
442
455
  settle(reject, new Error(`${levelPrefix} Pi CLI not found. ${PiProvider.getInstallInstructions()}`));
@@ -445,21 +458,6 @@ class PiProvider extends AIProvider {
445
458
  settle(reject, error);
446
459
  }
447
460
  });
448
-
449
- // Handle stdin errors (e.g., EPIPE if process exits before write completes)
450
- pi.stdin.on('error', (err) => {
451
- logger.error(`${levelPrefix} stdin error: ${err.message}`);
452
- });
453
-
454
- // Send the prompt to stdin (Pi reads from stdin when using -p with no args)
455
- pi.stdin.write(prompt, (err) => {
456
- if (err) {
457
- logger.error(`${levelPrefix} Failed to write prompt to stdin: ${err}`);
458
- pi.kill('SIGTERM');
459
- settle(reject, new Error(`${levelPrefix} Failed to write prompt to stdin: ${err}`));
460
- }
461
- });
462
- pi.stdin.end();
463
461
  });
464
462
  }
465
463
 
@@ -740,14 +738,13 @@ class PiProvider extends AIProvider {
740
738
  // Build args consistently using the shared method, applying provider and model extra_args
741
739
  const args = this.buildArgsForModel(model);
742
740
 
743
- // For extraction, we pass the prompt via stdin
744
- // Pi reads from stdin when using -p with no positional message arguments
741
+ // Use @file syntax for prompt delivery (bypasses devx stdin interference)
745
742
  if (useShell) {
746
743
  return {
747
744
  command: `${piCmd} ${quoteShellArgs(args).join(' ')}`,
748
745
  args: [],
749
746
  useShell: true,
750
- promptViaStdin: true,
747
+ promptViaFile: true,
751
748
  env: this.extraEnv
752
749
  };
753
750
  }
@@ -755,7 +752,7 @@ class PiProvider extends AIProvider {
755
752
  command: piCmd,
756
753
  args,
757
754
  useShell: false,
758
- promptViaStdin: true,
755
+ promptViaFile: true,
759
756
  env: this.extraEnv
760
757
  };
761
758
  }
@@ -6,7 +6,10 @@
6
6
  * and provides a factory function to create provider instances.
7
7
  */
8
8
 
9
+ const crypto = require('crypto');
9
10
  const path = require('path');
11
+ const os = require('os');
12
+ const fs = require('fs');
10
13
  const { spawn } = require('child_process');
11
14
  const logger = require('../utils/logger');
12
15
  const { extractJSON } = require('../utils/json-extractor');
@@ -181,6 +184,7 @@ class AIProvider {
181
184
  * @property {string[]} args - Arguments (prompt will be appended if promptViaStdin is false)
182
185
  * @property {boolean} useShell - Whether to use shell mode
183
186
  * @property {boolean} promptViaStdin - If true, send prompt to stdin; if false, append to args
187
+ * @property {boolean} promptViaFile - If true, write prompt to a temp file and pass @filepath as a positional arg (Pi-specific @file syntax; currently only used by PiProvider)
184
188
  */
185
189
  getExtractionConfig(model) {
186
190
  // Default: extraction not supported
@@ -213,7 +217,7 @@ class AIProvider {
213
217
  };
214
218
  }
215
219
 
216
- const { command, args, useShell, promptViaStdin, env: configEnv } = config;
220
+ const { command, args, useShell, promptViaStdin, promptViaFile, env: configEnv } = config;
217
221
  const prompt = `Extract the JSON object from the following text. Return ONLY the valid JSON, nothing else. Do not include any explanation, markdown formatting, or code blocks - just the raw JSON.
218
222
 
219
223
  === BEGIN INPUT TEXT ===
@@ -222,7 +226,21 @@ ${rawResponse}
222
226
 
223
227
  return new Promise((resolve) => {
224
228
  // Build final command and args based on prompt delivery method
225
- const finalArgs = promptViaStdin ? args : [...args, prompt];
229
+ // promptViaFile: write to temp file, pass @filepath as positional arg (Pi @file syntax)
230
+ // promptViaStdin: write to process stdin after spawn
231
+ // default: pass prompt as positional CLI arg
232
+ let tmpFile = null;
233
+ let cleanupTmpFile = () => {};
234
+ let finalArgs;
235
+
236
+ if (promptViaFile) {
237
+ tmpFile = path.join(os.tmpdir(), `pair-review-extract-${Date.now()}-${process.pid}-${crypto.randomUUID()}.txt`);
238
+ fs.writeFileSync(tmpFile, prompt);
239
+ cleanupTmpFile = () => { try { fs.unlinkSync(tmpFile); } catch { /* ignore */ } };
240
+ finalArgs = [...args, `@${tmpFile}`];
241
+ } else {
242
+ finalArgs = promptViaStdin ? args : [...args, prompt];
243
+ }
226
244
 
227
245
  logger.info(`${levelPrefix} Attempting LLM-based JSON extraction with ${extractionModel}...`);
228
246
 
@@ -269,6 +287,7 @@ ${rawResponse}
269
287
  });
270
288
 
271
289
  proc.on('close', (code) => {
290
+ cleanupTmpFile();
272
291
  if (settled) return;
273
292
 
274
293
  if (code !== 0) {
@@ -295,11 +314,12 @@ ${rawResponse}
295
314
  });
296
315
 
297
316
  proc.on('error', (error) => {
317
+ cleanupTmpFile();
298
318
  logger.warn(`${levelPrefix} LLM extraction process error: ${error.message}`);
299
319
  settle({ success: false, error: error.message });
300
320
  });
301
321
 
302
- // Send prompt via stdin if configured
322
+ // Deliver prompt based on config method
303
323
  if (promptViaStdin) {
304
324
  // Handle stdin errors (e.g., EPIPE if process exits before write completes)
305
325
  proc.stdin.on('error', (err) => {
@@ -314,6 +334,9 @@ ${rawResponse}
314
334
  }
315
335
  });
316
336
  proc.stdin.end();
337
+ } else if (promptViaFile) {
338
+ // Prompt delivered via @file arg — close stdin so wrappers (e.g., devx) don't hang
339
+ proc.stdin.end();
317
340
  }
318
341
  });
319
342
  }
@@ -35,6 +35,7 @@ class PiBridge extends EventEmitter {
35
35
  * @param {boolean} [options.useShell] - Use shell mode for multi-word commands
36
36
  * @param {string[]} [options.skills] - Array of skill file paths to load via --skill
37
37
  * @param {string[]} [options.extensions] - Array of extension directory paths to load via -e
38
+ * @param {string[]} [options.extraArgs] - Extra CLI args to append (e.g., from config extra_args)
38
39
  * @param {string} [options.sessionPath] - Path to a session file for resumption
39
40
  */
40
41
  constructor(options = {}) {
@@ -49,6 +50,7 @@ class PiBridge extends EventEmitter {
49
50
  this.useShell = options.useShell || false;
50
51
  this.skills = options.skills || [];
51
52
  this.extensions = options.extensions || [];
53
+ this.extraArgs = options.extraArgs || [];
52
54
  this.sessionPath = options.sessionPath || null;
53
55
 
54
56
  this._process = null;
@@ -288,6 +290,12 @@ class PiBridge extends EventEmitter {
288
290
  args.push('-e', ext);
289
291
  }
290
292
 
293
+ // Append extra args from provider config (e.g., extra_args in chat_providers).
294
+ // These go last so they can override earlier flags if needed.
295
+ if (this.extraArgs.length > 0) {
296
+ args.push(...this.extraArgs);
297
+ }
298
+
291
299
  return args;
292
300
  }
293
301
 
@@ -580,6 +580,7 @@ class ChatSessionManager {
580
580
  provider: def?.provider || null,
581
581
  model: options.model || def?.model,
582
582
  piCommand: def?.command,
583
+ extraArgs: def?.args,
583
584
  env: def?.env,
584
585
  useShell: def?.useShell,
585
586
  tools: CHAT_TOOLS,