@in-the-loop-labs/pair-review 1.6.2 → 2.0.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 (63) hide show
  1. package/README.md +77 -4
  2. package/package.json +1 -1
  3. package/plugin/.claude-plugin/plugin.json +1 -1
  4. package/plugin/skills/review-requests/SKILL.md +4 -1
  5. package/plugin-code-critic/.claude-plugin/plugin.json +1 -1
  6. package/plugin-code-critic/skills/analyze/SKILL.md +4 -3
  7. package/public/css/pr.css +1962 -114
  8. package/public/js/CONVENTIONS.md +16 -0
  9. package/public/js/components/AIPanel.js +66 -0
  10. package/public/js/components/AnalysisConfigModal.js +2 -2
  11. package/public/js/components/ChatPanel.js +2955 -0
  12. package/public/js/components/CouncilProgressModal.js +12 -16
  13. package/public/js/components/KeyboardShortcuts.js +3 -0
  14. package/public/js/components/PanelGroup.js +723 -0
  15. package/public/js/components/PreviewModal.js +3 -8
  16. package/public/js/index.js +8 -0
  17. package/public/js/local.js +17 -615
  18. package/public/js/modules/analysis-history.js +19 -68
  19. package/public/js/modules/comment-manager.js +103 -20
  20. package/public/js/modules/diff-context.js +176 -0
  21. package/public/js/modules/diff-renderer.js +30 -0
  22. package/public/js/modules/file-comment-manager.js +126 -105
  23. package/public/js/modules/file-list-merger.js +64 -0
  24. package/public/js/modules/panel-resizer.js +25 -6
  25. package/public/js/modules/suggestion-manager.js +40 -125
  26. package/public/js/pr.js +1009 -159
  27. package/public/js/repo-settings.js +36 -6
  28. package/public/js/utils/category-emoji.js +44 -0
  29. package/public/js/utils/time.js +32 -0
  30. package/public/local.html +107 -70
  31. package/public/pr.html +107 -70
  32. package/public/repo-settings.html +32 -0
  33. package/src/ai/analyzer.js +5 -1
  34. package/src/ai/copilot-provider.js +39 -9
  35. package/src/ai/cursor-agent-provider.js +45 -11
  36. package/src/ai/gemini-provider.js +17 -4
  37. package/src/ai/prompts/config.js +7 -1
  38. package/src/ai/provider-availability.js +1 -1
  39. package/src/ai/provider.js +25 -37
  40. package/src/chat/CONVENTIONS.md +18 -0
  41. package/src/chat/pi-bridge.js +491 -0
  42. package/src/chat/prompt-builder.js +272 -0
  43. package/src/chat/session-manager.js +619 -0
  44. package/src/config.js +14 -0
  45. package/src/database.js +322 -15
  46. package/src/main.js +4 -17
  47. package/src/routes/analyses.js +721 -0
  48. package/src/routes/chat.js +655 -0
  49. package/src/routes/config.js +29 -8
  50. package/src/routes/context-files.js +274 -0
  51. package/src/routes/local.js +225 -1133
  52. package/src/routes/mcp.js +39 -30
  53. package/src/routes/pr.js +424 -58
  54. package/src/routes/reviews.js +1035 -0
  55. package/src/routes/shared.js +4 -29
  56. package/src/server.js +34 -12
  57. package/src/sse/review-events.js +46 -0
  58. package/src/utils/auto-context.js +88 -0
  59. package/src/utils/category-emoji.js +33 -0
  60. package/src/utils/diff-annotator.js +75 -1
  61. package/src/utils/diff-file-list.js +57 -0
  62. package/src/routes/analysis.js +0 -1600
  63. package/src/routes/comments.js +0 -534
