@in-the-loop-labs/pair-review 2.3.3 → 2.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
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 +456 -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
@@ -0,0 +1,126 @@
1
+ // SPDX-License-Identifier: GPL-3.0-or-later
2
+ /**
3
+ * GitHub Collections Routes
4
+ *
5
+ * Handles endpoints for PR collections:
6
+ * - Review Requests: PRs where the user's review is requested
7
+ * - My PRs: PRs authored by the user
8
+ */
9
+
10
+ const express = require('express');
11
+ const { query, run, withTransaction } = require('../database');
12
+ const { GitHubClient } = require('../github/client');
13
+ const { getGitHubToken } = require('../config');
14
+ const logger = require('../utils/logger');
15
+
16
+ const router = express.Router();
17
+
18
+ /**
19
+ * Get cached review request PRs.
20
+ */
21
+ router.get('/api/github/review-requests', async (req, res) => {
22
+ try {
23
+ const db = req.app.get('db');
24
+ const rows = await query(db, 'SELECT owner, repo, number, title, author, updated_at, html_url, state, fetched_at FROM github_pr_cache WHERE collection = ? ORDER BY updated_at DESC', ['review-requests']);
25
+
26
+ const fetchedAt = rows.length > 0 ? rows[0].fetched_at : null;
27
+ res.json({ success: true, prs: rows, fetched_at: fetchedAt });
28
+ } catch (error) {
29
+ logger.error('Failed to fetch review requests:', error);
30
+ res.status(500).json({ success: false, error: 'Failed to fetch review requests' });
31
+ }
32
+ });
33
+
34
+ /**
35
+ * Refresh review request PRs from GitHub.
36
+ */
37
+ router.post('/api/github/review-requests/refresh', async (req, res) => {
38
+ try {
39
+ const config = req.app.get('config');
40
+ const githubToken = getGitHubToken(config);
41
+ if (!githubToken) {
42
+ return res.status(401).json({ success: false, error: 'GitHub token not configured' });
43
+ }
44
+
45
+ const db = req.app.get('db');
46
+ const client = new GitHubClient(githubToken);
47
+ const user = await client.getAuthenticatedUser();
48
+ const prs = await client.searchPullRequests(`is:pr is:open user-review-requested:${user.login}`);
49
+
50
+ await withTransaction(db, async () => {
51
+ await run(db, 'DELETE FROM github_pr_cache WHERE collection = ?', ['review-requests']);
52
+ for (const pr of prs) {
53
+ await run(db,
54
+ 'INSERT INTO github_pr_cache (owner, repo, number, title, author, updated_at, html_url, state, collection) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)',
55
+ [pr.owner, pr.repo, pr.number, pr.title, pr.author, pr.updated_at, pr.html_url, pr.state, 'review-requests']
56
+ );
57
+ }
58
+ });
59
+
60
+ const rows = await query(db, 'SELECT owner, repo, number, title, author, updated_at, html_url, state, fetched_at FROM github_pr_cache WHERE collection = ? ORDER BY updated_at DESC', ['review-requests']);
61
+ const fetchedAt = rows.length > 0 ? rows[0].fetched_at : null;
62
+ res.json({ success: true, prs: rows, fetched_at: fetchedAt });
63
+ } catch (error) {
64
+ if (error.status === 401 || error.status === 403) {
65
+ return res.status(401).json({ success: false, error: 'GitHub token is invalid or expired' });
66
+ }
67
+ logger.error('Failed to refresh review requests:', error);
68
+ res.status(500).json({ success: false, error: 'Failed to refresh review requests' });
69
+ }
70
+ });
71
+
72
+ /**
73
+ * Get cached user's own PRs.
74
+ */
75
+ router.get('/api/github/my-prs', async (req, res) => {
76
+ try {
77
+ const db = req.app.get('db');
78
+ const rows = await query(db, 'SELECT owner, repo, number, title, author, updated_at, html_url, state, fetched_at FROM github_pr_cache WHERE collection = ? ORDER BY updated_at DESC', ['my-prs']);
79
+
80
+ const fetchedAt = rows.length > 0 ? rows[0].fetched_at : null;
81
+ res.json({ success: true, prs: rows, fetched_at: fetchedAt });
82
+ } catch (error) {
83
+ logger.error('Failed to fetch my PRs:', error);
84
+ res.status(500).json({ success: false, error: 'Failed to fetch my PRs' });
85
+ }
86
+ });
87
+
88
+ /**
89
+ * Refresh user's own PRs from GitHub.
90
+ */
91
+ router.post('/api/github/my-prs/refresh', async (req, res) => {
92
+ try {
93
+ const config = req.app.get('config');
94
+ const githubToken = getGitHubToken(config);
95
+ if (!githubToken) {
96
+ return res.status(401).json({ success: false, error: 'GitHub token not configured' });
97
+ }
98
+
99
+ const db = req.app.get('db');
100
+ const client = new GitHubClient(githubToken);
101
+ const user = await client.getAuthenticatedUser();
102
+ const prs = await client.searchPullRequests(`is:pr is:open author:${user.login}`);
103
+
104
+ await withTransaction(db, async () => {
105
+ await run(db, 'DELETE FROM github_pr_cache WHERE collection = ?', ['my-prs']);
106
+ for (const pr of prs) {
107
+ await run(db,
108
+ 'INSERT INTO github_pr_cache (owner, repo, number, title, author, updated_at, html_url, state, collection) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)',
109
+ [pr.owner, pr.repo, pr.number, pr.title, pr.author, pr.updated_at, pr.html_url, pr.state, 'my-prs']
110
+ );
111
+ }
112
+ });
113
+
114
+ const rows = await query(db, 'SELECT owner, repo, number, title, author, updated_at, html_url, state, fetched_at FROM github_pr_cache WHERE collection = ? ORDER BY updated_at DESC', ['my-prs']);
115
+ const fetchedAt = rows.length > 0 ? rows[0].fetched_at : null;
116
+ res.json({ success: true, prs: rows, fetched_at: fetchedAt });
117
+ } catch (error) {
118
+ if (error.status === 401 || error.status === 403) {
119
+ return res.status(401).json({ success: false, error: 'GitHub token is invalid or expired' });
120
+ }
121
+ logger.error('Failed to refresh my PRs:', error);
122
+ res.status(500).json({ success: false, error: 'Failed to refresh my PRs' });
123
+ }
124
+ });
125
+
126
+ module.exports = router;
package/src/routes/mcp.js CHANGED
@@ -21,6 +21,7 @@ const {
21
21
  broadcastProgress,
22
22
  createProgressCallback
23
23
  } = require('./shared');
