ai-or-die 0.1.45 → 0.1.47

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.
@@ -507,6 +507,42 @@ jobs:
507
507
  playwright-report/
508
508
  retention-days: 14
509
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
+
510
546
  build-binary:
511
547
  runs-on: ${{ matrix.os }}
512
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();
@@ -3,7 +3,10 @@ REM Mock devtunnel CLI for E2E testing.
3
3
  REM Simulates the subcommands used by VSCodeTunnelManager's two-process model.
4
4
 
5
5
  REM --- user show: auth check (exit 0 = authenticated) ---
6
- if "%1"=="user" if "%2"=="show" exit /b 0
6
+ if "%1"=="user" if "%2"=="show" (
7
+ echo Logged in as mock-user using GitHub.
8
+ exit /b 0
9
+ )
7
10
 
8
11
  REM --- user login: authenticate (exit 0 = success) ---
9
12
  if "%1"=="user" if "%2"=="login" exit /b 0
@@ -4,6 +4,7 @@
4
4
 
5
5
  # --- user show: auth check (exit 0 = authenticated) ---
6
6
  if [ "$1" = "user" ] && [ "$2" = "show" ]; then
7
+ echo "Logged in as mock-user using GitHub."
7
8
  exit 0
8
9
  fi
9
10
 
@@ -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.45",
3
+ "version": "0.1.47",
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
@@ -1151,6 +1151,64 @@ class ClaudeCodeWebInterface {
1151
1151
  }
1152
1152
  }
