ai-or-die 0.1.44 → 0.1.46

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.
@@ -471,6 +471,78 @@ jobs:
471
471
  playwright-report/
472
472
  retention-days: 14
473
473
 
474
+ test-browser-mobile-journeys:
475
+ runs-on: ${{ matrix.os }}
476
+ needs: test
477
+ timeout-minutes: 12
478
+ strategy:
479
+ fail-fast: false
480
+ matrix:
481
+ os: [ubuntu-latest, windows-latest]
482
+ steps:
483
+ - uses: actions/checkout@v4
484
+ - uses: actions/setup-node@v4
485
+ with:
486
+ node-version: '22'
487
+ cache: 'npm'
488
+ - run: npm ci
489
+ - name: Cache Playwright browsers
490
+ uses: actions/cache@v4
491
+ with:
492
+ path: |
493
+ ~/.cache/ms-playwright
494
+ ~/AppData/Local/ms-playwright
495
+ key: playwright-${{ runner.os }}-${{ hashFiles('package-lock.json') }}
496
+ - name: Install Playwright browsers
497
+ run: npx playwright install chromium --with-deps
498
+ - name: Run mobile journey tests
499
+ run: npx playwright test --config e2e/playwright.config.js --project mobile-journeys
500
+ - name: Upload Playwright report
501
+ uses: actions/upload-artifact@v4
502
+ if: ${{ !cancelled() }}
503
+ with:
504
+ name: playwright-mobile-journeys-${{ matrix.os }}
505
+ path: |
506
+ e2e/test-results/
507
+ playwright-report/
508
+ retention-days: 14
509
+
510
+ test-browser-restart:
511
+ runs-on: ${{ matrix.os }}
512
+ needs: test
513
+ timeout-minutes: 12
514
+ strategy:
515
+ fail-fast: false
516
+ matrix:
517
+ os: [ubuntu-latest, windows-latest]
518
+ steps:
519
+ - uses: actions/checkout@v4
520
+ - uses: actions/setup-node@v4
521
+ with:
522
+ node-version: '22'
523
+ cache: 'npm'
524
+ - run: npm ci
525
+ - name: Cache Playwright browsers
526
+ uses: actions/cache@v4
527
+ with:
528
+ path: |
529
+ ~/.cache/ms-playwright
530
+ ~/AppData/Local/ms-playwright
531
+ key: playwright-${{ runner.os }}-${{ hashFiles('package-lock.json') }}
532
+ - name: Install Playwright browsers
533
+ run: npx playwright install chromium --with-deps
534
+ - name: Run restart tests
535
+ run: npx playwright test --config e2e/playwright.config.js --project restart
536
+ - name: Upload Playwright report
537
+ uses: actions/upload-artifact@v4
538
+ if: ${{ !cancelled() }}
539
+ with:
540
+ name: playwright-restart-${{ matrix.os }}
541
+ path: |
542
+ e2e/test-results/
543
+ playwright-report/
544
+ retention-days: 14
545
+
474
546
  build-binary:
475
547
  runs-on: ${{ matrix.os }}
476
548
  timeout-minutes: 12