24
+ const { safeParseJson } = require('../utils/safe-parse-json');
24
25
 
25
26
  // All valid tier values: canonical tiers + aliases (for Zod enum validation)
26
27
  const ALL_TIER_VALUES = /** @type {[string, ...string[]]} */ ([...TIERS, ...Object.keys(TIER_ALIASES)]);
@@ -428,7 +429,7 @@ function createMCPServer(db, options = {}) {
428
429
  type: s.type,
429
430
  ai_confidence: s.ai_confidence,
430
431
  status: s.status,
431
- reasoning: s.reasoning ? JSON.parse(s.reasoning) : null,
432
+ reasoning: safeParseJson(s.reasoning),
432
433
  }))
433
434
  }, null, 2)
434
435
  }]
package/src/routes/pr.js CHANGED
@@ -24,6 +24,7 @@ const Analyzer = require('../ai/analyzer');
24
24
  const { v4: uuidv4 } = require('uuid');
25
25
  const fs = require('fs').promises;
26
26
  const path = require('path');
27
+ const { getGitHubToken } = require('../config');
27
28
  const logger = require('../utils/logger');
28
29
  const { buildDiffLineSet } = require('../utils/diff-annotator');
29
30
  const { broadcastReviewEvent } = require('../events/review-events');
@@ -37,6 +38,7 @@ const {
37
38
  createProgressCallback,
38
39
  parseEnabledLevels
39
40
  } = require('./shared');
41
+ const { safeParseJson } = require('../utils/safe-parse-json');
40
42
  const { validateCouncilConfig, normalizeCouncilConfig } = require('./councils');
41
43
  const { TIERS, TIER_ALIASES, VALID_TIERS, resolveTier } = require('../ai/prompts/config');
42
44
  const analysesRouter = require('./analyses');