@@ -0,0 +1,619 @@
1
+ // SPDX-License-Identifier: GPL-3.0-or-later
2
+ /**
3
+ * Chat Session Manager
4
+ *
5
+ * Manages active chat sessions, each backed by a Pi RPC bridge process.
6
+ * Handles session lifecycle (create, message, close), persistence to SQLite,
7
+ * and event dispatch (delta, complete, tool_use) to registered listeners.
8
+ */
9
+
10
+ const fs = require('fs');
11
+ const path = require('path');
12
+ const PiBridge = require('./pi-bridge');
13
+ const logger = require('../utils/logger');
14
+
15
+ const pairReviewSkillPath = path.resolve(__dirname, '../../.pi/skills/pair-review-api/SKILL.md');
16
+ const taskExtensionDir = path.resolve(__dirname, '../../.pi/extensions/task');
17
+
18
+ const CHAT_TOOLS = 'read,bash,grep,find,ls';
19
+
20
+ class ChatSessionManager {
21
+ /**
22
+ * @param {Database} db - better-sqlite3 database instance
23
+ */
24
+ constructor(db) {
25
+ this._db = db;
26
+ this._sessions = new Map(); // sessionId -> { bridge, listeners }
27
+ }
28
+
29
+ /**
30
+ * Create a new chat session and spawn the agent process.
31
+ * @param {Object} options
32
+ * @param {string} options.provider - 'pi' (and later 'claude')
33
+ * @param {string} [options.model] - Model ID
34
+ * @param {number} options.reviewId - Review ID
35
+ * @param {number} [options.contextCommentId] - Optional suggestion ID that triggered chat
36
+ * @param {string} [options.systemPrompt] - System prompt text
37
+ * @param {string} [options.cwd] - Working directory for agent
38
+ * @param {string} [options.initialContext] - Initial context to prepend to the first user message
39
+ * @returns {Promise<{id: number, status: string}>}
40
+ */
41
+ async createSession({ provider, model, reviewId, contextCommentId, systemPrompt, cwd, initialContext }) {
42
+ // Insert session record into DB
43
+ const stmt = this._db.prepare(`
44
+ INSERT INTO chat_sessions (review_id, context_comment_id, provider, model, status)
45
+ VALUES (?, ?, ?, ?, 'active')
46
+ `);
47
+ const result = stmt.run(
48
+ reviewId,
49
+ contextCommentId || null,
50
+ provider,
51
+ model || null
52
+ );
53
+ const sessionId = Number(result.lastInsertRowid);
54
+
55
+ logger.info(`[ChatSession] Creating session ${sessionId} (provider=${provider}, review=${reviewId})`);
56
+
57
+ // Create and start the bridge
58
+ // Chat sessions get bash for git commands; review analysis uses the safe default
59
+ const bridge = new PiBridge({
60
+ provider,
61
+ model,
62
+ cwd,
63
+ systemPrompt,
64
+ tools: CHAT_TOOLS,
65
+ skills: [pairReviewSkillPath],
66
+ extensions: [taskExtensionDir]
67
+ });
68
+
69
+ const listeners = {
70
+ delta: new Set(),
71
+ complete: new Set(),
72
+ toolUse: new Set(),
73
+ status: new Set(),
74
+ error: new Set()
75
+ };
76
+
77
+ // Store in map before starting so event handlers can find it
78
+ this._sessions.set(sessionId, { bridge, listeners, initialContext: initialContext || null });
79
+
80
+ // Wire up bridge events
81
+ this._wireBridgeEvents(sessionId, bridge, listeners);
82
+
83
+ // Start the bridge process
84
+ try {
85
+ await bridge.start();
86
+ } catch (err) {
87
+ // Bridge failed to start — clean up
88
+ this._sessions.delete(sessionId);
89
+ this._db.prepare(`
90
+ UPDATE chat_sessions SET status = 'error', updated_at = CURRENT_TIMESTAMP
91
+ WHERE id = ?
92
+ `).run(sessionId);
93
+ logger.error(`[ChatSession] Failed to start bridge for session ${sessionId}: ${err.message}`);
94
+ throw err;
95
+ }
96
+
97
+ logger.info(`[ChatSession] Session ${sessionId} active`);
98
+ return { id: sessionId, status: 'active' };
99
+ }
100
+
101
+ /**
102
+ * Send a user message to an active session.
103
+ * Stores the user message in DB, forwards to bridge, and returns.
104
+ * Bridge will emit 'delta' and 'complete' events that route handlers listen to.
105
+ * @param {number} sessionId - Chat session ID
106
+ * @param {string} content - User message text
107
+ * @param {Object} [options]
108
+ * @param {string} [options.context] - Per-message context to prepend (e.g., focused suggestion details).
109
+ * Sent to the agent but NOT stored in DB as part of the user message.
110
+ * @param {Object} [options.contextData] - Structured context data (JSON-serializable) to persist
111
+ * in DB as a separate 'context' type message for session resumption UI reconstruction.
112
+ * @returns {Promise<{id: number}>} The stored message ID
113
+ */
114
+ async sendMessage(sessionId, content, { context, contextData, actionContext } = {}) {
115
+ const session = this._sessions.get(sessionId);
116
+ if (!session) {
117
+ throw new Error(`Session ${sessionId} not found or not active`);
118
+ }
119
+
120
+ if (!session.bridge.isReady()) {
121
+ throw new Error(`Session ${sessionId} bridge is not ready`);
122
+ }
123
+
124
+ if (session.bridge.isBusy()) {
125
+ throw new Error(`Session ${sessionId} is currently processing a message`);
126
+ }
127
+
128
+ // Build the message for the agent: initialContext > context > userMessage
129
+ // (broad session context first, then narrow per-message context, then user text)
130
+ let messageForAgent = content;
131
+
132
+ // Prepend per-message context first (focused suggestion — closer to user message)
133
+ if (context) {
134
+ messageForAgent = context + '\n\n---\n\n' + messageForAgent;
135
+ }
136
+
137
+ // Then prepend initial session context (all suggestions — outermost)
138
+ if (session.initialContext) {
139
+ messageForAgent = session.initialContext + '\n\n---\n\n' + messageForAgent;
140
+ session.initialContext = null; // Only prepend once
141
+ }
142
+
143
+ // Inject action context (from action bar buttons: adopt, update, dismiss).
144
+ // IMPORTANT: Item IDs are passed here as structured metadata for the agent only.
145
+ // They must NEVER appear in user-visible message text (inputEl.value).
146
+ // The frontend sets _pendingActionContext synchronously before sendMessage(),
147
+ // which consumes and clears it before the async fetch — no race window.
148
+ if (actionContext && actionContext.type && actionContext.itemId) {
149
+ const actionHint = `[Action: ${actionContext.type}, target ID: ${actionContext.itemId}]`;
150
+ messageForAgent = messageForAgent + '\n\n' + actionHint;
151
+ } else if (actionContext && actionContext.type === 'create-comment' && actionContext.file) {
152
+ const lineSpec = (!actionContext.line_end || actionContext.line_start === actionContext.line_end)
153
+ ? `${actionContext.line_start}`
154
+ : `${actionContext.line_start}-${actionContext.line_end}`;
155
+ const actionHint = `[Action: create-comment, file: ${actionContext.file}, lines: ${lineSpec}]`;
156
+ messageForAgent = messageForAgent + '\n\n' + actionHint;
157
+ }
158
+
159
+ // Store context + user message atomically
160
+ const insertAll = this._db.transaction(() => {
161
+ // 1. Insert context rows (for session resumption UI)
162
+ if (contextData) {
163
+ const ctxStmt = this._db.prepare(`
164
+ INSERT INTO chat_messages (session_id, role, type, content)
165
+ VALUES (?, 'user', 'context', ?)
166
+ `);
167
+ const items = Array.isArray(contextData) ? contextData : [contextData];
168
+ for (const item of items) {
169
+ ctxStmt.run(sessionId, typeof item === 'string' ? item : JSON.stringify(item));
170
+ }
171
+ }
172
+
173
+ // 2. Insert user message
174
+ const stmt = this._db.prepare(`
175
+ INSERT INTO chat_messages (session_id, role, type, content)
176
+ VALUES (?, 'user', 'message', ?)
177
+ `);
178
+ return stmt.run(sessionId, content);
179
+ });
180
+
181
+ const result = insertAll();
182
+ const messageId = Number(result.lastInsertRowid);
183
+
184
+ // Forward to bridge
185
+ logger.debug(`[ChatSession] Session ${sessionId}: forwarding message to bridge (${messageForAgent.length} chars, delta listeners: ${session.listeners.delta.size})`);
186
+ await session.bridge.sendMessage(messageForAgent);
187
+
188
+ return { id: messageId };
189
+ }
190
+
191
+ /**
192
+ * Register a callback for streaming text deltas from a session.
193
+ * @param {number} sessionId
194
+ * @param {function} callback - Called with {text} on each delta
195
+ * @returns {function} Unsubscribe function
196
+ */
197
+ onDelta(sessionId, callback) {
198
+ const session = this._sessions.get(sessionId);
199
+ if (!session) {
200
+ throw new Error(`Session ${sessionId} not found or not active`);
201
+ }
202
+ session.listeners.delta.add(callback);
203
+ return () => session.listeners.delta.delete(callback);
204
+ }
205
+
206
+ /**
207
+ * Register a callback for turn completion.
208
+ * When the agent completes, the assistant message is already stored in DB.
209
+ * @param {number} sessionId
210
+ * @param {function} callback - Called with {fullText, messageId}
211
+ * @returns {function} Unsubscribe function
212
+ */
213
+ onComplete(sessionId, callback) {
214
+ const session = this._sessions.get(sessionId);
215
+ if (!session) {
216
+ throw new Error(`Session ${sessionId} not found or not active`);
217
+ }
218
+ session.listeners.complete.add(callback);
219
+ return () => session.listeners.complete.delete(callback);
220
+ }
221
+
222
+ /**
223
+ * Register a callback for tool use events.
224
+ * @param {number} sessionId
225
+ * @param {function} callback - Called with {toolCallId, toolName, status, ...}
226
+ * @returns {function} Unsubscribe function
227
+ */
228
+ onToolUse(sessionId, callback) {
229
+ const session = this._sessions.get(sessionId);
230
+ if (!session) {
231
+ throw new Error(`Session ${sessionId} not found or not active`);
232
+ }
233
+ session.listeners.toolUse.add(callback);
234
+ return () => session.listeners.toolUse.delete(callback);
235
+ }
236
+
237
+ /**
238
+ * Register a callback for agent status events (working, turn_complete).
239
+ * @param {number} sessionId
240
+ * @param {function} callback - Called with {status}
241
+ * @returns {function} Unsubscribe function
242
+ */
243
+ onStatus(sessionId, callback) {
244
+ const session = this._sessions.get(sessionId);
245
+ if (!session) {
246
+ throw new Error(`Session ${sessionId} not found or not active`);
247
+ }
248
+ session.listeners.status.add(callback);
249
+ return () => session.listeners.status.delete(callback);
250
+ }
251
+
252
+ /**
253
+ * Register a callback for error events from the bridge.
254
+ * @param {number} sessionId
255
+ * @param {function} callback - Called with {message}
256
+ * @returns {function} Unsubscribe function
257
+ */
258
+ onError(sessionId, callback) {
259
+ const session = this._sessions.get(sessionId);
260
+ if (!session) {
261
+ throw new Error(`Session ${sessionId} not found or not active`);
262
+ }
263
+ session.listeners.error.add(callback);
264
+ return () => session.listeners.error.delete(callback);
265
+ }
266
+
267
+ /**
268
+ * Abort the current turn in an active session.
269
+ * @param {number} sessionId
270
+ */
271
+ abortSession(sessionId) {
272
+ const session = this._sessions.get(sessionId);
273
+ if (!session) {
274
+ throw new Error(`Session ${sessionId} not found or not active`);
275
+ }
276
+ session.bridge.abort();
277
+ logger.info(`[ChatSession] Aborted session ${sessionId}`);
278
+ }
279
+
280
+ /**
281
+ * Close a session and kill the agent process.
282
+ * @param {number} sessionId
283
+ * @returns {Promise<void>}
284
+ */
285
+ async closeSession(sessionId) {
286
+ const session = this._sessions.get(sessionId);
287
+ if (!session) {
288
+ // Session may already be closed — just update DB to be safe
289
+ this._db.prepare(`
290
+ UPDATE chat_sessions SET status = 'closed', updated_at = CURRENT_TIMESTAMP
291
+ WHERE id = ? AND status = 'active'
292
+ `).run(sessionId);
293
+ return;
294
+ }
295
+
296
+ // Remove from map first so the 'close' event handler doesn't double-update
297
+ this._sessions.delete(sessionId);
298
+
299
+ // Close the bridge process (PiBridge.close() handles listener cleanup internally)
300
+ await session.bridge.close();
301
+
302
+ // Update DB status
303
+ this._db.prepare(`
304
+ UPDATE chat_sessions SET status = 'closed', updated_at = CURRENT_TIMESTAMP
305
+ WHERE id = ?
306
+ `).run(sessionId);
307
+
308
+ logger.info(`[ChatSession] Session ${sessionId} closed`);
309
+ }
310
+
311
+ /**
312
+ * Get session info from the database.
313
+ * @param {number} sessionId
314
+ * @returns {Object|null}
315
+ */
316
+ getSession(sessionId) {
317
+ return this._db.prepare('SELECT * FROM chat_sessions WHERE id = ?').get(sessionId) || null;
318
+ }
319
+
320
+ /**
321
+ * Check if a session has an active in-memory bridge.
322
+ * Unlike getSession() which queries the DB, this checks the live session map.
323
+ * @param {number} sessionId
324
+ * @returns {boolean}
325
+ */
326
+ isSessionActive(sessionId) {
327
+ return this._sessions.has(sessionId);
328
+ }
329
+
330
+ /**
331
+ * List sessions for a review.
332
+ * @param {number} reviewId
333
+ * @returns {Array}
334
+ */
335
+ getSessionsForReview(reviewId) {
336
+ return this._db.prepare(
337
+ 'SELECT * FROM chat_sessions WHERE review_id = ? ORDER BY created_at DESC'
338
+ ).all(reviewId);
339
+ }
340
+
341
+ /**
342
+ * Get message history for a session.
343
+ * Returns all message types including rows with type='context' (used to
344
+ * reconstruct context cards in the UI for session resumption). Consumers
345
+ * building session replay should dispatch on the `type` field to distinguish
346
+ * 'message' rows from 'context' rows.
347
+ * @param {number} sessionId
348
+ * @returns {Array}
349
+ */
350
+ getMessages(sessionId) {
351
+ return this._db.prepare(
352
+ 'SELECT * FROM chat_messages WHERE session_id = ? ORDER BY id ASC'
353
+ ).all(sessionId);
354
+ }
355
+
356
+ /**
357
+ * Save a context message to an existing session.
358
+ * Used to persist analysis context cards (and potentially other context types)
359
+ * immediately, without waiting for the next user message.
360
+ * @param {number} sessionId
361
+ * @param {Object|string} contextData - Context data to persist (will be JSON-stringified if object)
362
+ * @returns {{ id: number }} The stored message ID
363
+ */
364
+ saveContextMessage(sessionId, contextData) {
365
+ const session = this._db.prepare('SELECT id FROM chat_sessions WHERE id = ?').get(sessionId);
366
+ if (!session) {
367
+ throw new Error(`Session ${sessionId} not found`);
368
+ }
369
+
370
+ const content = typeof contextData === 'string' ? contextData : JSON.stringify(contextData);
371
+ const stmt = this._db.prepare(`
372
+ INSERT INTO chat_messages (session_id, role, type, content)
373
+ VALUES (?, 'user', 'context', ?)
374
+ `);
375
+ const result = stmt.run(sessionId, content);
376
+ return { id: Number(result.lastInsertRowid) };
377
+ }
378
+
379
+ /**
380
+ * Resume a previously closed chat session by re-spawning the Pi bridge
381
+ * with the stored session file path.
382
+ * @param {number} sessionId
383
+ * @param {Object} options
384
+ * @param {string} [options.systemPrompt] - System prompt text
385
+ * @param {string} [options.cwd] - Working directory for agent
386
+ * @returns {Promise<{id: number, status: string}>}
387
+ */
388
+ async resumeSession(sessionId, { systemPrompt, cwd } = {}) {
389
+ // Already active — return immediately
390
+ if (this._sessions.has(sessionId)) {
391
+ return { id: sessionId, status: 'active' };
392
+ }
393
+
394
+ // Load session row from DB
395
+ const row = this._db.prepare('SELECT * FROM chat_sessions WHERE id = ?').get(sessionId);
396
+ if (!row) {
397
+ throw new Error(`Session ${sessionId} not found`);
398
+ }
399
+
400
+ if (!row.agent_session_id) {
401
+ throw new Error(`Session ${sessionId} has no session file — cannot resume`);
402
+ }
403
+
404
+ // Verify file exists on disk
405
+ if (!fs.existsSync(row.agent_session_id)) {
406
+ // Null out the stale path
407
+ this._db.prepare('UPDATE chat_sessions SET agent_session_id = NULL WHERE id = ?').run(sessionId);
408
+ throw new Error(`Session file not found on disk: ${row.agent_session_id}`);
409
+ }
410
+
411
+ logger.info(`[ChatSession] Resuming session ${sessionId} from ${row.agent_session_id}`);
412
+
413
+ // Create bridge with session path for resumption
414
+ const bridge = new PiBridge({
415
+ provider: row.provider,
416
+ model: row.model,
417
+ cwd,
418
+ systemPrompt,
419
+ tools: CHAT_TOOLS,
420
+ skills: [pairReviewSkillPath],
421
+ extensions: [taskExtensionDir],
422
+ sessionPath: row.agent_session_id
423
+ });
424
+
425
+ const listeners = {
426
+ delta: new Set(),
427
+ complete: new Set(),
428
+ toolUse: new Set(),
429
+ status: new Set(),
430
+ error: new Set()
431
+ };
432
+
433
+ this._sessions.set(sessionId, { bridge, listeners, initialContext: null });
434
+ this._wireBridgeEvents(sessionId, bridge, listeners);
435
+
436
+ // Start the bridge process
437
+ try {
438
+ await bridge.start();
439
+ } catch (err) {
440
+ this._sessions.delete(sessionId);
441
+ this._db.prepare(`
442
+ UPDATE chat_sessions SET status = 'error', updated_at = CURRENT_TIMESTAMP
443
+ WHERE id = ?
444
+ `).run(sessionId);
445
+ logger.error(`[ChatSession] Failed to resume bridge for session ${sessionId}: ${err.message}`);
446
+ throw err;
447
+ }
448
+
449
+ // Update DB status back to active
450
+ this._db.prepare(`
451
+ UPDATE chat_sessions SET status = 'active', updated_at = CURRENT_TIMESTAMP
452
+ WHERE id = ?
453
+ `).run(sessionId);
454
+
455
+ logger.info(`[ChatSession] Session ${sessionId} resumed`);
456
+ return { id: sessionId, status: 'active' };
457
+ }
458
+
459
+ /**
460
+ * Get the most recently updated session for a review.
461
+ * @param {number} reviewId
462
+ * @returns {Object|null}
463
+ */
464
+ getMRUSession(reviewId) {
465
+ return this._db.prepare(
466
+ 'SELECT * FROM chat_sessions WHERE review_id = ? ORDER BY updated_at DESC LIMIT 1'
467
+ ).get(reviewId) || null;
468
+ }
469
+
470
+ /**
471
+ * Get sessions for a review with message counts (for session list UI).
472
+ * @param {number} reviewId
473
+ * @returns {Array<Object>}
474
+ */
475
+ getSessionsWithMessageCount(reviewId) {
476
+ return this._db.prepare(`
477
+ SELECT s.*, COUNT(m.id) AS message_count,
478
+ (SELECT content FROM chat_messages
479
+ WHERE session_id = s.id AND role = 'user' AND type = 'message'
480
+ ORDER BY id ASC LIMIT 1
481
+ ) AS first_message
482
+ FROM chat_sessions s
483
+ LEFT JOIN chat_messages m ON m.session_id = s.id AND m.type = 'message'
484
+ WHERE s.review_id = ?
485
+ GROUP BY s.id
486
+ ORDER BY s.updated_at DESC
487
+ `).all(reviewId);
488
+ }
489
+
490
+ // ---------------------------------------------------------------------------
491
+ // Private methods
492
+ // ---------------------------------------------------------------------------
493
+
494
+ /**
495
+ * Wire up bridge event handlers that dispatch to the session's listener sets
496
+ * and handle DB persistence (e.g., storing assistant messages on completion).
497
+ * @param {number} sessionId
498
+ * @param {PiBridge} bridge
499
+ * @param {Object} listeners - Listener sets keyed by event type
500
+ */
501
+ _wireBridgeEvents(sessionId, bridge, listeners) {
502
+ bridge.on('delta', (data) => {
503
+ for (const cb of listeners.delta) {
504
+ try {
505
+ cb(data);
506
+ } catch (err) {
507
+ logger.error(`[ChatSession] Delta listener error: ${err.message}`);
508
+ }
509
+ }
510
+ });
511
+
512
+ bridge.on('complete', (data) => {
513
+ logger.debug(`[ChatSession] Session ${sessionId} complete: ${(data.fullText || '').length} chars, ${listeners.complete.size} listener(s)`);
514
+ // Store assistant message in DB
515
+ const fullText = data.fullText || '';
516
+ let messageId = null;
517
+ if (fullText) {
518
+ try {
519
+ const msgStmt = this._db.prepare(`
520
+ INSERT INTO chat_messages (session_id, role, type, content)
521
+ VALUES (?, 'assistant', 'message', ?)
522
+ `);
523
+ const msgResult = msgStmt.run(sessionId, fullText);
524
+ messageId = Number(msgResult.lastInsertRowid);
525
+ } catch (err) {
526
+ logger.error(`[ChatSession] Failed to store assistant message: ${err.message}`);
527
+ }
528
+ }
529
+
530
+ for (const cb of listeners.complete) {
531
+ try {
532
+ cb({ fullText, messageId });
533
+ } catch (err) {
534
+ logger.error(`[ChatSession] Complete listener error: ${err.message}`);
535
+ }
536
+ }
537
+ });
538
+
539
+ bridge.on('tool_use', (data) => {
540
+ for (const cb of listeners.toolUse) {
541
+ try {
542
+ cb(data);
543
+ } catch (err) {
544
+ logger.error(`[ChatSession] ToolUse listener error: ${err.message}`);
545
+ }
546
+ }
547
+ });
548
+
549
+ bridge.on('status', (data) => {
550
+ for (const cb of listeners.status) {
551
+ try {
552
+ cb(data);
553
+ } catch (err) {
554
+ logger.error(`[ChatSession] Status listener error: ${err.message}`);
555
+ }
556
+ }
557
+ });
558
+
559
+ bridge.on('error', (data) => {
560
+ logger.error(`[ChatSession] Bridge error for session ${sessionId}: ${data.error?.message || 'unknown'}`);
561
+ for (const cb of listeners.error) {
562
+ try {
563
+ cb({ message: data.error?.message || 'Agent encountered an error' });
564
+ } catch (err) {
565
+ logger.error(`[ChatSession] Error listener error: ${err.message}`);
566
+ }
567
+ }
568
+ });
569
+
570
+ bridge.on('close', () => {
571
+ // If the bridge closes unexpectedly (not via closeSession), update DB
572
+ if (this._sessions.has(sessionId)) {
573
+ for (const cb of listeners.error) {
574
+ try {
575
+ cb({ message: 'Agent process ended unexpectedly' });
576
+ } catch (err) {
577
+ logger.error(`[ChatSession] Error listener error: ${err.message}`);
578
+ }
579
+ }
580
+ try {
581
+ this._db.prepare(`
582
+ UPDATE chat_sessions SET status = 'closed', updated_at = CURRENT_TIMESTAMP
583
+ WHERE id = ? AND status = 'active'
584
+ `).run(sessionId);
585
+ } catch (err) {
586
+ logger.error(`[ChatSession] Failed to update session status on close: ${err.message}`);
587
+ }
588
+ this._sessions.delete(sessionId);
589
+ logger.warn(`[ChatSession] Session ${sessionId} closed unexpectedly`);
590
+ }
591
+ });
592
+
593
+ bridge.on('session', (event) => {
594
+ if (event.sessionFile) {
595
+ try {
596
+ this._db.prepare('UPDATE chat_sessions SET agent_session_id = ? WHERE id = ?')
597
+ .run(event.sessionFile, sessionId);
598
+ logger.info(`[ChatSession] Session ${sessionId} session file: ${event.sessionFile}`);
599
+ } catch (err) {
600
+ logger.warn(`[ChatSession] Failed to store session file: ${err.message}`);
601
+ }
602
+ }
603
+ });
604
+ }
605
+
606
+ /**
607
+ * Close all active sessions (for cleanup on server shutdown).
608
+ * @returns {Promise<void>}
609
+ */
610
+ async closeAll() {
611
+ const sessionIds = [...this._sessions.keys()];
612
+ if (sessionIds.length === 0) return;
613
+
614
+ logger.info(`[ChatSession] Closing ${sessionIds.length} active session(s)`);
615
+ await Promise.all(sessionIds.map((id) => this.closeSession(id)));
616
+ }
617
+ }
618
+
619
+ module.exports = ChatSessionManager;
package/src/config.js CHANGED
@@ -20,6 +20,8 @@ const DEFAULT_CONFIG = {
20
20
  debug_stream: false, // When true, logs AI provider streaming events (equivalent to --debug-stream CLI flag)
21
21
  db_name: "", // Custom database filename (default: database.db). Useful for per-worktree isolation.
22
22
  yolo: false, // When true, skips fine-grained AI provider permission setup (equivalent to --yolo CLI flag)
23
+ enable_chat: true, // When true, enables the chat panel feature (requires Pi AI provider)
24
+ chat: { enable_shortcuts: true }, // Chat panel settings (enable_shortcuts: show action shortcut buttons)
23
25
  providers: {}, // Custom provider configurations (overrides built-in defaults)
24
26
  monorepos: {} // Monorepo configurations: { "owner/repo": { path: "~/path/to/clone" } }
25
27
  };
