lazy-gravity 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (34) hide show
  1. package/README.md +18 -6
  2. package/dist/bin/cli.js +18 -18
  3. package/dist/bin/commands/doctor.js +2 -1
  4. package/dist/bin/commands/start.js +25 -2
  5. package/dist/bot/index.js +346 -152
  6. package/dist/commands/joinCommandHandler.js +302 -0
  7. package/dist/commands/joinDetachCommandHandler.js +285 -0
  8. package/dist/commands/registerSlashCommands.js +35 -0
  9. package/dist/database/chatSessionRepository.js +10 -0
  10. package/dist/database/userPreferenceRepository.js +72 -0
  11. package/dist/events/interactionCreateHandler.js +58 -36
  12. package/dist/events/messageCreateHandler.js +158 -53
  13. package/dist/services/antigravityLauncher.js +4 -3
  14. package/dist/services/approvalDetector.js +6 -0
  15. package/dist/services/cdpBridgeManager.js +184 -84
  16. package/dist/services/cdpConnectionPool.js +79 -51
  17. package/dist/services/cdpService.js +149 -51
  18. package/dist/services/chatSessionService.js +229 -8
  19. package/dist/services/errorPopupDetector.js +6 -0
  20. package/dist/services/planningDetector.js +6 -0
  21. package/dist/services/responseMonitor.js +125 -24
  22. package/dist/services/updateCheckService.js +147 -0
  23. package/dist/services/userMessageDetector.js +221 -0
  24. package/dist/ui/modeUi.js +11 -1
  25. package/dist/ui/outputUi.js +30 -0
  26. package/dist/ui/sessionPickerUi.js +48 -0
  27. package/dist/utils/antigravityPaths.js +94 -0
  28. package/dist/utils/configLoader.js +10 -0
  29. package/dist/utils/discordButtonUtils.js +33 -0
  30. package/dist/utils/logBuffer.js +47 -0
  31. package/dist/utils/logger.js +80 -20
  32. package/dist/utils/pathUtils.js +57 -0
  33. package/dist/utils/plainTextFormatter.js +70 -0
  34. package/package.json +4 -4
@@ -30,6 +30,118 @@ const GET_CHAT_TITLE_SCRIPT = `(() => {
30
30
  const hasActiveChat = title.length > 0 && title !== 'Agent';
31
31
  return { title: title || '(Untitled)', hasActiveChat };
32
32
  })()`;