@@ -199,7 +201,7 @@ router.get('/api/pr/:owner/:repo/:number', async (req, res) => {
199
201
  let pendingDraft = null;
200
202
  if (review) {
201
203
  const config = req.app.get('config');
202
- const githubToken = config?.github_token || req.app.get('githubToken');
204
+ const githubToken = getGitHubToken(config || {}) || req.app.get('githubToken');
203
205
 
204
206
  if (githubToken) {
205
207
  try {
@@ -527,7 +529,7 @@ router.get('/api/pr/:owner/:repo/:number/github-drafts', async (req, res) => {
527
529
  }
528
530
 
529
531
  // Initialize GitHub client and check for pending drafts on GitHub
530
- const githubToken = config.github_token || req.app.get('githubToken');
532
+ const githubToken = getGitHubToken(config) || req.app.get('githubToken');
531
533
  if (!githubToken) {
532
534
  return res.status(500).json({
533
535
  error: 'GitHub token not configured. Please check your ~/.pair-review/config.json'
@@ -1803,4 +1805,166 @@ router.post('/api/pr/:owner/:repo/:number/analyses/council', async (req, res) =>
1803
1805
  }
1804
1806
  });
1805
1807
 
1808
+ /**
1809
+ * Get shareable review data for a PR
1810
+ * Returns PR metadata, diff, and AI analysis results in a single payload
1811
+ * for consumption by external share sites.
1812
+ *
1813
+ * NOTE: Intentionally PR-only. Sharing requires a stable PR reference
1814
+ * (owner/repo/number) that external consumers can resolve. Local mode
1815
+ * reviews operate on uncommitted changes with no such reference.
1816
+ */
1817
+ router.get('/api/pr/:owner/:repo/:number/share', async (req, res) => {
1818
+ try {
1819
+ const { owner, repo, number } = req.params;
1820
+ const prNumber = parseInt(number);
1821
+
1822
+ if (isNaN(prNumber) || prNumber <= 0) {
1823
+ return res.status(400).json({ error: 'Invalid pull request number' });
1824
+ }
1825
+
1826
+ const repository = normalizeRepository(owner, repo);
1827
+ const db = req.app.get('db');
1828
+
1829
+ // Get PR metadata
1830
+ const prMetadata = await queryOne(db, `
1831
+ SELECT id, pr_number, repository, title, author, base_branch, head_branch, pr_data
1832
+ FROM pr_metadata
1833
+ WHERE pr_number = ? AND repository = ? COLLATE NOCASE
1834
+ `, [prNumber, repository]);
1835
+
1836
+ if (!prMetadata) {
1837
+ return res.status(404).json({
1838
+ error: `Pull request #${prNumber} not found in repository ${repository}`
1839
+ });
1840
+ }
1841
+
1842
+ // Parse PR data for diff and SHAs
1843
+ let prData = {};
1844
+ try {
1845
+ prData = prMetadata.pr_data ? JSON.parse(prMetadata.pr_data) : {};
1846
+ } catch (parseError) {
1847
+ logger.warn('Error parsing PR data JSON for share:', parseError.message);
1848
+ }
1849
+
1850
+ // Get review record
1851
+ const reviewRepo = new ReviewRepository(db);
1852
+ const review = await reviewRepo.getReviewByPR(prNumber, repository);
1853
+
1854
+ // Build changed files list
1855
+ // changed_files may use 'insertions' (from git diff) or 'additions' (from GitHub API)
1856
+ const changedFiles = (prData.changed_files || []).map(f => ({
1857
+ path: f.file,
1858
+ additions: f.insertions ?? f.additions ?? 0,
1859
+ deletions: f.deletions ?? 0
1860
+ }));
1861
+
1862
+ // Get the authenticated user (who is sharing)
1863
+ let sharedBy = null;
1864
+ try {
1865
+ const config = req.app.get('config') || {};
1866
+ const githubToken = getGitHubToken(config);
1867
+ if (githubToken) {
1868
+ const githubClient = new GitHubClient(githubToken);
1869
+ const user = await githubClient.getAuthenticatedUser();
1870
+ sharedBy = user.login;
1871
+ }
1872
+ } catch (authError) {
1873
+ logger.warn('Could not get authenticated user for share:', authError.message);
1874
+ }
1875
+
1876
+ // Build response payload
1877
+ const payload = {
1878
+ owner,
1879
+ repo,
1880
+ prNumber,
1881
+ title: prMetadata.title || '',
1882
+ author: prMetadata.author || '',
1883
+ baseBranch: prMetadata.base_branch || '',
1884
+ headBranch: prMetadata.head_branch || '',
1885
+ baseSha: prData.base_sha || '',
1886
+ headSha: prData.head_sha || '',
1887
+ diff: prData.diff || '',
1888
+ changedFiles,
1889
+ sharedBy,
1890
+ run: null,
1891
+ suggestions: []
1892
+ };
1893
+
1894
+ // If we have a review, get the analysis run and its suggestions
1895
+ // Supports optional runId query param for specific run selection
1896
+ if (review) {
1897
+ const analysisRunRepo = new AnalysisRunRepository(db);
1898
+ const requestedRunId = req.query.runId;
1899
+ let targetRun = null;
1900
+
1901
+ if (requestedRunId) {
1902
+ // Specific run requested - fetch it directly
1903
+ targetRun = await analysisRunRepo.getById(requestedRunId);
1904
+ // Verify it belongs to this review and is completed
1905
+ if (!targetRun || targetRun.review_id !== review.id || targetRun.status !== 'completed') {
1906
+ targetRun = null;
1907
+ }
1908
+ }
1909
+
1910
+ // If no specific run requested or it wasn't found/valid, fall back to the most recently completed run
1911
+ if (!targetRun) {
1912
+ const runs = await analysisRunRepo.getByReviewId(review.id);
1913
+ targetRun = runs.find(r => r.status === 'completed') || null;
1914
+ }
1915
+
1916
+ if (targetRun) {
1917
+ payload.run = {
1918
+ id: targetRun.id,
1919
+ provider: targetRun.provider || null,
1920
+ model: targetRun.model || null,
1921
+ tier: targetRun.tier || null,
1922
+ summary: targetRun.summary || null,
1923
+ completedAt: targetRun.completed_at || null,
1924
+ duration: targetRun.started_at && targetRun.completed_at
1925
+ ? new Date(targetRun.completed_at).getTime() - new Date(targetRun.started_at).getTime()
1926
+ : null,
1927
+ customInstructions: targetRun.custom_instructions || targetRun.request_instructions || null
1928
+ };
1929
+
1930
+ // Get suggestions for this run
1931
+ const rows = await query(db, `
1932
+ SELECT
1933
+ id, file, line_start, line_end, side, type, title, body,
1934
+ suggestion_text, ai_confidence, reasoning, status, is_file_level
1935
+ FROM comments
1936
+ WHERE review_id = ?
1937
+ AND source = 'ai'
1938
+ AND ai_run_id = ?
1939
+ AND ai_level IS NULL
1940
+ AND (is_raw = 0 OR is_raw IS NULL)
1941
+ AND status IN ('active', 'adopted')
1942
+ ORDER BY file, line_start
1943
+ `, [review.id, targetRun.id]);
1944
+
1945
+ payload.suggestions = rows.map(row => ({
1946
+ id: row.id,
1947
+ file: row.file,
1948
+ lineStart: row.line_start,
1949
+ lineEnd: row.line_end,
1950
+ side: row.side || 'RIGHT',
1951
+ type: row.type || 'comment',
1952
+ title: row.title || '',
1953
+ body: row.body || '',
1954
+ suggestionText: row.suggestion_text || '',
1955
+ confidence: row.ai_confidence != null ? row.ai_confidence : null,
1956
+ reasoning: safeParseJson(row.reasoning, []),
1957
+ status: row.status,
1958
+ isFileLevel: row.is_file_level === 1
1959
+ }));
1960
+ }
1961
+ }
1962
+
1963
+ res.json(payload);
1964
+ } catch (error) {
1965
+ logger.error('Error generating share data:', error);
1966
+ res.status(500).json({ error: 'Failed to generate share data' });
1967
+ }
1968
+ });
1969
+
1806
1970
  module.exports = router;
@@ -20,6 +20,7 @@ const simpleGit = require('simple-git');
20
20
  const { GitWorktreeManager } = require('../git/worktree');
21
21
  const { normalizeRepository } = require('../utils/paths');
22
22
  const { resolveFormat, formatAdoptedComment: formatComment } = require('../utils/comment-formatter');
23
+ const { safeParseJson } = require('../utils/safe-parse-json');
23
24
 
24
25
  const router = express.Router();
25
26
 
@@ -568,7 +569,7 @@ router.get('/api/reviews/:reviewId/suggestions', validateReviewId, async (req, r
568
569
 
569
570
  return {
570
571
  ...row,
571
- reasoning: row.reasoning ? JSON.parse(row.reasoning) : null,
572
+ reasoning: safeParseJson(row.reasoning),
572
573
  formattedBody
573
574
  };
574
575
  });
@@ -231,16 +231,34 @@ function createProgressCallback(analysisId) {
231
231
  }
232
232
  }
233
233
 
234
- currentStatus.levels[levelKey].streamEvent = evt;
235
- // Propagate voiceId so council progress modal can identify active voice
236
- if (progressUpdate.voiceId) {
237
- currentStatus.levels[levelKey].voiceId = progressUpdate.voiceId;
238
- }
239
- // Propagate consolidation step so frontend can identify active consolidation child
240
- if (consolidationMatch) {
241
- currentStatus.levels[levelKey].consolidationStep = `L${consolidationMatch[1]}`;
242
- } else if (level === 'orchestration') {
243
- currentStatus.levels[levelKey].consolidationStep = 'orchestration';
234
+ // Per-voice orchestration streams: store in voices map, not shared state.
235
+ // This prevents per-reviewer orchestration (within each voice's analysis)
236
+ // from overwriting the shared consolidation streamEvent/consolidationStep.
237
+ const isPerVoiceOrchestration = (level === 'orchestration' || consolidationMatch) && progressUpdate.voiceId;
238
+ if (isPerVoiceOrchestration) {
239
+ if (!currentStatus.levels[levelKey].voices) {
240
+ currentStatus.levels[levelKey].voices = {};
241
+ }
242
+ if (!currentStatus.levels[levelKey].voices[progressUpdate.voiceId]) {
243
+ currentStatus.levels[levelKey].voices[progressUpdate.voiceId] = { status: 'running' };
244
+ }
245
+ currentStatus.levels[levelKey].voices[progressUpdate.voiceId].streamEvent = evt;
246
+ // Levels 1-3: stream events are stored in the shared levels[n].streamEvent field
247
+ // with voiceId as a routing discriminator (only one voice streams per level at a time).
248
+ // Level 4 is different (handled above) because it has both per-voice orchestration
249
+ // AND shared cross-voice consolidation, requiring separate storage paths.
250
+ } else {
251
+ currentStatus.levels[levelKey].streamEvent = evt;
252
+ // Propagate voiceId so council progress modal can identify active voice
253
+ if (progressUpdate.voiceId) {
254
+ currentStatus.levels[levelKey].voiceId = progressUpdate.voiceId;
255
+ }
256
+ // Propagate consolidation step so frontend can identify active consolidation child
257
+ if (consolidationMatch) {
258
+ currentStatus.levels[levelKey].consolidationStep = `L${consolidationMatch[1]}`;
259
+ } else if (level === 'orchestration') {
260
+ currentStatus.levels[levelKey].consolidationStep = 'orchestration';
261
+ }
244
262
  }
245
263
  activeAnalyses.set(analysisId, currentStatus);
246
264
 
@@ -295,48 +313,51 @@ function createProgressCallback(analysisId) {
295
313
  // Both maps must be preserved across updates since each progress event only
296
314
  // reports on a single step or voice at a time.
297
315
  if (level === 'orchestration' || consolidationMatch) {
298
- const step = consolidationMatch ? `L${consolidationMatch[1]}` : 'orchestration';
299
- // Preserve existing consolidation steps when updating level 4
300
- const existing = currentStatus.levels[4] || {};
301
- const steps = { ...(existing.steps || {}) };
302
- steps[step] = {
303
- status: progressUpdate.status || 'running',
304
- progress: progressUpdate.progress || (consolidationMatch ? 'Consolidating...' : 'Finalizing results...')
305
- };
306
- // Derive the top-level consolidation status from the aggregate of step statuses
307
- // so that a single step completing doesn't mark the whole phase as completed
308
- const stepStatuses = Object.values(steps).map(s => s.status);
309
- const derivedStatus = stepStatuses.every(s => s === 'completed') ? 'completed'
310
- : stepStatuses.some(s => s === 'failed') ? 'failed'
311
- : stepStatuses.some(s => s === 'running') ? 'running'
312
- : progressUpdate.status || 'running';
313
- // Preserve existing per-voice orchestration states when rebuilding level 4
314
- const existingVoices = existing.voices ? { ...existing.voices } : undefined;
315
- currentStatus.levels[4] = {
316
- status: derivedStatus,
317
- progress: progressUpdate.progress || (consolidationMatch ? 'Consolidating...' : 'Finalizing results...'),
318
- streamEvent: existing.streamEvent,
319
- consolidationStep: step,
320
- steps,
321
- voices: existingVoices
322
- };
323
-
324
- // Track per-voice orchestration state (voice-centric council mode):
325
- // When a voiceId is present, store per-voice status in levels[4].voices
326
- // so the frontend can update individual reviewer's consolidation row.
316
+ // Per-voice orchestration updates (voiceId present): only update the per-voice
317
+ // entry in levels[4].voices. Do NOT touch the shared consolidation state (steps,
318
+ // consolidationStep, streamEvent, top-level progress). This prevents per-reviewer
319
+ // orchestration (within each voice's analysis) from being confused with the
320
+ // overall cross-voice or cross-level consolidation.
327
321
  if (progressUpdate.voiceId) {
328
- if (!currentStatus.levels[4].voices) {
329
- currentStatus.levels[4].voices = {};
330
- }
331
- currentStatus.levels[4].voices[progressUpdate.voiceId] = {
322
+ const existing = currentStatus.levels[4] || {};
323
+ const existingVoices = existing.voices ? { ...existing.voices } : {};
324
+ const prev = existingVoices[progressUpdate.voiceId] || {};
325
+ const voiceStatus = progressUpdate.status || 'running';
326
+ existingVoices[progressUpdate.voiceId] = voiceStatus === 'running'
327
+ ? { ...prev, status: voiceStatus, progress: progressUpdate.progress || 'Consolidating...' }
328
+ : { status: voiceStatus, progress: progressUpdate.progress || 'Consolidating...' };
329
+ currentStatus.levels[4] = {
330
+ ...existing,
331
+ voices: existingVoices,
332
+ voiceId: progressUpdate.voiceId
333
+ };
334
+ } else {
335
+ // Shared consolidation update (no voiceId): update steps map and derive
336
+ // aggregate status. This is the cross-level or cross-voice consolidation.
337
+ const step = consolidationMatch ? `L${consolidationMatch[1]}` : 'orchestration';
338
+ const existing = currentStatus.levels[4] || {};
339
+ const steps = { ...(existing.steps || {}) };
340
+ steps[step] = {
332
341
  status: progressUpdate.status || 'running',
333
- progress: progressUpdate.progress || 'Consolidating...'
342
+ progress: progressUpdate.progress || (consolidationMatch ? 'Consolidating...' : 'Finalizing results...')
343
+ };
344
+ // Derive the top-level consolidation status from the aggregate of step statuses
345
+ // so that a single step completing doesn't mark the whole phase as completed
346
+ const stepStatuses = Object.values(steps).map(s => s.status);
347
+ const derivedStatus = stepStatuses.every(s => s === 'completed') ? 'completed'
348
+ : stepStatuses.some(s => s === 'failed') ? 'failed'
349
+ : stepStatuses.some(s => s === 'running') ? 'running'
350
+ : progressUpdate.status || 'running';
351
+ // Preserve existing per-voice orchestration states when rebuilding level 4
352
+ const existingVoices = existing.voices ? { ...existing.voices } : undefined;
353
+ currentStatus.levels[4] = {
354
+ status: derivedStatus,
355
+ progress: progressUpdate.progress || (consolidationMatch ? 'Consolidating...' : 'Finalizing results...'),
356
+ streamEvent: existing.streamEvent,
357
+ consolidationStep: step,
358
+ steps,
359
+ voices: existingVoices
334
360
  };
335
- // Last-writer-wins: reflects whichever voice reported most recently.
336
- // Intentional — mirrors levels 1-3 behavior (line ~334) and the frontend
337
- // uses per-voice detail from the `voices` map, not this top-level field.
338
- // This field exists for backward compat with single-model progress routing.
339
- currentStatus.levels[4].voiceId = progressUpdate.voiceId;
340
361
  }
341
362
  }
342
363
 
package/src/server.js CHANGED
@@ -5,6 +5,7 @@ const { loadConfig, getGitHubToken, resolveDbName, warnIfDevModeWithoutDbName }
5
5
  const { initializeDatabase, getDatabaseStatus, queryOne, run } = require('./database');
6
6
  const { normalizeRepository } = require('./utils/paths');
7
7
  const { applyConfigOverrides, checkAllProviders } = require('./ai');
8
+ const { checkAllChatProviders } = require('./chat/chat-providers');
8
9
  const logger = require('./utils/logger');
9
10
  const { attachWebSocket, closeAll: closeAllWS } = require('./ws');
10
11
 
@@ -161,6 +162,25 @@ async function startServer(sharedDb = null) {
161
162
  // Middleware
162
163
  app.use(requestLogger);
163
164
  app.use(express.json());
165
+
166
+ // CORS middleware for share endpoints
167
+ // Allows configured external origins to fetch share data
168
+ const shareAllowedOrigins = config.share?.allowed_origins || [];
169
+ if (shareAllowedOrigins.length > 0) {
170
+ app.use('/api/pr/:owner/:repo/:number/share', (req, res, next) => {
171
+ const origin = req.headers.origin;
172
+ if (origin && shareAllowedOrigins.includes(origin)) {
173
+ res.setHeader('Access-Control-Allow-Origin', origin);
174
+ res.setHeader('Access-Control-Allow-Methods', 'GET, OPTIONS');
175
+ res.setHeader('Access-Control-Allow-Headers', 'Content-Type');
176
+ res.setHeader('Vary', 'Origin');
177
+ }
178
+ if (req.method === 'OPTIONS') {
179
+ return res.sendStatus(204);
180
+ }
181
+ next();
182
+ });
183
+ }
164
184
 
165
185
  // Static files with cache control headers
166
186
  // In dev_mode, all caching is disabled to avoid stale resources during development
@@ -261,10 +281,11 @@ async function startServer(sharedDb = null) {
261
281
  const councilRoutes = require('./routes/councils');
262
282
  const chatRoutes = require('./routes/chat');
263
283
  const contextFilesRoutes = require('./routes/context-files');
284
+ const githubCollectionsRoutes = require('./routes/github-collections');
264
285
 
265
286
  // Initialize chat session manager
266
287
  const ChatSessionManager = require('./chat/session-manager');
267
- chatSessionManager = new ChatSessionManager(db);
288
+ chatSessionManager = new ChatSessionManager(db, config.chat_providers || {});
268
289
  app.chatSessionManager = chatSessionManager;
269
290
 
270
291
  // Mount specific routes first to ensure they match before general PR routes
@@ -278,6 +299,7 @@ async function startServer(sharedDb = null) {
278
299
  app.use('/', localRoutes);
279
300
  app.use('/', setupRoutes);
280
301
  app.use('/', mcpRoutes);
302
+ app.use('/', githubCollectionsRoutes);
281
303
  app.use('/', prRoutes);
282
304
 
283
305
  // Error handling middleware
@@ -299,7 +321,11 @@ async function startServer(sharedDb = null) {
299
321
  // condition where the frontend fetches config before the cache is populated)
300
322
  const defaultProvider = config.default_provider || 'claude';
301
323
  try {
324
+ // Sequential: checkAllProviders must finish first because it populates
325
+ // the AI provider availability cache that checkAllChatProviders reads
326
+ // (e.g. the pi chat provider calls getCachedAvailability('pi')).
302
327
  await checkAllProviders(defaultProvider);
328
+ await checkAllChatProviders();
303
329
  } catch (err) {
304
330
  console.warn('Provider availability check failed:', err.message);
305
331
  }
@@ -0,0 +1,19 @@
1
+ // SPDX-License-Identifier: GPL-3.0-or-later
2
+ /**
3
+ * Safely parse JSON with a fallback value.
4
+ * Useful for database columns that may contain malformed JSON.
5
+ *
6
+ * @param {string|null|undefined} str - The JSON string to parse
7
+ * @param {*} [fallback=null] - Value to return if parsing fails
8
+ * @returns {*} Parsed JSON value or the fallback
9
+ */
10
+ function safeParseJson(str, fallback = null) {
11
+ if (str == null) return fallback;
12
+ try {
13
+ return JSON.parse(str);
14
+ } catch {
15
+ return fallback;
16
+ }
17
+ }
18
+
19
+ module.exports = { safeParseJson };