@@ -0,0 +1,121 @@
1
+ #!/usr/bin/env node
2
+
3
+ 'use strict';
4
+
5
+ const { spawn } = require('child_process');
6
+ const path = require('path');
7
+ const { RESTART_EXIT_CODE } = require('../src/restart-manager');
8
+
9
+ const RESTART_DELAY_MS = 1000;
10
+ const CRASH_RESTART_DELAY_MS = 3000;
11
+ const CIRCUIT_BREAKER_WINDOW_MS = 30000;
12
+ const CIRCUIT_BREAKER_MAX_CRASHES = 3;
13
+ const SHUTDOWN_TIMEOUT_MS = 10000;
14
+
15
+ const serverScript = process.env.SUPERVISOR_CHILD_SCRIPT
16
+ || path.join(__dirname, 'ai-or-die.js');
17
+ const forwardedArgs = process.argv.slice(2);
18
+
19
+ let child = null;
20
+ let shuttingDown = false;
21
+ let crashTimestamps = [];
22
+ let pendingRestartTimer = null;
23
+
24
+ function startServer() {
25
+ pendingRestartTimer = null;
26
+ const nodeArgs = ['--expose-gc', serverScript, ...forwardedArgs];
27
+
28
+ child = spawn(process.execPath, nodeArgs, {
29
+ stdio: ['inherit', 'inherit', 'inherit', 'ipc'],
30
+ env: { ...process.env, SUPERVISED: '1' }
31
+ });
32
+
33
+ child.on('exit', (code, signal) => {
34
+ child = null;
35
+
36
+ if (shuttingDown) {
37
+ process.exit(0);
38
+ return;
39
+ }
40
+
41
+ if (code === 0) {
42
+ // Clean shutdown — don't restart
43
+ console.log('[supervisor] Server exited cleanly');
44
+ process.exit(0);
45
+ return;
46
+ }
47
+
48
+ if (code === RESTART_EXIT_CODE) {
49
+ // Restart requested — quick restart, don't count as crash
50
+ console.log(`[supervisor] Restart requested, respawning in ${RESTART_DELAY_MS}ms...`);
51
+ pendingRestartTimer = setTimeout(startServer, RESTART_DELAY_MS);
52
+ return;
53
+ }
54
+
55
+ // Unexpected exit — check circuit breaker
56
+ const now = Date.now();
57
+ crashTimestamps.push(now);
58
+ // Remove timestamps outside the window
59
+ crashTimestamps = crashTimestamps.filter(t => now - t < CIRCUIT_BREAKER_WINDOW_MS);
60
+
61
+ if (crashTimestamps.length >= CIRCUIT_BREAKER_MAX_CRASHES) {
62
+ console.error(`[supervisor] Circuit breaker: ${CIRCUIT_BREAKER_MAX_CRASHES} crashes within ${CIRCUIT_BREAKER_WINDOW_MS / 1000}s. Stopping.`);
63
+ process.exit(1);
64
+ return;
65
+ }
66
+
67
+ const exitInfo = signal ? `signal ${signal}` : `code ${code}`;
68
+ console.warn(`[supervisor] Server exited unexpectedly (${exitInfo}), restarting in ${CRASH_RESTART_DELAY_MS}ms... (crash ${crashTimestamps.length}/${CIRCUIT_BREAKER_MAX_CRASHES})`);
69
+ pendingRestartTimer = setTimeout(startServer, CRASH_RESTART_DELAY_MS);
70
+ });
71
+
72
+ child.on('error', (err) => {
73
+ // Spawn errors (ENOENT, EACCES) — the child process failed to launch.
74
+ // Port-in-use (EADDRINUSE) errors surface as child exit codes, not here.
75
+ console.error('[supervisor] Failed to spawn server:', err.message);
76
+ });
77
+ }
78
+
79
+ function shutdownGracefully() {
80
+ if (shuttingDown) return;
81
+ shuttingDown = true;
82
+
83
+ // Cancel any pending restart timer to prevent spawning a new child during shutdown
84
+ if (pendingRestartTimer) {
85
+ clearTimeout(pendingRestartTimer);
86
+ pendingRestartTimer = null;
87
+ }
88
+
89
+ if (!child) {
90
+ process.exit(0);
91
+ return;
92
+ }
93
+
94
+ // Send shutdown via IPC (works on Windows, unlike SIGINT)
95
+ try {
96
+ child.send({ type: 'shutdown' });
97
+ } catch (_) {
98
+ // IPC channel may be closed
99
+ }
100
+
101
+ // Fallback: force kill after timeout
102
+ const killTimer = setTimeout(() => {
103
+ console.warn('[supervisor] Server did not exit within timeout, force killing');
104
+ // child may be null if exit handler fired between IPC send and this timer
105
+ if (child) {
106
+ try { child.kill('SIGKILL'); } catch (_) { /* ignore */ }
107
+ }
108
+ setTimeout(() => process.exit(1), 1000);
109
+ }, SHUTDOWN_TIMEOUT_MS);
110
+ killTimer.unref();
111
+ }
112
+
113
+ process.on('SIGINT', shutdownGracefully);
114
+ // SIGTERM is not available on Windows; IPC message (below) is the Windows shutdown path
115
+ process.on('SIGTERM', shutdownGracefully);
116
+ // Allow test harness to trigger shutdown via IPC
117
+ process.on('message', (msg) => {
118
+ if (msg && msg.type === 'shutdown') shutdownGracefully();
119
+ });
120
+
121
+ startServer();
@@ -127,5 +127,11 @@ module.exports = defineConfig({
127
127
  name: 'mobile-journeys',
128
128
  testMatch: '50-mobile-user-journeys.spec.js',
129
129
  },
130
+ // Server restart feature tests
131
+ {
132
+ name: 'restart',
133
+ testMatch: '20-server-restart.spec.js',
134
+ timeout: 120000,
135
+ },
130
136
  ],
131
137
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ai-or-die",
3
- "version": "0.1.44",
3
+ "version": "0.1.46",
4
4
  "description": "Universal AI coding terminal — Claude, Copilot, Gemini & more in your browser",
5
5
  "main": "src/server.js",
6
6
  "bin": {
@@ -8,9 +8,12 @@
8
8
  "aiordie": "./bin/ai-or-die.js"
9
9
  },