33
+ /**
34
+ * Script to find the Past Conversations button and return its coordinates.
35
+ * We use coordinates so that the actual click is done via CDP Input.dispatchMouseEvent,
36
+ * which works reliably in Electron (DOM .click() can be ignored).
37
+ *
38
+ * Returns: { found: boolean, x: number, y: number }
39
+ */
40
+ const FIND_PAST_CONVERSATIONS_BUTTON_SCRIPT = `(() => {
41
+ const isVisible = (el) => !!el && el instanceof HTMLElement && el.offsetParent !== null;
42
+ const getRect = (el) => {
43
+ const rect = el.getBoundingClientRect();
44
+ return { found: true, x: Math.round(rect.x + rect.width / 2), y: Math.round(rect.y + rect.height / 2) };
45
+ };
46
+
47
+ // Strategy 1 (primary): data-past-conversations-toggle attribute
48
+ const toggle = document.querySelector('[data-past-conversations-toggle]');
49
+ if (toggle && isVisible(toggle)) return getRect(toggle);
50
+
51
+ // Strategy 2: data-tooltip-id containing "history"
52
+ const tooltipEls = Array.from(document.querySelectorAll('[data-tooltip-id]'));
53
+ for (const el of tooltipEls) {
54
+ if (!isVisible(el)) continue;
55
+ const tid = (el.getAttribute('data-tooltip-id') || '').toLowerCase();
56
+ if (tid.includes('history') || tid.includes('past-conversations')) {
57
+ return getRect(el);
58
+ }
59
+ }
60
+
61
+ // Strategy 3: SVG with lucide-history class
62
+ const icons = Array.from(document.querySelectorAll('svg.lucide-history, svg[class*="lucide-history"]'));
63
+ for (const icon of icons) {
64
+ const parent = icon.closest('a, button, [role="button"], div[class*="cursor-pointer"]');
65
+ const target = parent instanceof HTMLElement && isVisible(parent) ? parent : icon;
66
+ if (isVisible(target)) return getRect(target);
67
+ }
68
+
69
+ return { found: false, x: 0, y: 0 };
70
+ })()`;
71
+ /**
72
+ * Script to scrape session items from the open Past Conversations panel.
73
+ * Expects the panel to already be visible.
74
+ *
75
+ * Returns: { sessions: SessionListItem[] }
76
+ */
77
+ const SCRAPE_PAST_CONVERSATIONS_SCRIPT = `(() => {
78
+ const isVisible = (el) => !!el && el instanceof HTMLElement && el.offsetParent !== null;
79
+ const normalize = (text) => (text || '').trim();
80
+
81
+ const items = [];
82
+ const seen = new Set();
83
+
84
+ // Find the scrollable conversation list container
85
+ const containers = Array.from(document.querySelectorAll('div[class*="overflow-auto"], div[class*="overflow-y-scroll"]'));
86
+ const container = containers.find((c) => isVisible(c) && c.querySelectorAll('div[class*="cursor-pointer"]').length > 0) || document;
87
+
88
+ // Detect the "Other Conversations" section boundary.
89
+ // Sessions below this header belong to other projects and must be excluded.
90
+ let boundaryTop = Infinity;
91
+ const headerCandidates = container.querySelectorAll('div[class*="text-xs"][class*="opacity"]');
92
+ for (const el of headerCandidates) {
93
+ if (!isVisible(el)) continue;
94
+ const t = normalize(el.textContent || '');
95
+ if (/^Other\\s+Conversations?$/i.test(t)) {
96
+ boundaryTop = el.getBoundingClientRect().top;
97
+ break;
98
+ }
99
+ }
100
+
101
+ // Each session row is a div with cursor-pointer
102
+ const rows = Array.from(container.querySelectorAll('div[class*="cursor-pointer"]'));
103
+ for (const row of rows) {
104
+ if (!isVisible(row)) continue;
105
+ // Skip rows that are below the "Other Conversations" boundary
106
+ if (row.getBoundingClientRect().top >= boundaryTop) continue;
107
+ // Find the session title — nested span within the row
108
+ const spans = Array.from(row.querySelectorAll('span.text-sm span, span.text-sm'));
109
+ let title = '';
110
+ for (const span of spans) {
111
+ const t = normalize(span.textContent || '');
112
+ // Skip timestamp labels like "1 hr ago", "7 mins ago"
113
+ if (/^\\d+\\s+(min|hr|hour|day|sec|week|month|year)s?\\s+ago$/i.test(t)) continue;
114
+ // Skip very short or action-like labels
115
+ if (t.length < 2 || t.length > 200) continue;
116
+ if (/^(show\\s+\\d+\\s+more|new|past|history|settings|close|menu)\\b/i.test(t)) continue;
117
+ title = t;
118
+ break;
119
+ }
120
+ if (!title || seen.has(title)) continue;
121
+ seen.add(title);
122
+ // Detect if this is the active/current session (has focusBackground class)
123
+ const isActive = /focusBackground/i.test(row.className || '');
124
+ items.push({ title, isActive });
125
+ }
126
+ return { sessions: items };
127
+ })()`;
128
+ /**
129
+ * Script to find the "Show N more..." link and return its coordinates.
130
+ * Returns: { found: boolean, x: number, y: number }
131
+ */
132
+ const FIND_SHOW_MORE_BUTTON_SCRIPT = `(() => {
133
+ const isVisible = (el) => !!el && el instanceof HTMLElement && el.offsetParent !== null;
134
+ const els = Array.from(document.querySelectorAll('div, span'));
135
+ for (const el of els) {
136
+ if (!isVisible(el)) continue;
137
+ const text = (el.textContent || '').trim();
138
+ if (/^Show\\s+\\d+\\s+more/i.test(text)) {
139
+ const rect = el.getBoundingClientRect();
140
+ return { found: true, x: Math.round(rect.x + rect.width / 2), y: Math.round(rect.y + rect.height / 2) };
141
+ }
142
+ }
143
+ return { found: false, x: 0, y: 0 };
144
+ })()`;
33
145
  /**
34
146
  * Build a script that activates an existing chat in the side panel by its title.
35
147
  * Uses broad selector fallbacks because Antigravity's DOM structure can vary across versions.
@@ -235,14 +347,35 @@ function buildActivateViaPastConversationsScript(title) {
235
347
  };
236
348
 
237
349
  return (async () => {
238
- let opened = clickByPatterns([
239
- 'past conversations',
240
- 'past conversation',
241
- 'conversation history',
242
- 'past chats',
243
- '過去の会話',
244
- 'chat history',
245
- ]);
350
+ // Primary: click via data-past-conversations-toggle attribute
351
+ let opened = false;
352
+ const toggleBtn = document.querySelector('[data-past-conversations-toggle]');
353
+ if (toggleBtn && isVisible(toggleBtn)) {
354
+ const clickable = getClickable(toggleBtn);
355
+ if (clickable) { clickable.click(); opened = true; }
356
+ }
357
+ if (!opened) {
358
+ // Fallback: data-tooltip-id containing "history"
359
+ const tooltipEls = asArray(document.querySelectorAll('[data-tooltip-id]'));
360
+ for (const el of tooltipEls) {
361
+ if (!isVisible(el)) continue;
362
+ const tid = normalize(el.getAttribute('data-tooltip-id') || '');
363
+ if (tid.includes('history') || tid.includes('past-conversations')) {
364
+ const cl = getClickable(el);
365
+ if (cl) { cl.click(); opened = true; break; }
366
+ }
367
+ }
368
+ }
369
+ if (!opened) {
370
+ opened = clickByPatterns([
371
+ 'past conversations',
372
+ 'past conversation',
373
+ 'conversation history',
374
+ 'past chats',
375
+ '過去の会話',
376
+ 'chat history',
377
+ ]);
378
+ }
246
379
  if (!opened) {
247
380
  opened = clickIconHistoryButton();
248
381
  }
@@ -286,6 +419,94 @@ function buildActivateViaPastConversationsScript(title) {
286
419
  class ChatSessionService {
287
420
  static ACTIVATE_SESSION_MAX_WAIT_MS = 30000;
288
421
  static ACTIVATE_SESSION_RETRY_INTERVAL_MS = 800;
422
+ static LIST_SESSIONS_TARGET = 20;
423
+ /**
424
+ * List recent sessions by opening the Past Conversations panel.
425
+ *
426
+ * Flow (all clicks via CDP Input.dispatchMouseEvent for Electron compatibility):
427
+ * 1. Find Past Conversations button coordinates
428
+ * 2. Click it via CDP mouse events
429
+ * 3. Wait for panel to render
430
+ * 4. Scrape visible sessions
431
+ * 5. If < TARGET sessions, find & click "Show N more..."
432
+ * 6. Re-scrape
433
+ * 7. Close panel with Escape key
434
+ *
435
+ * @param cdpService CdpService instance to use
436
+ * @returns Array of session list items (empty array on failure)
437
+ */
438
+ async listAllSessions(cdpService) {
439
+ try {
440
+ // Step 1: Find Past Conversations button
441
+ const btnState = await this.evaluateOnAnyContext(cdpService, FIND_PAST_CONVERSATIONS_BUTTON_SCRIPT, false);
442
+ if (!btnState?.found) {
443
+ return [];
444
+ }
445
+ // Step 2: Click via CDP mouse events (reliable in Electron)
446
+ await this.cdpMouseClick(cdpService, btnState.x, btnState.y);
447
+ // Step 3: Wait for panel to render
448
+ await new Promise((r) => setTimeout(r, 500));
449
+ // Step 4: Scrape sessions
450
+ let scrapeResult = await this.evaluateOnAnyContext(cdpService, SCRAPE_PAST_CONVERSATIONS_SCRIPT, false);
451
+ let sessions = scrapeResult?.sessions ?? [];
452
+ // Step 5: If fewer than TARGET, click "Show N more..."
453
+ if (sessions.length < ChatSessionService.LIST_SESSIONS_TARGET) {
454
+ const showMoreState = await this.evaluateOnAnyContext(cdpService, FIND_SHOW_MORE_BUTTON_SCRIPT, false);
455
+ if (showMoreState?.found) {
456
+ await this.cdpMouseClick(cdpService, showMoreState.x, showMoreState.y);
457
+ await new Promise((r) => setTimeout(r, 500));
458
+ // Step 6: Re-scrape
459
+ scrapeResult = await this.evaluateOnAnyContext(cdpService, SCRAPE_PAST_CONVERSATIONS_SCRIPT, false);
460
+ sessions = scrapeResult?.sessions ?? [];
461
+ }
462
+ }
463
+ // Step 7: Close panel with Escape
464
+ await cdpService.call('Input.dispatchKeyEvent', {
465
+ type: 'keyDown', key: 'Escape', code: 'Escape',
466
+ windowsVirtualKeyCode: 27, nativeVirtualKeyCode: 27,
467
+ });
468
+ await cdpService.call('Input.dispatchKeyEvent', {
469
+ type: 'keyUp', key: 'Escape', code: 'Escape',
470
+ windowsVirtualKeyCode: 27, nativeVirtualKeyCode: 27,
471
+ });
472
+ return sessions.slice(0, ChatSessionService.LIST_SESSIONS_TARGET);
473
+ }
474
+ catch (_) {
475
+ return [];
476
+ }
477
+ }
478
+ /**
479
+ * Evaluate a script on the first context that returns a truthy value.
480
+ */
481
+ async evaluateOnAnyContext(cdpService, expression, awaitPromise) {
482
+ const contexts = cdpService.getContexts();
483
+ for (const ctx of contexts) {
484
+ try {
485
+ const result = await cdpService.call('Runtime.evaluate', {
486
+ expression, returnByValue: true, awaitPromise, contextId: ctx.id,
487
+ });
488
+ const value = result?.result?.value;
489
+ if (value)
490
+ return value;
491
+ }
492
+ catch (_) { /* try next context */ }
493
+ }
494
+ return null;
495
+ }
496
+ /**
497
+ * Click at coordinates via CDP Input.dispatchMouseEvent.
498
+ */
499
+ async cdpMouseClick(cdpService, x, y) {
500
+ await cdpService.call('Input.dispatchMouseEvent', {
501
+ type: 'mouseMoved', x, y,
502
+ });
503
+ await cdpService.call('Input.dispatchMouseEvent', {
504
+ type: 'mousePressed', x, y, button: 'left', clickCount: 1,
505
+ });
506
+ await cdpService.call('Input.dispatchMouseEvent', {
507
+ type: 'mouseReleased', x, y, button: 'left', clickCount: 1,
508
+ });
509
+ }
289
510
  /**
290
511
  * Start a new chat session in the Antigravity UI.
291
512
  *
@@ -98,6 +98,7 @@ class ErrorPopupDetector {
98
98
  cdpService;
99
99
  pollIntervalMs;
100
100
  onErrorPopup;
101
+ onResolved;
101
102
  pollTimer = null;
102
103
  isRunning = false;
103
104
  /** Key of the last detected error popup (for duplicate notification prevention) */