@@ -138,6 +140,12 @@ async function loadConfig() {
138
140
  // Merge with defaults to ensure all keys exist
139
141
  // Legacy keys ('provider', 'model') are handled lazily via getDefaultProvider/getDefaultModel
140
142
  const mergedConfig = { ...DEFAULT_CONFIG, ...config };
143
+ // Deep-merge one level for object-valued defaults (e.g. chat, providers, monorepos)
144
+ for (const key of Object.keys(DEFAULT_CONFIG)) {
145
+ if (typeof DEFAULT_CONFIG[key] === 'object' && DEFAULT_CONFIG[key] !== null && !Array.isArray(DEFAULT_CONFIG[key])) {
146
+ mergedConfig[key] = { ...DEFAULT_CONFIG[key], ...config[key] };
147
+ }
148
+ }
141
149
 
142
150
  // Merge local config (CWD/.pair-review/config.json) on top of global config
143
151
  try {
@@ -145,6 +153,12 @@ async function loadConfig() {
145
153
  const localConfigData = await fs.readFile(localConfigPath, 'utf8');
146
154
  const localConfig = JSON.parse(localConfigData);
147
155
  Object.assign(mergedConfig, localConfig);
156
+ // Deep-merge one level for object-valued local config overrides
157
+ for (const key of Object.keys(DEFAULT_CONFIG)) {
158
+ if (typeof DEFAULT_CONFIG[key] === 'object' && DEFAULT_CONFIG[key] !== null && !Array.isArray(DEFAULT_CONFIG[key])) {
159
+ mergedConfig[key] = { ...DEFAULT_CONFIG[key], ...config[key], ...localConfig[key] };
160
+ }
161
+ }
148
162
  } catch (localError) {
149
163
  if (localError.code !== 'ENOENT') {
150
164
  if (localError instanceof SyntaxError) {