myrlin-workbook 0.9.20 → 0.9.22
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/logs/server.pid +1 -1
- package/package.json +1 -1
- package/src/gui.js +2 -48
- package/src/supervisor.js +3 -3
- package/src/web/pty-manager.js +0 -12
- package/src/web/public/app.js +38 -3
package/logs/server.pid
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
|
|
1
|
+
67796
|
package/package.json
CHANGED
package/src/gui.js
CHANGED
|
@@ -189,15 +189,8 @@ if (!process.env.CWM_NO_OPEN) {
|
|
|
189
189
|
}
|
|
190
190
|
}
|
|
191
191
|
|
|
192
|
-
// ───
|
|
193
|
-
//
|
|
194
|
-
// ConPTY on Windows allocates significant native memory (outside V8 heap)
|
|
195
|
-
// so RSS can spike well beyond --max-old-space-size.
|
|
196
|
-
const MEMORY_CHECK_INTERVAL = 15000; // Check every 15 seconds
|
|
197
|
-
const MEMORY_WARN_MB = 200; // Start killing idle (zero-client) PTYs
|
|
198
|
-
const MEMORY_CRITICAL_MB = 350; // Aggressively kill PTYs to survive
|
|
199
|
-
|
|
200
|
-
// Periodic RSS logging so we can trace memory trajectory in server.log
|
|
192
|
+
// ─── RSS Logger ───────────────────────────────────────────
|
|
193
|
+
// Periodic RSS logging so we can trace memory in server.log
|
|
201
194
|
const _rssLogger = setInterval(() => {
|
|
202
195
|
const mem = process.memoryUsage();
|
|
203
196
|
const ptyManager = getPtyManager();
|
|
@@ -208,45 +201,6 @@ const _rssLogger = setInterval(() => {
|
|
|
208
201
|
}, 60000);
|
|
209
202
|
_rssLogger.unref();
|
|
210
203
|
|
|
211
|
-
const _memoryWatchdog = setInterval(() => {
|
|
212
|
-
const rssMB = Math.round(process.memoryUsage().rss / 1024 / 1024);
|
|
213
|
-
if (rssMB < MEMORY_WARN_MB) return;
|
|
214
|
-
|
|
215
|
-
const { logWarning } = require('./crash-logger');
|
|
216
|
-
const ptyManager = getPtyManager();
|
|
217
|
-
if (!ptyManager) return;
|
|
218
|
-
|
|
219
|
-
const sessions = ptyManager.listSessions();
|
|
220
|
-
const isCritical = rssMB >= MEMORY_CRITICAL_MB;
|
|
221
|
-
const label = isCritical ? 'CRITICAL' : 'WARNING';
|
|
222
|
-
try { console.log(`[Memory ${label}] RSS=${rssMB}MB, ${sessions.length} PTY sessions`); } catch (_) {}
|
|
223
|
-
logWarning('server', `Memory ${label}: RSS=${rssMB}MB, ${sessions.length} PTY sessions`);
|
|
224
|
-
|
|
225
|
-
// Kill PTY sessions with zero connected WebSocket clients first
|
|
226
|
-
let killed = 0;
|
|
227
|
-
for (const s of sessions) {
|
|
228
|
-
if (s.clientCount === 0) {
|
|
229
|
-
ptyManager.killSession(s.sessionId);
|
|
230
|
-
killed++;
|
|
231
|
-
}
|
|
232
|
-
}
|
|
233
|
-
|
|
234
|
-
// In critical mode, also kill sessions that have been alive longest
|
|
235
|
-
if (isCritical && killed === 0 && sessions.length > 1) {
|
|
236
|
-
const oldest = [...sessions].sort((a, b) => (a.createdAt || 0) - (b.createdAt || 0));
|
|
237
|
-
ptyManager.killSession(oldest[0].sessionId);
|
|
238
|
-
killed++;
|
|
239
|
-
}
|
|
240
|
-
|
|
241
|
-
if (killed > 0) {
|
|
242
|
-
try { console.log(`[Memory] Killed ${killed} PTY session(s) to free memory`); } catch (_) {}
|
|
243
|
-
}
|
|
244
|
-
|
|
245
|
-
// Force GC if available (node --expose-gc)
|
|
246
|
-
if (global.gc) global.gc();
|
|
247
|
-
}, MEMORY_CHECK_INTERVAL);
|
|
248
|
-
_memoryWatchdog.unref();
|
|
249
|
-
|
|
250
204
|
// ─── Graceful Shutdown ─────────────────────────────────────
|
|
251
205
|
|
|
252
206
|
process.on('SIGINT', () => {
|
package/src/supervisor.js
CHANGED
|
@@ -46,7 +46,7 @@ if (process.argv.includes('--daemon')) {
|
|
|
46
46
|
// Windows kills the entire job group. Use cmd.exe /c start to create a
|
|
47
47
|
// process in a completely new console session, then redirect its output.
|
|
48
48
|
const { execSync } = require('child_process');
|
|
49
|
-
const cmd = `cmd.exe /c start /b "" "${nodeExe}" --max-old-space-size=
|
|
49
|
+
const cmd = `cmd.exe /c start /b "" "${nodeExe}" --max-old-space-size=4096 ${scriptArgs} >> "${logFile}" 2>&1`;
|
|
50
50
|
execSync(cmd, { stdio: 'ignore', windowsHide: true });
|
|
51
51
|
|
|
52
52
|
// The PID isn't directly available from start /b. Write a marker so we
|
|
@@ -73,7 +73,7 @@ if (process.argv.includes('--daemon')) {
|
|
|
73
73
|
// Unix: standard detach with file descriptors
|
|
74
74
|
const out = fs.openSync(logFile, 'a');
|
|
75
75
|
const err = fs.openSync(logFile, 'a');
|
|
76
|
-
const child = spawn(nodeExe, ['--max-old-space-size=
|
|
76
|
+
const child = spawn(nodeExe, ['--max-old-space-size=4096', __filename, ...childArgs], {
|
|
77
77
|
stdio: ['ignore', out, err],
|
|
78
78
|
detached: true,
|
|
79
79
|
env: { ...process.env },
|
|
@@ -115,7 +115,7 @@ function startChild() {
|
|
|
115
115
|
lastStartTime = Date.now();
|
|
116
116
|
console.log(`[supervisor] Starting GUI server (attempt ${consecutiveRestarts + 1})...`);
|
|
117
117
|
|
|
118
|
-
child = spawn(process.execPath, ['--max-old-space-size=
|
|
118
|
+
child = spawn(process.execPath, ['--max-old-space-size=4096', guiScript, ...guiArgs], {
|
|
119
119
|
stdio: 'inherit',
|
|
120
120
|
env: { ...process.env, CWM_NO_OPEN: consecutiveRestarts > 0 ? '1' : '' },
|
|
121
121
|
});
|
package/src/web/pty-manager.js
CHANGED
|
@@ -108,11 +108,6 @@ class PtySession {
|
|
|
108
108
|
}
|
|
109
109
|
}
|
|
110
110
|
|
|
111
|
-
// Maximum number of live PTY sessions. Each one is a ConPTY handle + a Claude
|
|
112
|
-
// process (100-200MB each). Beyond this limit, new spawns are rejected to
|
|
113
|
-
// prevent the OS from OOM-killing the server process tree.
|
|
114
|
-
const MAX_PTY_SESSIONS = 5;
|
|
115
|
-
|
|
116
111
|
class PtySessionManager {
|
|
117
112
|
constructor() {
|
|
118
113
|
this.sessions = new Map(); // sessionId -> PtySession
|
|
@@ -137,13 +132,6 @@ class PtySessionManager {
|
|
|
137
132
|
return existing;
|
|
138
133
|
}
|
|
139
134
|
|
|
140
|
-
// Enforce max live sessions to prevent OOM from too many ConPTY handles
|
|
141
|
-
const aliveCount = [...this.sessions.values()].filter(s => s.alive).length;
|
|
142
|
-
if (aliveCount >= MAX_PTY_SESSIONS) {
|
|
143
|
-
console.log(`[PTY] Rejected spawn for ${sessionId}: ${aliveCount} live sessions (max ${MAX_PTY_SESSIONS})`);
|
|
144
|
-
return null;
|
|
145
|
-
}
|
|
146
|
-
|
|
147
135
|
// ── Defense-in-depth: validate all user-controlled inputs ──
|
|
148
136
|
// Primary validation happens at the API/WebSocket boundary (server.js, pty-server.js).
|
|
149
137
|
// This is a secondary gate to catch any bypass or future code path that skips validation.
|
package/src/web/public/app.js
CHANGED
|
@@ -9058,6 +9058,36 @@ class CWMApp {
|
|
|
9058
9058
|
TERMINAL GRID VIEW
|
|
9059
9059
|
═══════════════════════════════════════════════════════════ */
|
|
9060
9060
|
|
|
9061
|
+
/**
|
|
9062
|
+
* Show a disconnected placeholder in a terminal pane slot.
|
|
9063
|
+
* Preserves session info in terminalPanes[] for layout saves.
|
|
9064
|
+
* Click reconnects via openTerminalInPane().
|
|
9065
|
+
*/
|
|
9066
|
+
_showDisconnectedPlaceholder(slotIdx, sessionId, sessionName, spawnOpts) {
|
|
9067
|
+
// Store placeholder so saveCurrentGroupPanes preserves the mapping
|
|
9068
|
+
this.terminalPanes[slotIdx] = { sessionId, sessionName, spawnOpts: spawnOpts || {}, _disconnected: true };
|
|
9069
|
+
const paneEl = document.getElementById(`term-pane-${slotIdx}`);
|
|
9070
|
+
if (!paneEl) return;
|
|
9071
|
+
paneEl.hidden = false;
|
|
9072
|
+
paneEl.classList.remove('terminal-pane-empty');
|
|
9073
|
+
const titleEl = paneEl.querySelector('.terminal-pane-title');
|
|
9074
|
+
if (titleEl) titleEl.textContent = sessionName || sessionId;
|
|
9075
|
+
const closeBtn = paneEl.querySelector('.terminal-pane-close');
|
|
9076
|
+
if (closeBtn) closeBtn.hidden = false;
|
|
9077
|
+
const container = document.getElementById(`term-container-${slotIdx}`);
|
|
9078
|
+
if (container) {
|
|
9079
|
+
container.innerHTML = `<div style="display:flex;flex-direction:column;align-items:center;justify-content:center;height:100%;gap:8px;color:var(--overlay0);font-size:13px;cursor:pointer;" class="reconnect-prompt">
|
|
9080
|
+
<span style="font-size:20px;">🔌</span>
|
|
9081
|
+
<span>${this.escapeHtml(sessionName || sessionId)}</span>
|
|
9082
|
+
<span style="font-size:11px;opacity:0.7;">Click to connect</span>
|
|
9083
|
+
</div>`;
|
|
9084
|
+
container.querySelector('.reconnect-prompt').addEventListener('click', () => {
|
|
9085
|
+
this.openTerminalInPane(slotIdx, sessionId, sessionName, spawnOpts);
|
|
9086
|
+
});
|
|
9087
|
+
}
|
|
9088
|
+
this.updateTerminalGridLayout();
|
|
9089
|
+
}
|
|
9090
|
+
|
|
9061
9091
|
openTerminalInPane(slotIdx, sessionId, sessionName, spawnOpts) {
|
|
9062
9092
|
// Check localStorage for a previously saved name for this session
|
|
9063
9093
|
const savedTitle = this.getProjectSessionTitle(sessionId);
|
|
@@ -12048,10 +12078,12 @@ class CWMApp {
|
|
|
12048
12078
|
const uploadBtn = paneEl.querySelector('.terminal-pane-upload');
|
|
12049
12079
|
if (uploadBtn) uploadBtn.hidden = false;
|
|
12050
12080
|
}
|
|
12051
|
-
// Reattach xterm DOM
|
|
12081
|
+
// Reattach xterm DOM, or re-render placeholder for disconnected panes
|
|
12052
12082
|
if (cached.domFragments[i]) {
|
|
12053
12083
|
const termContainer = document.getElementById(`term-container-${i}`);
|
|
12054
12084
|
if (termContainer) termContainer.appendChild(cached.domFragments[i]);
|
|
12085
|
+
} else if (cached.panes[i]._disconnected) {
|
|
12086
|
+
this._showDisconnectedPlaceholder(i, cached.panes[i].sessionId, cached.panes[i].sessionName, cached.panes[i].spawnOpts);
|
|
12055
12087
|
}
|
|
12056
12088
|
}
|
|
12057
12089
|
}
|
|
@@ -12071,12 +12103,15 @@ class CWMApp {
|
|
|
12071
12103
|
}
|
|
12072
12104
|
});
|
|
12073
12105
|
} else {
|
|
12074
|
-
// No cache
|
|
12106
|
+
// No cache: show disconnected placeholders instead of spawning all PTYs.
|
|
12107
|
+
// Each PTY spawns a Claude process (~150MB), so eagerly connecting all
|
|
12108
|
+
// panes across all tab groups would consume gigabytes of system memory.
|
|
12109
|
+
// Users click a pane to connect on demand.
|
|
12075
12110
|
const group = this._tabGroups.find(g => g.id === groupId);
|
|
12076
12111
|
if (group && group.panes) {
|
|
12077
12112
|
group.panes.forEach(p => {
|
|
12078
12113
|
if (p.sessionId && !this.terminalPanes[p.slot]) {
|
|
12079
|
-
this.
|
|
12114
|
+
this._showDisconnectedPlaceholder(p.slot, p.sessionId, p.sessionName || 'Terminal', p.spawnOpts || {});
|
|
12080
12115
|
}
|
|
12081
12116
|
});
|
|
12082
12117
|
}
|