@@ -112,6 +113,7 @@ class ErrorPopupDetector {
112
113
  this.cdpService = options.cdpService;
113
114
  this.pollIntervalMs = options.pollIntervalMs ?? 3000;
114
115
  this.onErrorPopup = options.onErrorPopup;
116
+ this.onResolved = options.onResolved;
115
117
  }
116
118
  /** Start monitoring. */
117
119
  start() {
@@ -223,8 +225,12 @@ class ErrorPopupDetector {
223
225
  }
224
226
  else {
225
227
  // Reset when popup disappears (prepare for next detection)
228
+ const wasDetected = this.lastDetectedKey !== null;
226
229
  this.lastDetectedKey = null;
227
230
  this.lastDetectedInfo = null;
231
+ if (wasDetected && this.onResolved) {
232
+ this.onResolved();
233
+ }
228
234
  }
229
235
  }
230
236
  catch (error) {
@@ -149,6 +149,7 @@ class PlanningDetector {
149
149
  cdpService;
150
150
  pollIntervalMs;
151
151
  onPlanningRequired;
152
+ onResolved;
152
153
  pollTimer = null;
153
154
  isRunning = false;
154
155
  /** Key of the last detected planning info (for duplicate notification prevention) */
@@ -163,6 +164,7 @@ class PlanningDetector {
163
164
  this.cdpService = options.cdpService;
164
165
  this.pollIntervalMs = options.pollIntervalMs ?? 2000;
165
166
  this.onPlanningRequired = options.onPlanningRequired;
167
+ this.onResolved = options.onResolved;
166
168
  }
167
169
  /** Start monitoring. */
168
170
  start() {
@@ -270,8 +272,12 @@ class PlanningDetector {
270
272
  }
271
273
  else {
272
274
  // Reset when buttons disappear (prepare for next planning detection)
275
+ const wasDetected = this.lastDetectedKey !== null;
273
276
  this.lastDetectedKey = null;
274
277
  this.lastDetectedInfo = null;
278
+ if (wasDetected && this.onResolved) {
279
+ this.onResolved();
280
+ }
275
281
  }
276
282
  }
277
283
  catch (error) {
@@ -453,7 +453,6 @@ class ResponseMonitor {
453
453
  onPhaseChange;
454
454
  onProcessLog;
455
455
  pollTimer = null;
456
- timeoutTimer = null;
457
456
  isRunning = false;
458
457
  lastText = null;
459
458
  baselineText = null;
@@ -463,6 +462,13 @@ class ResponseMonitor {
463
462
  quotaDetected = false;
464
463
  seenProcessLogKeys = new Set();
465
464
  structuredDiagLogged = false;
465
+ // CDP disconnect handling (#48)
466
+ isPaused = false;
467
+ onCdpDisconnected = null;
468
+ onCdpReconnected = null;
469
+ onCdpReconnectFailed = null;
470
+ // Activity-based timeout (#49)
471
+ lastActivityTime = 0;
466
472
  constructor(options) {
467
473
  this.cdpService = options.cdpService;
468
474
  this.pollIntervalMs = options.pollIntervalMs ?? 2000;
@@ -478,18 +484,31 @@ class ResponseMonitor {
478
484
  }
479
485
  /** Start monitoring */
480
486
  async start() {
487
+ return this.initMonitoring(false);
488
+ }
489
+ /**
490
+ * Start monitoring in passive mode.
491
+ * Same as start() but with generationStarted=true, so text changes
492
+ * are detected immediately without waiting for the stop button to appear.
493
+ * Used when joining an existing session that may already be generating.
494
+ */
495
+ async startPassive() {
496
+ return this.initMonitoring(true);
497
+ }
498
+ /** Internal initialization shared between start() and startPassive() */
499
+ async initMonitoring(passive) {
481
500
  if (this.isRunning)
482
501
  return;
483
502
  this.isRunning = true;
503
+ this.isPaused = false;
484
504
  this.lastText = null;
485
505
  this.baselineText = null;
486
- this.generationStarted = false;
487
- this.currentPhase = 'waiting';
506
+ this.generationStarted = passive;
507
+ this.currentPhase = passive ? 'generating' : 'waiting';
488
508
  this.stopGoneCount = 0;
489
509
  this.quotaDetected = false;
490
510
  this.seenProcessLogKeys = new Set();
491
- // Always fire callback on start, even though phase is already 'waiting'
492
- this.onPhaseChange?.('waiting', null);
511
+ this.onPhaseChange?.(this.currentPhase, null);
493
512
  // Capture baseline text
494
513
  try {
495
514
  const baseResult = await this.cdpService.call('Runtime.evaluate', this.buildEvaluateParams(exports.RESPONSE_SELECTORS.RESPONSE_TEXT));
@@ -513,35 +532,44 @@ class ResponseMonitor {
513
532
  catch {
514
533
  // baseline capture only
515
534
  }
516
- // Set timeout timer
517
- if (this.maxDurationMs > 0) {
518
- this.timeoutTimer = setTimeout(async () => {
519
- const lastText = this.lastText ?? '';
520
- this.setPhase('timeout', lastText);
521
- await this.stop();
522
- try {
523
- await Promise.resolve(this.onTimeout?.(lastText));
524
- }
525
- catch (error) {
526
- logger_1.logger.error('[ResponseMonitor] timeout callback failed:', error);
535
+ // In structured mode, also capture activity lines from the structured
536
+ // extraction to align the baseline with polling logic. The PROCESS_LOGS
537
+ // script skips <details> content, but structured extraction (Pass 2)
538
+ // explicitly walks <details> elements — without this, tool-call/thinking
539
+ // entries from previous turns leak into the process log as "new" entries.
540
+ if (this.extractionMode === 'structured') {
541
+ try {
542
+ const structuredBaseline = await this.cdpService.call('Runtime.evaluate', this.buildEvaluateParams(exports.RESPONSE_SELECTORS.RESPONSE_STRUCTURED));
543
+ const baselineClassified = (0, assistantDomExtractor_1.classifyAssistantSegments)(structuredBaseline?.result?.value);
544
+ if (baselineClassified.diagnostics.source === 'dom-structured') {
545
+ for (const line of baselineClassified.activityLines) {
546
+ const key = (line || '').replace(/\r/g, '').trim().slice(0, 200);
547
+ if (key)
548
+ this.seenProcessLogKeys.add(key);
549
+ }
527
550
  }
528
- }, this.maxDurationMs);
551
+ }
552
+ catch {
553
+ // structured baseline is best-effort
554
+ }
529
555
  }
530
- logger_1.logger.info(`── Monitoring started | poll=${this.pollIntervalMs}ms timeout=${this.maxDurationMs / 1000}s baseline=${this.baselineText?.length ?? 0}ch`);
531
- // Start polling
556
+ // Activity-based timeout: track last activity time instead of fixed timer (#49)
557
+ this.lastActivityTime = Date.now();
558
+ // Register CDP connection event listeners (#48)
559
+ this.registerCdpConnectionListeners();
560
+ const mode = passive ? 'Passive monitoring' : 'Monitoring';
561
+ logger_1.logger.debug(`── ${mode} started | poll=${this.pollIntervalMs}ms inactivityTimeout=${this.maxDurationMs / 1000}s baseline=${this.baselineText?.length ?? 0}ch`);
532
562
  this.schedulePoll();
533
563
  }
534
564
  /** Stop monitoring */
535
565
  async stop() {
536
566
  this.isRunning = false;
567
+ this.isPaused = false;
568
+ this.unregisterCdpConnectionListeners();
537
569
  if (this.pollTimer) {
538
570
  clearTimeout(this.pollTimer);
539
571
  this.pollTimer = null;
540
572
  }
541
- if (this.timeoutTimer) {
542
- clearTimeout(this.timeoutTimer);
543
- this.timeoutTimer = null;
544
- }
545
573
  }
546
574
  /** Get current phase */
547
575
  getPhase() {
@@ -593,14 +621,71 @@ class ResponseMonitor {
593
621
  case 'quotaReached':
594
622
  logger_1.logger.warn('Quota Reached');
595
623
  break;
624
+ case 'disconnected':
625
+ logger_1.logger.warn(`CDP Disconnected — paused (${len} chars captured)`);
626
+ break;
596
627
  default:
597
628
  logger_1.logger.phase(`${phase}`);
598
629
  }
599
630
  this.onPhaseChange?.(phase, text);
600
631
  }
601
632
  }
633
+ registerCdpConnectionListeners() {
634
+ this.onCdpDisconnected = () => {
635
+ if (!this.isRunning)
636
+ return;
637
+ logger_1.logger.warn('[ResponseMonitor] CDP disconnected — pausing poll');
638
+ this.isPaused = true;
639
+ if (this.pollTimer) {
640
+ clearTimeout(this.pollTimer);
641
+ this.pollTimer = null;
642
+ }
643
+ this.setPhase('disconnected', this.lastText);
644
+ };
645
+ this.onCdpReconnected = () => {
646
+ if (!this.isRunning)
647
+ return;
648
+ logger_1.logger.warn('[ResponseMonitor] CDP reconnected — resuming poll');
649
+ this.isPaused = false;
650
+ this.lastActivityTime = Date.now();
651
+ const resumePhase = this.generationStarted ? 'generating' : 'waiting';
652
+ this.setPhase(resumePhase, this.lastText);
653
+ this.schedulePoll();
654
+ };
655
+ this.onCdpReconnectFailed = async (err) => {
656
+ if (!this.isRunning)
657
+ return;
658
+ logger_1.logger.error('[ResponseMonitor] CDP reconnection failed — stopping monitor:', err.message);
659
+ const lastText = this.lastText ?? '';
660
+ this.setPhase('disconnected', lastText);
661
+ await this.stop();
662
+ try {
663
+ await Promise.resolve(this.onTimeout?.(lastText));
664
+ }
665
+ catch (error) {
666
+ logger_1.logger.error('[ResponseMonitor] timeout callback failed:', error);
667
+ }
668
+ };
669
+ this.cdpService.on('disconnected', this.onCdpDisconnected);
670
+ this.cdpService.on('reconnected', this.onCdpReconnected);
671
+ this.cdpService.on('reconnectFailed', this.onCdpReconnectFailed);
672
+ }
673
+ unregisterCdpConnectionListeners() {
674
+ if (this.onCdpDisconnected) {
675
+ this.cdpService.removeListener('disconnected', this.onCdpDisconnected);
676
+ this.onCdpDisconnected = null;
677
+ }
678
+ if (this.onCdpReconnected) {
679
+ this.cdpService.removeListener('reconnected', this.onCdpReconnected);
680
+ this.onCdpReconnected = null;
681
+ }
682
+ if (this.onCdpReconnectFailed) {
683
+ this.cdpService.removeListener('reconnectFailed', this.onCdpReconnectFailed);
684
+ this.onCdpReconnectFailed = null;
685
+ }
686
+ }
602
687
  schedulePoll() {
603
- if (!this.isRunning)
688
+ if (!this.isRunning || this.isPaused)
604
689
  return;
605
690
  this.pollTimer = setTimeout(async () => {
606
691
  await this.poll();
@@ -637,6 +722,7 @@ class ResponseMonitor {
637
722
  newEntries.push(normalized.slice(0, 300));
638
723
  }
639
724
  if (newEntries.length > 0) {
725
+ this.lastActivityTime = Date.now();
640
726
  try {
641
727
  this.onProcessLog?.(newEntries.join('\n\n'));
642
728
  }
@@ -714,6 +800,7 @@ class ResponseMonitor {
714
800
  }
715
801
  // Handle stop button appearing
716
802
  if (isGenerating) {
803
+ this.lastActivityTime = Date.now();
717
804
  if (!this.generationStarted) {
718
805
  this.generationStarted = true;
719
806
  this.setPhase('thinking', null);
@@ -748,6 +835,7 @@ class ResponseMonitor {
748
835
  // Text change handling
749
836
  const textChanged = effectiveText !== null && effectiveText !== this.lastText;
750
837
  if (textChanged) {
838
+ this.lastActivityTime = Date.now();
751
839
  this.lastText = effectiveText;
752
840
  if (this.currentPhase === 'waiting' || this.currentPhase === 'thinking') {
753
841
  this.setPhase('generating', effectiveText);
@@ -773,6 +861,19 @@ class ResponseMonitor {
773
861
  return;
774
862
  }
775
863
  }
864
+ // Activity-based inactivity timeout (#49)
865
+ if (this.maxDurationMs > 0 && Date.now() - this.lastActivityTime >= this.maxDurationMs) {
866
+ const lastText = this.lastText ?? '';
867
+ this.setPhase('timeout', lastText);
868
+ await this.stop();
869
+ try {
870
+ await Promise.resolve(this.onTimeout?.(lastText));
871
+ }
872
+ catch (error) {
873
+ logger_1.logger.error('[ResponseMonitor] timeout callback failed:', error);
874
+ }
875
+ return;
876
+ }
776
877
  }
777
878
  catch (error) {
778
879
  logger_1.logger.error('[ResponseMonitor] poll error:', error);