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.
- package/.github/workflows/ci.yml +72 -0
- package/bin/supervisor.js +121 -0
- package/e2e/playwright.config.js +6 -0
- package/package.json +6 -3
- package/src/public/app.js +124 -9
- package/src/public/components/extra-keys.css +2 -2
- package/src/public/components/tabs.css +2 -0
- package/src/public/mobile.css +3 -2
- package/src/public/session-manager.js +3 -3
- package/src/restart-manager.js +132 -0
- package/src/server.js +85 -21
- package/src/utils/session-store.js +31 -4
package/.github/workflows/ci.yml
CHANGED
|
@@ -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();
|
package/e2e/playwright.config.js
CHANGED
|
@@ -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.
|
|
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/
|
|
12
|
-
"
|
|
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 ||
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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 <=
|
|
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 <=
|
|
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 <=
|
|
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';
|
package/src/public/mobile.css
CHANGED
|
@@ -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
|
-
|
|
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 <=
|
|
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 <=
|
|
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 <=
|
|
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
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
}
|
|
181
|
-
|
|
182
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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(-
|
|
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
|
-
|
|
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
|
-
|
|
167
|
+
// Restore usage data if available (saved under sessionUsage key)
|
|
168
|
+
sessionUsage: session.sessionUsage || null
|
|
142
169
|
});
|
|
143
170
|
}
|
|
144
171
|
|