10
10
  "scripts": {
11
- "start": "node bin/ai-or-die.js",
12
- "dev": "node bin/ai-or-die.js --dev",
11
+ "start": "node bin/supervisor.js",
12
+ "start:direct": "node bin/ai-or-die.js",
13
+ "dev": "node bin/supervisor.js --dev",
14
+ "dev:direct": "node bin/ai-or-die.js --dev",
13
15
  "test": "mocha --exit test/*.test.js",
16
+ "test:integration": "mocha --exit --timeout 20000 test/supervisor-integration.test.js",
14
17
  "test:browser": "npx playwright test --config e2e/playwright.config.js",
15
18
  "build:bundle": "node scripts/build-sea.js bundle",
16
19
  "build:sea": "node scripts/build-sea.js",
package/src/public/app.js CHANGED
@@ -674,11 +674,16 @@ class ClaudeCodeWebInterface {
674
674
  }
675
675
 
676
676
  _setupExtraKeys() {
677
- if (!this.isMobile || !window.visualViewport || typeof ExtraKeys === 'undefined') return;
677
+ if (!this.isMobile || typeof ExtraKeys === 'undefined') return;
678
678
 
679
679
  this.extraKeys = new ExtraKeys({ app: this });
680
680
  this._keyboardOpen = false;
681
681
 
682
+ // Browsers without visualViewport (Firefox Android, Samsung Internet):
683
+ // Initialize extra-keys but skip viewport-based keyboard detection.
684
+ // The extra-keys bar is still usable via manual show/hide.
685
+ if (!window.visualViewport) return;
686
+
682
687
  // Thrashing detection: if >3 resize events in 500ms, fall back to fixed threshold
683
688
  const resizeTimestamps = [];
684
689
  let useFallbackThreshold = false;
@@ -1146,6 +1151,64 @@ class ClaudeCodeWebInterface {
1146
1151
  }
1147
1152
  }
1148
1153
 
1154
+ _showMemoryWarning(message) {
1155
+ // Don't show duplicate warnings if banner already visible
1156
+ if (document.getElementById('memoryWarningBanner')) return;
1157
+
1158
+ const banner = document.createElement('div');
1159
+ banner.id = 'memoryWarningBanner';
1160
+ banner.setAttribute('role', 'alert');
1161
+ banner.setAttribute('aria-atomic', 'true');
1162
+ banner.style.cssText = 'position:fixed;top:0;left:0;right:0;z-index:10000;background:#92400e;color:#fef3c7;padding:10px 16px;display:flex;align-items:center;justify-content:space-between;gap:8px;font-size:13px;font-family:Inter,sans-serif;box-shadow:0 2px 8px rgba(0,0,0,0.3);';
1163
+
1164
+ const text = document.createElement('span');
1165
+ const icon = document.createElement('span');
1166
+ icon.setAttribute('aria-hidden', 'true');
1167
+ icon.textContent = '\u26a0\ufe0f ';
1168
+ text.appendChild(icon);
1169
+ if (message.supervised) {
1170
+ text.appendChild(document.createTextNode(`Memory usage is high (${message.rss}). Save your work \u2014 you can restart now to keep things running smoothly.`));
1171
+ } else {
1172
+ text.appendChild(document.createTextNode(`Memory usage is high (${message.rss}). Save your work, then stop the server (Ctrl+C) and run \u201cnpm start\u201d again to free memory.`));
1173
+ }
1174
+ banner.appendChild(text);
1175
+
1176
+ const btnGroup = document.createElement('div');
1177
+ btnGroup.style.cssText = 'display:flex;gap:8px;flex-shrink:0;';
1178
+
1179
+ if (message.supervised) {
1180
+ const restartBtn = document.createElement('button');
1181
+ restartBtn.textContent = 'Restart Now';
1182
+ restartBtn.setAttribute('aria-label', 'Restart the server now to free memory');
1183
+ restartBtn.style.cssText = 'background:#b91c1c;color:#fff;border:none;padding:6px 14px;border-radius:4px;cursor:pointer;font-size:12px;font-weight:600;min-height:32px;';
1184
+ restartBtn.addEventListener('click', () => {
1185
+ this.send({ type: 'restart_server' });
1186
+ banner.remove();
1187
+ });
1188
+ btnGroup.appendChild(restartBtn);
1189
+ }
1190
+
1191
+ const dismissBtn = document.createElement('button');
1192
+ dismissBtn.textContent = 'Dismiss';
1193
+ dismissBtn.setAttribute('aria-label', 'Dismiss memory warning');
1194
+ dismissBtn.style.cssText = 'background:transparent;color:#fef3c7;border:1px solid #fef3c7;padding:6px 12px;border-radius:4px;cursor:pointer;font-size:12px;min-height:32px;';
1195
+ dismissBtn.addEventListener('click', () => banner.remove());
1196
+ btnGroup.appendChild(dismissBtn);
1197
+
1198
+ banner.appendChild(btnGroup);
1199
+ document.body.appendChild(banner);
1200
+
1201
+ // Focus dismiss button unless user is actively typing
1202
+ const active = document.activeElement;
1203
+ const isTyping = active && (active.tagName === 'INPUT' || active.tagName === 'TEXTAREA' || active.isContentEditable);
1204
+ if (!isTyping) dismissBtn.focus();
1205
+
1206
+ // Auto-dismiss after 90 seconds (longer to account for users stepping away)
1207
+ setTimeout(() => {
1208
+ if (banner.parentNode) banner.remove();
1209
+ }, 90000);
1210
+ }
1211
+
1149
1212
  _handleVoiceMessage(message) {
1150
1213
  var btn = document.getElementById('voiceInputBtn');
1151
1214
  switch (message.type) {
@@ -1404,6 +1467,15 @@ class ClaudeCodeWebInterface {
1404
1467
 
1405
1468
  this.socket.onopen = () => {
1406
1469
  this.reconnectAttempts = 0;
1470
+ // Clear server restart state if we were reconnecting after a restart
1471
+ if (this._serverRestarting) {
1472
+ this._serverRestarting = false;
1473
+ this._restartReconnectAttempts = 0;
1474
+ if (this._restartTimeout) {
1475
+ clearTimeout(this._restartTimeout);
1476
+ this._restartTimeout = null;
1477
+ }
1478
+ }
1407
1479
  this.updateStatus('Connected');
1408
1480
  console.log('Connected to server');
1409
1481
 
@@ -1438,7 +1510,14 @@ class ClaudeCodeWebInterface {
1438
1510
  };
1439
1511
 
1440
1512
  this.socket.onclose = (event) => {
1441
- if (!event.wasClean && this.reconnectAttempts < this.maxReconnectAttempts) {
1513
+ // During server restart, don't count failures against reconnect budget
1514
+ // but still use backoff to avoid thundering herd
1515
+ if (this._serverRestarting) {
1516
+ this.updateStatus('Restarting \u2014 reconnecting\u2026');
1517
+ const restartBackoff = Math.min(2000 * Math.pow(1.5, this._restartReconnectAttempts || 0), 15000);
1518
+ this._restartReconnectAttempts = (this._restartReconnectAttempts || 0) + 1;
1519
+ setTimeout(() => this.reconnect(), restartBackoff);
1520
+ } else if (!event.wasClean && this.reconnectAttempts < this.maxReconnectAttempts) {
1442
1521
  this.updateStatus('Reconnecting...');
1443
1522
  setTimeout(() => this.reconnect(), Math.min(this.reconnectDelay * Math.pow(2, this.reconnectAttempts), 30000));
1444
1523
  this.reconnectAttempts++;
@@ -1477,6 +1556,9 @@ class ClaudeCodeWebInterface {
1477
1556
  if (this._reconnecting) return;
1478
1557
  this._reconnecting = true;
1479
1558
  this.disconnect();
1559
+ // Reset overlay flag so session_joined can show start prompt if terminal
1560
+ // exited during the disconnect (fixes stuck blank screen after reconnect)
1561
+ this._overlayExplicitlyHidden = false;
1480
1562
  // Reset flow control state so stale pause signals aren't sent on new connection
1481
1563
  this._outputPaused = false;
1482
1564
  this._pendingCallbacks = 0;
@@ -1494,7 +1576,10 @@ class ClaudeCodeWebInterface {
1494
1576
  }
1495
1577
  setTimeout(() => {
1496
1578
  try {
1497
- this.connect()
1579
+ const connectTimeout = new Promise((_, reject) =>
1580
+ setTimeout(() => reject(new Error('Connection timeout')), 10000)
1581
+ );
1582
+ Promise.race([this.connect(), connectTimeout])
1498
1583
  .catch(err => console.error('Reconnection failed:', err))
1499
1584
  .finally(() => { this._reconnecting = false; });
1500
1585
  } catch (err) {
@@ -1691,10 +1776,13 @@ class ClaudeCodeWebInterface {
1691
1776
  if (isNewSession) {
1692
1777
  console.log('[session_joined] New session detected, showing start prompt');
1693
1778
  this.showOverlay('startPrompt');
1779
+ } else if (message.wasActive && message.agent) {
1780
+ console.log('[session_joined] Session was active before server restart, agent:', message.agent);
1781
+ const toolAlias = this.getAlias(message.agent) || message.agent;
1782
+ this.terminal.writeln(`\r\n\x1b[33mThe server was restarted and ${toolAlias} was stopped. Your session history is preserved \u2014 click \u201cStart ${toolAlias}\u201d below to pick up where you left off.\x1b[0m`);
1783
+ this.showOverlay('startPrompt');
1694
1784
  } else {
1695
- console.log('[session_joined] Existing session with stopped Claude, showing restart prompt');
1696
- // For existing sessions where Claude has stopped, show start prompt
1697
- // This allows the user to restart Claude in the same session
1785
+ console.log('[session_joined] Existing session with stopped tool, showing restart prompt');
1698
1786
  this.terminal.writeln(`\r\n\x1b[33m${this.getAlias('claude')} has stopped in this session. Click "Start ${this.getAlias('claude')}" to restart.\x1b[0m`);
1699
1787
  this.showOverlay('startPrompt');
1700
1788
  }
@@ -1960,6 +2048,28 @@ class ClaudeCodeWebInterface {
1960
2048
  this._handleVoiceMessage(message);
1961
2049
  break;
1962
2050
 
2051
+ // Server memory and restart events
2052
+ case 'memory_warning':
2053
+ this._showMemoryWarning(message);
2054
+ break;
2055
+
2056
+ case 'server_restarting':
2057
+ console.log('[server_restarting] Server restart imminent');
2058
+ this._serverRestarting = true;
2059
+ this._restartReconnectAttempts = 0;
2060
+ this.reconnectAttempts = 0;
2061
+ this.updateStatus('Restarting \u2014 please wait\u2026');
2062
+ // Start a 60-second timeout — if server doesn't come back, show error
2063
+ if (this._restartTimeout) clearTimeout(this._restartTimeout);
2064
+ this._restartTimeout = setTimeout(() => {
2065
+ if (this._serverRestarting) {
2066
+ this._serverRestarting = false;
2067
+ this.updateStatus('Disconnected');
2068
+ this.showError('The server did not come back after restarting.\n\n\u2022 Refresh this page to try reconnecting\n\u2022 If the problem persists, restart the server manually with \u201cnpm start\u201d');
2069
+ }
2070
+ }, 60000);
2071
+ break;
2072
+
1963
2073
  default:
1964
2074
  console.log('Unknown message type:', message.type);
1965
2075
  }
@@ -3694,6 +3804,11 @@ class ClaudeCodeWebInterface {
3694
3804
  this.pendingJoinResolve = resolve;
3695
3805
  this.pendingJoinSessionId = sessionId;
3696
3806
 
3807
+ // Reset overlay flag before joining a new session so that
3808
+ // session_joined can correctly show/hide the overlay based on
3809
+ // the NEW session's state, not a stale flag from the previous tab.
3810
+ this._overlayExplicitlyHidden = false;
3811
+
3697
3812
  // Send the join request
3698
3813
  this.send({ type: 'join_session', sessionId });
3699
3814
 
@@ -4012,7 +4127,7 @@ class ClaudeCodeWebInterface {
4012
4127
  // Container is already visible by default
4013
4128
 
4014
4129
  // Check if mobile screen
4015
- const isMobile = window.innerWidth <= 768;
4130
+ const isMobile = window.innerWidth <= 820;
4016
4131
  const isSmallMobile = window.innerWidth <= 480;
4017
4132
 
4018
4133
  // Format tokens (K/M notation)
@@ -4152,7 +4267,7 @@ class ClaudeCodeWebInterface {
4152
4267
  if (totalTokens > 0) {
4153
4268
  const opusPercent = (opusTokens / totalTokens) * 100;
4154
4269
  const sonnetPercent = (sonnetTokens / totalTokens) * 100;
4155
- const isMobile = window.innerWidth <= 768;
4270
+ const isMobile = window.innerWidth <= 820;
4156
4271
 
4157
4272
  // Use short names on mobile, full names on desktop
4158
4273
  const opusName = isMobile ? 'O' : 'Opus';
@@ -4175,7 +4290,7 @@ class ClaudeCodeWebInterface {
4175
4290
  }
4176
4291
  } else {
4177
4292
  // No active session or expired session - show zeros
4178
- const isMobile = window.innerWidth <= 768;
4293
+ const isMobile = window.innerWidth <= 820;
4179
4294
 
4180
4295
  document.getElementById('usageTitle').textContent = '0h 0m';
4181
4296
  document.getElementById('usageTokens').textContent = isMobile ? '0%' : '0';
@@ -78,8 +78,8 @@
78
78
 
79
79
  @media (orientation: landscape) {
80
80
  .extra-key {
81
- min-height: 36px;
82
- min-width: 36px;
81
+ min-height: 44px;
82
+ min-width: 44px;
83
83
  font-size: var(--text-sm);
84
84
  }
85
85
  }
@@ -433,6 +433,8 @@
433
433
  .overflow-tab-close {
434
434
  width: 20px;
435
435
  height: 20px;
436
+ min-width: 44px;
437
+ min-height: 44px;
436
438
  display: flex;
437
439
  align-items: center;
438
440
  justify-content: center;
@@ -328,7 +328,7 @@
328
328
  padding: 0;
329
329
  border-width: 0;
330
330
  overflow: hidden;
331
- transition: opacity 0.2s ease, height 0.2s ease;
331
+ transition: opacity 0.2s ease, height 0.2s ease, padding 0.2s ease, border-width 0.2s ease;
332
332
  }
333
333
  body.keyboard-open .session-tabs-bar {
334
334
  height: 0;
@@ -336,7 +336,8 @@
336
336
  overflow: hidden;
337
337
  padding: 0;
338
338
  border-width: 0;
339
- transition: height 0.2s ease;
339
+ opacity: 0;
340
+ transition: opacity 0.2s ease, height 0.2s ease, padding 0.2s ease, border-width 0.2s ease;
340
341
  }
341
342
  body.keyboard-open #app {
342
343
  padding-bottom: 0 !important;
@@ -479,7 +479,7 @@ class SessionTabManager {
479
479
  }
480
480
 
481
481
  updateTabOverflow() {
482
- const isMobile = window.innerWidth <= 768;
482
+ const isMobile = window.innerWidth <= 820;
483
483
  const overflowWrapper = document.getElementById('tabOverflowWrapper');
484
484
  const overflowCount = document.querySelector('.tab-overflow-count');
485
485
 
@@ -647,7 +647,7 @@ class SessionTabManager {
647
647
  });
648
648
 
649
649
  // Reorder tabs based on the initial timestamps (mobile only)
650
- if (window.innerWidth <= 768) {
650
+ if (window.innerWidth <= 820) {
651
651
  this.reorderTabsByLastAccessed();
652
652
  }
653
653
 
@@ -808,7 +808,7 @@ class SessionTabManager {
808
808
  this.updateTabHistory(sessionId);
809
809
  }
810
810
 
811
- if (window.innerWidth <= 768) {
811
+ if (window.innerWidth <= 820) {
812
812
  const tabIndex = this.getOrderedTabIds().indexOf(sessionId);
813
813
  if (tabIndex >= 2) this.reorderTabsByLastAccessed();
814
814
  }
@@ -0,0 +1,132 @@
1
+ 'use strict';
2
+
3
+ const RESTART_EXIT_CODE = 75;
4
+ const MEMORY_CHECK_INTERVAL_MS = 5 * 60 * 1000; // 5 minutes
5
+ const NOTIFICATION_THROTTLE_MS = 30 * 60 * 1000; // 30 minutes
6
+ const RESTART_BROADCAST_DELAY_MS = 500;
7
+ const MIN_RESTART_INTERVAL_MS = 5 * 60 * 1000; // 5 minutes between restarts
8
+
9
+ /**
10
+ * Memory monitoring and restart trigger.
11
+ *
12
+ * This is NOT a supervisor — process lifecycle is managed by bin/supervisor.js.
13
+ * This module only:
14
+ * 1. Monitors memory and triggers GC when RSS exceeds a threshold
15
+ * 2. Notifies clients when memory is critically high
16
+ * 3. Initiates a restart by broadcasting to clients, then delegating to
17
+ * the server's existing handleShutdown() with exit code 75
18
+ */
19
+ class RestartManager {
20
+ constructor(server) {
21
+ this.server = server;
22
+ this.gcThresholdBytes = (parseInt(process.env.MEMORY_GC_THRESHOLD_MB, 10) || 1024) * 1024 * 1024;
23
+ this.warnThresholdBytes = (parseInt(process.env.MEMORY_WARN_THRESHOLD_MB, 10) || 2048) * 1024 * 1024;
24
+ this._lastWarningTime = 0;
25
+ this._lastRestartTime = 0;
26
+ this._monitorInterval = null;
27
+ }
28
+
29
+ startMemoryMonitoring() {
30
+ this._monitorInterval = setInterval(() => this._checkMemory(), MEMORY_CHECK_INTERVAL_MS);
31
+ // Don't block process exit
32
+ if (this._monitorInterval.unref) this._monitorInterval.unref();
33
+ }
34
+
35
+ stopMemoryMonitoring() {
36
+ if (this._monitorInterval) {
37
+ clearInterval(this._monitorInterval);
38
+ this._monitorInterval = null;
39
+ }
40
+ }
41
+
42
+ _checkMemory() {
43
+ const mem = process.memoryUsage();
44
+ const rssMB = (mem.rss / (1024 * 1024)).toFixed(1);
45
+ const heapMB = (mem.heapUsed / (1024 * 1024)).toFixed(1);
46
+
47
+ // Schedule GC via setImmediate so in-flight I/O (WebSocket frames, HTTP responses)
48
+ // drains first. global.gc() is synchronous and can stall the event loop 100-300ms.
49
+ if (mem.rss > this.gcThresholdBytes && typeof global.gc === 'function') {
50
+ console.log(`[memory] RSS ${rssMB} MB exceeds GC threshold, scheduling garbage collection...`);
51
+ setImmediate(() => this._runGc());
52
+ }
53
+
54
+ // Notify user when RSS exceeds warning threshold (throttled)
55
+ if (mem.rss > this.warnThresholdBytes) {
56
+ const now = Date.now();
57
+ if (now - this._lastWarningTime >= NOTIFICATION_THROTTLE_MS) {
58
+ this._lastWarningTime = now;
59
+ console.warn(`[memory] RSS ${rssMB} MB exceeds warning threshold (${(this.warnThresholdBytes / (1024 * 1024)).toFixed(0)} MB). Notifying clients.`);
60
+ this.server.broadcastToAll({
61
+ type: 'memory_warning',
62
+ rss: `${rssMB} MB`,
63
+ rssBytes: mem.rss,
64
+ heapUsed: `${heapMB} MB`,
65
+ heapUsedBytes: mem.heapUsed,
66
+ threshold: `${(this.warnThresholdBytes / (1024 * 1024)).toFixed(0)} MB`,
67
+ supervised: this.server.supervised
68
+ });
69
+ }
70
+ }
71
+ }
72
+
73
+ _runGc() {
74
+ const before = process.memoryUsage().rss;
75
+ // Try minor GC first (young generation, ~5ms)
76
+ try { global.gc({ type: 'minor' }); } catch (_) { /* ignore */ }
77
+
78
+ const afterMinor = process.memoryUsage().rss;
79
+ if (afterMinor > this.gcThresholdBytes) {
80
+ // Minor GC wasn't enough, do full GC (~100-300ms)
81
+ try { global.gc(); } catch (_) { /* ignore */ }
82
+ }
83
+
84
+ const after = process.memoryUsage().rss;
85
+ const reclaimedMB = ((before - after) / (1024 * 1024)).toFixed(1);
86
+ console.log(`[memory] GC complete. Reclaimed ${reclaimedMB} MB. RSS: ${(after / (1024 * 1024)).toFixed(1)} MB`);
87
+ }
88
+
89
+ async initiateRestart(reason = 'manual') {
90
+ // Guard: reuse the server's existing shutdown guard
91
+ if (this.server.isShuttingDown) {
92
+ console.log('[restart] Already shutting down, ignoring restart request');
93
+ return 'already_shutting_down';
94
+ }
95
+
96
+ // Rate limit: prevent restart-loop DoS
97
+ const now = Date.now();
98
+ if (now - this._lastRestartTime < MIN_RESTART_INTERVAL_MS) {
99
+ const waitSec = Math.ceil((MIN_RESTART_INTERVAL_MS - (now - this._lastRestartTime)) / 1000);
100
+ console.log(`[restart] Rate limited, ${waitSec}s remaining before next restart allowed`);
101
+ return 'rate_limited';
102
+ }
103
+ this._lastRestartTime = now;
104
+
105
+ console.log(`[restart] Initiating restart (reason: ${reason})`);
106
+
107
+ // Broadcast restart notification to all clients before shutdown begins.
108
+ // Wrapped in try/catch: a throw here (e.g., half-closed socket) must not
109
+ // abort the restart — the broadcast is best-effort, the shutdown is mandatory.
110
+ try {
111
+ this.server.broadcastToAll({
112
+ type: 'server_restarting',
113
+ reason
114
+ });
115
+ } catch (e) {
116
+ console.warn('[restart] Failed to broadcast restart notification:', e.message);
117
+ }
118
+
119
+ // Brief wait for WebSocket frames to be delivered
120
+ await new Promise(r => setTimeout(r, RESTART_BROADCAST_DELAY_MS));
121
+
122
+ // Delegate to the server's single shutdown path with restart exit code
123
+ await this.server.handleShutdown(RESTART_EXIT_CODE);
124
+ return 'restarting';
125
+ }
126
+ }
127
+
128
+ module.exports = RestartManager;
129
+ module.exports.RESTART_EXIT_CODE = RESTART_EXIT_CODE;
130
+ module.exports.MEMORY_CHECK_INTERVAL_MS = MEMORY_CHECK_INTERVAL_MS;
131
+ module.exports.NOTIFICATION_THROTTLE_MS = NOTIFICATION_THROTTLE_MS;
132
+ module.exports.MIN_RESTART_INTERVAL_MS = MIN_RESTART_INTERVAL_MS;
package/src/server.js CHANGED
@@ -21,6 +21,7 @@ const { VSCodeTunnelManager } = require('./vscode-tunnel');
21
21
  const InstallAdvisor = require('./install-advisor');
22
22
  const SttEngine = require('./stt-engine');
23
23
  const CircularBuffer = require('./utils/circular-buffer');
24
+ const RestartManager = require('./restart-manager');
24
25
 
25
26
  /** Foreground/background session priority constants */
26
27
  const COALESCE_MS_FG = 16; // 60 flushes/sec for active session
@@ -77,6 +78,9 @@ class ClaudeCodeWebServer {
77
78
  this.activityBroadcastTimestamps = new Map(); // sessionId -> last broadcast timestamp
78
79
  this.startTime = Date.now(); // Track server start time
79
80
  this.isShuttingDown = false; // Flag to prevent duplicate shutdown
81
+ this.supervised = typeof process.send === 'function'; // Running under supervisor with IPC
82
+ this.restartManager = new RestartManager(this);
83
+ this.restartManager.startMemoryMonitoring();
80
84
  // Commands dropdown removed
81
85
  // Assistant aliases (for UI display only)
82
86
  this.aliases = {
@@ -90,6 +94,7 @@ class ClaudeCodeWebServer {
90
94
  this.setupExpress();
91
95
  this._sessionsLoaded = this.loadPersistedSessions();
92
96
  this.setupAutoSave();
97
+ this.setupIpcListener();
93
98
  }
94
99
 
95
100
  async loadPersistedSessions() {
@@ -126,6 +131,7 @@ class ClaudeCodeWebServer {
126
131
  for (const [id, session] of this.claudeSessions) {
127
132
  if (!session.active && session.connections.size === 0 && new Date(session.lastActivity || session.created).getTime() < sevenDaysAgo) {
128
133
  this.claudeSessions.delete(id);
134
+ this.activityBroadcastTimestamps.delete(id);
129
135
  this.sessionStore.markDirty();
130
136
  }
131
137
  }
@@ -143,7 +149,7 @@ class ClaudeCodeWebServer {
143
149
  ? this.sessionStore.serializeForSave(this.claudeSessions)
144
150
  : JSON.stringify([...this.claudeSessions.entries()]);
145
151
  const fs = require('fs');
146
- fs.writeFileSync(this.sessionStore.sessionsFile + '.crash', data);
152
+ fs.writeFileSync(this.sessionStore.sessionsFile + '.crash', data, { mode: 0o600 });
147
153
  } catch (saveErr) {
148
154
  console.error('Failed to save sessions on crash:', saveErr);
149
155
  }
@@ -154,6 +160,22 @@ class ClaudeCodeWebServer {
154
160
  // Don't swallow — let it propagate to uncaughtException on Node 15+
155
161
  });
156
162
  }
163
+
164
+ setupIpcListener() {
165
+ if (!this.supervised) return;
166
+ // When running under the supervisor, listen for graceful shutdown via IPC
167
+ process.on('message', (msg) => {
168
+ if (msg && msg.type === 'shutdown') {
169
+ console.log('Received shutdown request via IPC');
170
+ this.handleShutdown();
171
+ }
172
+ });
173
+ // If the supervisor crashes, continue running standalone
174
+ process.on('disconnect', () => {
175
+ console.warn('IPC channel disconnected (supervisor may have crashed). Continuing standalone.');
176
+ this.supervised = false;
177
+ });
178
+ }
157
179
 
158
180
  setTunnelManager(tm) {
159
181
  this.tunnelManager = tm;
@@ -166,29 +188,24 @@ class ClaudeCodeWebServer {
166
188
  await this.sessionStore.saveSessions(this.claudeSessions);
167
189
  }
168
190
 
169
- async handleShutdown() {
191
+ async handleShutdown(exitCode = 0) {
170
192
  // Prevent multiple shutdown attempts
171
193
  if (this.isShuttingDown) {
172
194
  return;
173
195
  }
174
196
  this.isShuttingDown = true;
175
197
 
176
- console.log('\nGracefully shutting down...');
177
- await this.saveSessionsToDisk(true);
178
- if (this.autoSaveInterval) {
179
- clearInterval(this.autoSaveInterval);
180
- }
181
- if (this.sessionEvictionInterval) {
182
- clearInterval(this.sessionEvictionInterval);
183
- }
184
- // Stop all VS Code tunnels
185
- await this.vscodeTunnel.stopAll();
186
- // Clean up temp images for all sessions
187
- for (const [, session] of this.claudeSessions) {
188
- this.cleanupSessionImages(session);
189
- }
198
+ // Hard timeout: if close() hangs, force exit (protects unsupervised mode)
199
+ const forceExitTimer = setTimeout(() => {
200
+ console.error(`Shutdown timed out after 15s, forcing exit (code ${exitCode})`);
201
+ process.exit(exitCode);
202
+ }, 15000);
203
+ forceExitTimer.unref();
204
+
205
+ console.log(`\nGracefully shutting down (exit code: ${exitCode})...`);
190
206
  await this.close();
191
- process.exit(0);
207
+ clearTimeout(forceExitTimer);
208
+ process.exit(exitCode);
192
209
  }
193
210
 
194
211
  isPathWithinBase(targetPath) {
@@ -1482,6 +1499,23 @@ class ClaudeCodeWebServer {
1482
1499
  this.sendToWebSocket(wsInfo.ws, { type: 'pong' });
1483
1500
  break;
1484
1501
 
1502
+ case 'restart_server':
1503
+ if (!this.supervised) {
1504
+ this.sendToWebSocket(wsInfo.ws, {
1505
+ type: 'error',
1506
+ message: 'Server is not running in supervised mode. Restart manually.'
1507
+ });
1508
+ } else if (this.restartManager) {
1509
+ const result = await this.restartManager.initiateRestart('user_requested');
1510
+ if (result === 'rate_limited') {
1511
+ this.sendToWebSocket(wsInfo.ws, {
1512
+ type: 'error',
1513
+ message: 'Restart was requested too recently. Please wait a few minutes.'
1514
+ });
1515
+ }
1516
+ }
1517
+ break;
1518
+
1485
1519
  case 'get_usage':
1486
1520
  this.handleGetUsage(wsInfo);
1487
1521
  break;
@@ -1649,6 +1683,8 @@ class ClaudeCodeWebServer {
1649
1683
  sessionName: session.name,
1650
1684
  workingDir: session.workingDir,
1651
1685
  active: session.active,
1686
+ wasActive: session.wasActive || false,
1687
+ agent: session.agent || null,
1652
1688
  outputBuffer: session.outputBuffer.slice(-200) // Send last 200 lines
1653
1689
  });
1654
1690
 
@@ -1795,6 +1831,7 @@ class ClaudeCodeWebServer {
1795
1831
  currentSession.agent = null;
1796
1832
  this.sessionStore.markDirty();
1797
1833
  }
1834
+ this.activityBroadcastTimestamps.delete(sessionId);
1798
1835
  this.broadcastToSession(sessionId, { type: 'error', message: error.message });
1799
1836
  this.broadcastSessionActivity(sessionId, 'session_error');
1800
1837
  },
@@ -1871,6 +1908,14 @@ class ClaudeCodeWebServer {
1871
1908
  });
1872
1909
  }
1873
1910
 
1911
+ broadcastToAll(data) {
1912
+ for (const [, wsInfo] of this.webSocketConnections) {
1913
+ if (wsInfo.ws.readyState === WebSocket.OPEN) {
1914
+ this.sendToWebSocket(wsInfo.ws, data);
1915
+ }
1916
+ }
1917
+ }
1918
+
1874
1919
  // Sends a lightweight event to all WebSocket connections that are NOT joined
1875
1920
  // to the specified session. This enables clients to track activity in background
1876
1921
  // sessions for notification purposes without receiving full terminal output.
@@ -2064,7 +2109,7 @@ class ClaudeCodeWebServer {
2064
2109
  // Save sessions before closing
2065
2110
  await this.saveSessionsToDisk(true);
2066
2111
 
2067
- // Clear auto-save interval
2112
+ // Clear all intervals
2068
2113
  if (this.autoSaveInterval) {
2069
2114
  clearInterval(this.autoSaveInterval);
2070
2115
  }
@@ -2075,14 +2120,32 @@ class ClaudeCodeWebServer {
2075
2120
  clearInterval(this.sessionEvictionInterval);
2076
2121
  }
2077
2122
 
2123
+ // Stop memory monitoring to release the interval timer
2124
+ if (this.restartManager) {
2125
+ this.restartManager.stopMemoryMonitoring();
2126
+ }
2127
+
2128
+ // Stop all VS Code tunnels
2129
+ try { await this.vscodeTunnel.stopAll(); } catch (_) { /* ignore */ }
2130
+
2131
+ // Clean up temp images for all sessions
2132
+ for (const [, session] of this.claudeSessions) {
2133
+ this.cleanupSessionImages(session);
2134
+ }
2135
+
2078
2136
  if (this.wss) {
2137
+ // Terminate existing WebSocket clients so server.close() callback fires promptly
2138
+ // (wss.close() alone only stops new connections; open clients keep the HTTP server alive)
2139
+ for (const client of this.wss.clients) {
2140
+ try { client.terminate(); } catch (_) { /* ignore */ }
2141
+ }
2079
2142
  this.wss.close();
2080
2143
  }
2081
2144
  if (this.server) {
2082
2145
  this.server.close();
2083
2146
  }
2084
-
2085
- // Flush pending output and stop all sessions, awaiting clean shutdown
2147
+
2148
+ // Flush pending output and stop all sessions with a 5-second timeout
2086
2149
  const stopPromises = [];
2087
2150
  for (const [sessionId, session] of this.claudeSessions.entries()) {
2088
2151
  this._flushAndClearOutputTimer(session, sessionId);
@@ -2093,7 +2156,8 @@ class ClaudeCodeWebServer {
2093
2156
  }
2094
2157
  }
2095
2158
  }
2096
- await Promise.allSettled(stopPromises);
2159
+ const timeout = new Promise(resolve => setTimeout(resolve, 5000));
2160
+ await Promise.race([Promise.allSettled(stopPromises), timeout]);
2097
2161
 
2098
2162
  // Clear all data
2099
2163
  this.claudeSessions.clear();
@@ -3,6 +3,8 @@ const path = require('path');
3
3
  const os = require('os');
4
4
  const CircularBuffer = require('./circular-buffer');
5
5
 
6
+ const MAX_BUFFER_BYTES_PER_SESSION = 512 * 1024; // 512KB per-session byte cap
7
+
6
8
  class SessionStore {
7
9
  constructor() {
8
10
  // Store sessions in user's home directory
@@ -16,6 +18,23 @@ class SessionStore {
16
18
  this._dirty = true;
17
19
  }
18
20
 
21
+ /**
22
+ * Trim an array of output lines to fit within MAX_BUFFER_BYTES_PER_SESSION.
23
+ * Keeps the most recent lines (end of array) and drops the oldest.
24
+ */
25
+ _capBufferByBytes(lines) {
26
+ let totalBytes = 0;
27
+ // Walk backwards from the end (newest lines) summing byte lengths
28
+ let startIndex = lines.length;
29
+ for (let i = lines.length - 1; i >= 0; i--) {
30
+ const lineBytes = Buffer.byteLength(lines[i] || '', 'utf8');
31
+ if (totalBytes + lineBytes > MAX_BUFFER_BYTES_PER_SESSION) break;
32
+ totalBytes += lineBytes;
33
+ startIndex = i;
34
+ }
35
+ return startIndex === 0 ? lines : lines.slice(startIndex);
36
+ }
37
+
19
38
  async initializeStorage() {
20
39
  try {
21
40
  // Create storage directory if it doesn't exist
@@ -40,8 +59,10 @@ class SessionStore {
40
59
  lastActivity: session.lastActivity || new Date(),
41
60
  workingDir: session.workingDir || process.cwd(),
42
61
  active: false, // Always set to false when saving (processes won't persist)
62
+ wasActive: session.active || false, // Preserve active state for restart awareness
63
+ agent: session.agent || null, // Which tool was running (claude, codex, etc.)
43
64
  outputBuffer: (session.outputBuffer && typeof session.outputBuffer.slice === 'function')
44
- ? session.outputBuffer.slice(-100) : [], // Keep last 100 lines
65
+ ? this._capBufferByBytes(session.outputBuffer.slice(-1000)) : [], // Keep last 1000 lines, capped at 512KB
45
66
  connections: [], // Clear connections (they won't persist)
46
67
  lastAccessed: session.lastAccessed || Date.now(),
47
68
  // Session-specific usage tracking
@@ -64,8 +85,14 @@ class SessionStore {
64
85
  };
65
86
 
66
87
  // Write to a temporary file first, then rename (atomic operation)
88
+ // Use restrictive permissions (owner-only) since output may contain secrets.
89
+ // Note: mode 0o600 is silently ignored on Windows (which uses ACLs instead
90
+ // of Unix permissions). The file inherits the user's default ACL, which is
91
+ // acceptable but not explicitly enforced.
67
92
  const tempFile = `${this.sessionsFile}.tmp`;
68
- await fs.writeFile(tempFile, JSON.stringify(data));
93
+ // JSON.stringify is CPU-bound; yield to pending I/O before serializing
94
+ const jsonStr = await new Promise(resolve => setImmediate(() => resolve(JSON.stringify(data))));
95
+ await fs.writeFile(tempFile, jsonStr, { mode: 0o600 });
69
96
  // Ensure directory still exists before rename (handles race conditions)
70
97
  await fs.mkdir(this.storageDir, { recursive: true });
71
98
  await fs.rename(tempFile, this.sessionsFile);
@@ -137,8 +164,8 @@ class SessionStore {
137
164
  connections: new Set(),
138
165
  outputBuffer: CircularBuffer.fromArray(session.outputBuffer || [], 1000),
139
166
  maxBufferSize: 1000,
140
- // Restore usage data if available
141
- usageData: session.usageData || null
167
+ // Restore usage data if available (saved under sessionUsage key)
168
+ sessionUsage: session.sessionUsage || null
142
169
  });
143
170
  }
144
171