1153
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
+
1154
1212
  _handleVoiceMessage(message) {
1155
1213
  var btn = document.getElementById('voiceInputBtn');
1156
1214
  switch (message.type) {
@@ -1409,6 +1467,15 @@ class ClaudeCodeWebInterface {
1409
1467
 
1410
1468
  this.socket.onopen = () => {
1411
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
+ }
1412
1479
  this.updateStatus('Connected');
1413
1480
  console.log('Connected to server');
1414
1481
 
@@ -1443,7 +1510,14 @@ class ClaudeCodeWebInterface {
1443
1510
  };
1444
1511
 
1445
1512
  this.socket.onclose = (event) => {
1446
- 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) {
1447
1521
  this.updateStatus('Reconnecting...');
1448
1522
  setTimeout(() => this.reconnect(), Math.min(this.reconnectDelay * Math.pow(2, this.reconnectAttempts), 30000));
1449
1523
  this.reconnectAttempts++;
@@ -1702,10 +1776,13 @@ class ClaudeCodeWebInterface {
1702
1776
  if (isNewSession) {
1703
1777
  console.log('[session_joined] New session detected, showing start prompt');
1704
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');
1705
1784
  } else {
1706
- console.log('[session_joined] Existing session with stopped Claude, showing restart prompt');
1707
- // For existing sessions where Claude has stopped, show start prompt
1708
- // This allows the user to restart Claude in the same session
1785
+ console.log('[session_joined] Existing session with stopped tool, showing restart prompt');
1709
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`);
1710
1787
  this.showOverlay('startPrompt');
1711
1788
  }
@@ -1971,6 +2048,28 @@ class ClaudeCodeWebInterface {
1971
2048
  this._handleVoiceMessage(message);
1972
2049
  break;
1973
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
+
1974
2073
  default:
1975
2074
  console.log('Unknown message type:', message.type);
1976
2075
  }
@@ -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() {
@@ -144,7 +149,7 @@ class ClaudeCodeWebServer {
144
149
  ? this.sessionStore.serializeForSave(this.claudeSessions)
145
150
  : JSON.stringify([...this.claudeSessions.entries()]);
146
151
  const fs = require('fs');
147
- fs.writeFileSync(this.sessionStore.sessionsFile + '.crash', data);
152
+ fs.writeFileSync(this.sessionStore.sessionsFile + '.crash', data, { mode: 0o600 });
148
153
  } catch (saveErr) {
149
154
  console.error('Failed to save sessions on crash:', saveErr);
150
155
  }
@@ -155,6 +160,22 @@ class ClaudeCodeWebServer {
155
160
  // Don't swallow — let it propagate to uncaughtException on Node 15+
156
161
  });
157
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
+ }
158
179
 
159
180
  setTunnelManager(tm) {
160
181
  this.tunnelManager = tm;
@@ -167,29 +188,24 @@ class ClaudeCodeWebServer {
167
188
  await this.sessionStore.saveSessions(this.claudeSessions);
168
189
  }
169
190
 
170
- async handleShutdown() {
191
+ async handleShutdown(exitCode = 0) {
171
192
  // Prevent multiple shutdown attempts
172
193
  if (this.isShuttingDown) {
173
194
  return;
174
195
  }
175
196
  this.isShuttingDown = true;
176
197
 
177
- console.log('\nGracefully shutting down...');
178
- await this.saveSessionsToDisk(true);
179
- if (this.autoSaveInterval) {
180
- clearInterval(this.autoSaveInterval);
181
- }
182
- if (this.sessionEvictionInterval) {
183
- clearInterval(this.sessionEvictionInterval);
184
- }
185
- // Stop all VS Code tunnels
186
- await this.vscodeTunnel.stopAll();
187
- // Clean up temp images for all sessions
188
- for (const [, session] of this.claudeSessions) {
189
- this.cleanupSessionImages(session);
190
- }
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})...`);
191
206
  await this.close();
192
- process.exit(0);
207
+ clearTimeout(forceExitTimer);
208
+ process.exit(exitCode);
193
209
  }
194
210
 
195
211
  isPathWithinBase(targetPath) {
@@ -1483,6 +1499,23 @@ class ClaudeCodeWebServer {
1483
1499
  this.sendToWebSocket(wsInfo.ws, { type: 'pong' });
1484
1500
  break;
1485
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
+
1486
1519
  case 'get_usage':
1487
1520
  this.handleGetUsage(wsInfo);
1488
1521
  break;
@@ -1650,6 +1683,8 @@ class ClaudeCodeWebServer {
1650
1683
  sessionName: session.name,
1651
1684
  workingDir: session.workingDir,
1652
1685
  active: session.active,
1686
+ wasActive: session.wasActive || false,
1687
+ agent: session.agent || null,
1653
1688
  outputBuffer: session.outputBuffer.slice(-200) // Send last 200 lines
1654
1689
  });
1655
1690
 
@@ -1873,6 +1908,14 @@ class ClaudeCodeWebServer {
1873
1908
  });
1874
1909
  }
1875
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
+
1876
1919
  // Sends a lightweight event to all WebSocket connections that are NOT joined
1877
1920
  // to the specified session. This enables clients to track activity in background
1878
1921
  // sessions for notification purposes without receiving full terminal output.
@@ -2066,7 +2109,7 @@ class ClaudeCodeWebServer {
2066
2109
  // Save sessions before closing
2067
2110
  await this.saveSessionsToDisk(true);
2068
2111
 
2069
- // Clear auto-save interval
2112
+ // Clear all intervals
2070
2113
  if (this.autoSaveInterval) {
2071
2114
  clearInterval(this.autoSaveInterval);
2072
2115
  }
@@ -2077,14 +2120,32 @@ class ClaudeCodeWebServer {
2077
2120
  clearInterval(this.sessionEvictionInterval);
2078
2121
  }
2079
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
+
2080
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
+ }
2081
2142
  this.wss.close();
2082
2143
  }
2083
2144
  if (this.server) {
2084
2145
  this.server.close();
2085
2146
  }
2086
-
2087
- // Flush pending output and stop all sessions, awaiting clean shutdown
2147
+
2148
+ // Flush pending output and stop all sessions with a 5-second timeout
2088
2149
  const stopPromises = [];
2089
2150
  for (const [sessionId, session] of this.claudeSessions.entries()) {
2090
2151
  this._flushAndClearOutputTimer(session, sessionId);
@@ -2095,7 +2156,8 @@ class ClaudeCodeWebServer {
2095
2156
  }
2096
2157
  }
2097
2158
  }
2098
- await Promise.allSettled(stopPromises);
2159
+ const timeout = new Promise(resolve => setTimeout(resolve, 5000));
2160
+ await Promise.race([Promise.allSettled(stopPromises), timeout]);
2099
2161
 
2100
2162
  // Clear all data
2101
2163
  this.claudeSessions.clear();
@@ -21,7 +21,9 @@ class TunnelManager {
21
21
  this.stopping = false;
22
22
  this._restarting = false;
23
23
  this.retryCount = 0;
24
- this.tunnelId = `aiordie-${os.hostname().toLowerCase().replace(/[^a-z0-9-]/g, '')}`;
24
+ this._hostnameSlug = os.hostname().toLowerCase().replace(/[^a-z0-9-]/g, '');
25
+ this.tunnelId = `aiordie-${this._hostnameSlug}`;
26
+ this._authProvider = null;
25
27
 
26
28
  // Resilience tracking
27
29
  this._lastSpawnTime = null;
@@ -183,15 +185,20 @@ class TunnelManager {
183
185
 
184
186
  /**
185
187
  * Check if user is logged in. If not, attempt interactive login.
188
+ * Detects auth provider (GitHub vs Entra) and adjusts tunnel ID to avoid
189
+ * cross-provider name collisions.
186
190
  */
187
191
  async _checkLogin() {
188
- const isLoggedIn = await new Promise((resolve) => {
189
- execFile('devtunnel', ['user', 'show'], { timeout: 10000 }, (err) => {
190
- resolve(!err);
192
+ const result = await new Promise((resolve) => {
193
+ execFile('devtunnel', ['user', 'show'], { timeout: 10000 }, (err, stdout) => {
194
+ resolve({ ok: !err, stdout: (stdout || '').toString() });
191
195
  });
192
196
  });
193
197
 
194
- if (isLoggedIn) return true;
198
+ if (result.ok) {
199
+ this._applyAuthSuffix(result.stdout);
200
+ return true;
201
+ }
195
202
 
196
203
  console.log(' DevTunnel requires authentication. Launching login...\n');
197
204
 
@@ -206,6 +213,13 @@ class TunnelManager {
206
213
 
207
214
  if (loginOk) {
208
215
  console.log('\n Login successful. Connecting tunnel...');
216
+ // Re-check to detect which provider was used
217
+ const recheck = await new Promise((resolve) => {
218
+ execFile('devtunnel', ['user', 'show'], { timeout: 10000 }, (err, stdout) => {
219
+ resolve((stdout || '').toString());
220
+ });
221
+ });
222
+ this._applyAuthSuffix(recheck);
209
223
  return true;
210
224
  }
211
225
 
@@ -213,6 +227,19 @@ class TunnelManager {
213
227
  return false;
214
228
  }
215
229
 
230
+ /**
231
+ * Parse auth provider from `devtunnel user show` output and append
232
+ * a suffix to the tunnel ID when using GitHub auth. This prevents
233
+ * name collisions with tunnels created under a different provider.
234
+ */
235
+ _applyAuthSuffix(userShowOutput) {
236
+ const match = userShowOutput.match(/using\s+(GitHub)/i);
237
+ if (match) {
238
+ this._authProvider = 'github';
239
+ this.tunnelId = `aiordie-${this._hostnameSlug}-gh`;
240
+ }
241
+ }
242
+
216
243
  /**
217
244
  * Create the named tunnel and configure its port.
218
245
  * Steps: devtunnel create <id> → devtunnel port create <id> -p <port>
@@ -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
 
@@ -45,6 +45,7 @@ class VSCodeTunnelManager {
45
45
 
46
46
  this._healthInterval = null;
47
47
  this._reservedPorts = new Set();
48
+ this._authProvider = null;
48
49
 
49
50
  // Kick off async command discovery at construction time
50
51
  this._initPromise = Promise.all([
@@ -123,7 +124,7 @@ class VSCodeTunnelManager {
123
124
  connectionToken,
124
125
  localUrl: null,
125
126
  publicUrl: null,
126
- tunnelId: `aiordie-vscode-${sessionId.slice(0, 12).replace(/[^a-z0-9-]/gi, '')}`,
127
+ tunnelId: `aiordie-vscode-${sessionId.slice(0, 12).replace(/[^a-z0-9-]/gi, '')}${this._authProvider === 'github' ? '-gh' : ''}`,
127
128
  status: 'starting',
128
129
  sessionId,
129
130
  workingDir: workingDir || process.cwd(),
@@ -161,6 +162,13 @@ class VSCodeTunnelManager {
161
162
  return { success: false, error: 'Authentication failed or was cancelled' };
162
163
  }
163
164
  console.warn(`[VSCODE-TUNNEL] Session ${sessionId}: devtunnel login successful`);
165
+ // Re-check to detect auth provider after fresh login
166
+ await this._checkDevtunnelAuth();
167
+ }
168
+
169
+ // Update tunnel ID with auth-provider suffix after detection
170
+ if (this._authProvider === 'github') {
171
+ tunnel.tunnelId = `aiordie-vscode-${sessionId.slice(0, 12).replace(/[^a-z0-9-]/gi, '')}-gh`;
164
172
  }
165
173
 
166
174
  // Start health check interval (once)
@@ -241,7 +249,7 @@ class VSCodeTunnelManager {
241
249
 
242
250
  // Step 2: Clean up devtunnel (fire-and-forget)
243
251
  if (this._devtunnelCommand) {
244
- execFile(this._devtunnelCommand, ['delete', tunnel.tunnelId, '-y'], { timeout: 10000 }, () => {});
252
+ execFile(this._devtunnelCommand, ['delete', tunnel.tunnelId, '-f'], { timeout: 10000 }, () => {});
245
253
  }
246
254
 
247
255
  // Step 3: Kill server process
@@ -457,12 +465,22 @@ class VSCodeTunnelManager {
457
465
 
458
466
  /**
459
467
  * Check if user is authenticated with devtunnel (OS-level credential store).
468
+ * Also detects auth provider (GitHub vs Entra) for tunnel name suffixing.
460
469
  */
461
470
  async _checkDevtunnelAuth() {
462
471
  if (!this._devtunnelCommand) return false;
463
472
  return new Promise((resolve) => {
464
- execFile(this._devtunnelCommand, ['user', 'show'], { timeout: 10000 }, (err) => {
465
- resolve(!err);
473
+ execFile(this._devtunnelCommand, ['user', 'show'], { timeout: 10000 }, (err, stdout) => {
474
+ if (err) {
475
+ resolve(false);
476
+ } else {
477
+ const output = (stdout || '').toString();
478
+ const match = output.match(/using\s+(GitHub)/i);
479
+ if (match) {
480
+ this._authProvider = 'github';
481
+ }
482
+ resolve(true);
483
+ }
466
484
  });
467
485
  });
468
486
  }