Files
Bohdan Triapitsyn ff75dc9bd5 fix(terminal): unset NODE_CHANNEL_FD for PTY shells on every POSIX host
The PTY runtime exported an empty NODE_CHANNEL_FD to override the daemon's
IPC descriptor, because bun-pty merges the native environ back into the
child and a JS-only delete does not stick. Node CLIs launched from the
shell (opencode, claude) then printed "warn: Failed to parse IPC channel
number ''" on exit.

The Linux-only env -u ARGV0 wrapper now applies on macOS and Linux and
also unsets NODE_CHANNEL_FD, so the variable is gone instead of empty.

Testing: runtime and inherited-env tests updated for the POSIX wrapper;
verified in the running app that exiting opencode no longer prints the
warning.
2026-09-07 12:18:22 +03:00

579 lines
32 KiB
JavaScript

import { randomUUID } from 'node:crypto';
import { WebSocketServer } from 'ws';
import {
TERMINAL_WS_MAX_PAYLOAD_BYTES,
TERMINAL_WS_PATH,
createTerminalWsControlFrame,
parseRequestPathname,
readTerminalWsControlFrame,
} from './terminal-ws-protocol.js';
import { sanitizeTerminalHistoryChunk } from './history.js';
import { consumeTerminalThemeQueries, terminalThemeModeReport } from './theme-response.js';
import { buildTerminalShellLaunch, createTerminalShellResolver, normalizeTerminalShell } from './shells.js';
import { stripAppImageArgv0Leak, resolvePosixPtyLaunch } from '../inherited-env.js';
const MAX_SESSIONS = 20;
const MAX_HISTORY_BYTES = 512 * 1024;
const MAX_INPUT_CHARS = 65_536;
const IDLE_TIMEOUT_MS = 30 * 60 * 1000;
const TERMINATION_GRACE_MS = 1000;
const INTERACTIVE_TERMINAL_MODE = 'interactive';
const COMMAND_TERMINAL_MODE = 'command';
// Error code the create/restart routes attach when the requested cwd no longer
// exists. Mirrored by `TERMINAL_CWD_MISSING_CODE` in packages/ui/src/lib/terminalApi.ts.
const TERMINAL_CWD_MISSING_CODE = 'TERMINAL_CWD_MISSING';
const TERMINAL_PURPOSE = Object.freeze({ type: 'terminal' });
const MAX_PURPOSE_ID_CHARS = 128;
const OBJECT_TAG = '[object Object]';
const validateSize = (value, max) => Number.isInteger(value) && value >= 1 && value <= max;
const isString = (value) => String(value) === value;
const isObjectRecord = (value) => value != null && !Array.isArray(value) && Object.prototype.toString.call(value) === OBJECT_TAG;
const normalizeCreateMode = ({ mode, command }) => {
const normalizedMode = mode == null ? INTERACTIVE_TERMINAL_MODE : mode;
if (normalizedMode !== INTERACTIVE_TERMINAL_MODE && normalizedMode !== COMMAND_TERMINAL_MODE) throw new Error('Invalid terminal mode');
if (normalizedMode === INTERACTIVE_TERMINAL_MODE) {
if (command != null) throw new Error('Interactive terminal create does not accept a command');
return { mode: INTERACTIVE_TERMINAL_MODE, command: null };
}
if (!isString(command) || !command.trim()) throw new Error('Terminal command is required');
const trimmedCommand = command.trim();
if (trimmedCommand.length > MAX_INPUT_CHARS) throw new Error('Terminal command exceeds the input limit');
return { mode: COMMAND_TERMINAL_MODE, command: trimmedCommand };
};
const normalizePurposeId = (value, errorMessage) => {
if (!isString(value) || !value.trim()) throw new Error(errorMessage);
const normalized = value.trim();
if (normalized.length > MAX_PURPOSE_ID_CHARS) throw new Error(errorMessage);
return normalized;
};
const normalizeTerminalPurpose = (value) => {
if (value == null) return TERMINAL_PURPOSE;
if (!isObjectRecord(value)) throw new Error('Invalid terminal purpose');
if (value.type === 'terminal') return TERMINAL_PURPOSE;
if (value.type !== 'project-action') throw new Error('Invalid terminal purpose');
return {
type: 'project-action',
actionId: normalizePurposeId(value.actionId, 'Terminal project action id is required'),
executionId: normalizePurposeId(value.executionId, 'Terminal execution id is required'),
};
};
const getSessionPurpose = (session) => session.purpose ?? TERMINAL_PURPOSE;
const isPurposeActionMatch = (left, right) => {
if (left.type !== right.type) return false;
return left.type !== 'project-action' || left.actionId === right.actionId;
};
const findRunningActionSession = (sessions, resolvedCwd, purpose, path) => {
if (purpose.type !== 'project-action') return null;
for (const session of sessions.values()) {
if (session.status !== 'running') continue;
if (path.resolve(session.cwd) !== resolvedCwd) continue;
const sessionPurpose = getSessionPurpose(session);
if (sessionPurpose.type === 'project-action' && sessionPurpose.actionId === purpose.actionId) return session;
}
return null;
};
const findPendingActionCreate = (pendingSessionCreates, resolvedCwd, purpose) => {
if (purpose.type !== 'project-action') return null;
for (const pending of pendingSessionCreates.values()) {
if (pending.cancelled || pending.cwd !== resolvedCwd) continue;
if (pending.purpose?.type === 'project-action' && pending.purpose.actionId === purpose.actionId) return pending;
}
return null;
};
const trimHistory = (history) => {
const bytes = Buffer.from(history);
if (bytes.byteLength <= MAX_HISTORY_BYTES) return history;
let start = bytes.byteLength - MAX_HISTORY_BYTES;
while (start < bytes.byteLength && (bytes[start] & 0xc0) === 0x80) start += 1;
return bytes.subarray(start).toString('utf8');
};
export function createTerminalRuntime({
app, server, fs, path, uiAuthController, buildAugmentedPath, searchPathFor, isExecutable,
isRequestOriginAllowed, rejectWebSocketUpgrade, TERMINAL_INPUT_WS_HEARTBEAT_INTERVAL_MS,
loadPtyProvider, terminalTerminationGraceMs = TERMINATION_GRACE_MS,
}) {
const sessions = new Map();
const pendingSessionCreates = new Map();
const pendingSessionRestarts = new Map();
const connections = new Set();
const pendingTerminations = new Set();
const runtime = typeof globalThis.Bun === 'undefined' ? 'node' : 'bun';
let ptyProviderPromise = null;
let wsServer = new WebSocketServer({ noServer: true, maxPayload: TERMINAL_WS_MAX_PAYLOAD_BYTES });
const shellResolver = createTerminalShellResolver({ fs, path, searchPathFor, isExecutable, buildAugmentedPath });
const getPtyProvider = async () => {
if (!ptyProviderPromise) {
ptyProviderPromise = loadPtyProvider ? loadPtyProvider() : (async () => {
if (typeof globalThis.Bun !== 'undefined') {
try { const pty = await import('bun-pty'); return { spawn: pty.spawn, backend: 'bun-pty' }; } catch { /* fall through */ }
}
const pty = await import('node-pty');
return { spawn: pty.spawn, backend: 'node-pty' };
})();
}
return ptyProviderPromise;
};
const spawnPty = async ({ cwd, cols, rows, themeMode, shell, loginShell, mode, command }) => {
const provider = await getPtyProvider();
const resolvedShell = await shellResolver.resolve(shell);
let lastError = null;
for (const executable of resolvedShell.executables) {
try {
const env = { ...process.env, PATH: buildAugmentedPath(), TERM: 'xterm-256color', COLORTERM: 'truecolor', COLORFGBG: themeMode === 'light' ? '0;15' : '15;0' };
// The daemon's IPC fd is closed inside the PTY; an inherited NODE_CHANNEL_FD
// (even an empty one) makes Node CLIs warn about an unparsable IPC channel.
delete env.NODE_CHANNEL_FD;
delete env.BASH_XTRACEFD; delete env.BASH_ENV; delete env.ENV; delete env.ELECTRON_RUN_AS_NODE;
// AppImage exports ARGV0; zsh would otherwise rewrite argv[0] for every command (#2588).
stripAppImageArgv0Leak(env);
const shellLaunch = buildTerminalShellLaunch(executable, { mode, command, loginShell });
// bun-pty merges the native OS environ back in, so the POSIX launch is
// wrapped with `env -u` for the variables deleted above.
const launch = resolvePosixPtyLaunch(shellLaunch.executable, shellLaunch.args);
const options = { name: 'xterm-256color', cwd, cols, rows, env };
if (process.platform === 'win32') options.useConpty = true;
return { process: await provider.spawn(launch.executable, launch.args, options), backend: provider.backend, shell: resolvedShell.id, loginShell };
} catch (error) { lastError = error; }
}
throw lastError ?? new Error('No executable shell found');
};
const killProcess = (ptyProcess, force = false) => {
if (!ptyProcess) return;
if (process.platform !== 'win32' && Number.isInteger(ptyProcess.pid) && ptyProcess.pid > 0) {
try { process.kill(-ptyProcess.pid, force ? 'SIGKILL' : 'SIGTERM'); } catch { /* already gone */ }
}
try { ptyProcess.kill(force ? 'SIGKILL' : undefined); } catch { /* already gone */ }
};
const terminateProcess = (ptyProcess, force = false) => {
if (!ptyProcess) return Promise.resolve();
if (force) { killProcess(ptyProcess, true); return Promise.resolve(); }
let termination;
termination = new Promise((resolve) => {
let settled = false;
let disposable = null;
const finish = () => {
if (settled) return;
settled = true;
clearTimeout(timeout);
disposable?.dispose?.();
resolve();
};
const timeout = setTimeout(() => { killProcess(ptyProcess, true); finish(); }, terminalTerminationGraceMs);
try { disposable = ptyProcess.onExit(() => finish()); } catch { /* backend is already gone */ }
killProcess(ptyProcess, false);
}).finally(() => pendingTerminations.delete(termination));
pendingTerminations.add(termination);
return termination;
};
const send = (socket, message) => {
if (socket?.readyState !== 1) return false;
try { socket.send(createTerminalWsControlFrame(message), { binary: true }); return true; } catch { return false; }
};
const closeAttachments = (sessionId, code, message) => {
for (const connection of connections) {
if (!connection.attachments.delete(sessionId)) continue;
send(connection.socket, { t: 'error', v: 3, s: sessionId, code, message, fatal: true });
}
};
const snapshot = (session) => ({
t: 'snapshot', v: 3, s: session.id, q: session.sequence, history: session.history,
// The PTY size the history was drawn for: a client replays history at this
// size before fitting its own viewport, so shell output wrapped for one
// width never gets re-laid-out at another.
cols: session.cols, rows: session.rows,
status: session.status, exitCode: session.exitCode, signal: session.signal,
mode: session.mode ?? INTERACTIVE_TERMINAL_MODE, purpose: getSessionPurpose(session),
runtime, ptyBackend: session.backend,
});
const publish = (session, event) => {
session.sequence += 1;
const message = { ...event, v: 3, s: session.id, q: session.sequence };
for (const connection of connections) {
const attachment = connection.attachments.get(session.id);
if (!attachment) continue;
if (attachment.initializing) attachment.pending.push(message);
else send(connection.socket, message);
}
};
const drainEvents = (session) => {
if (session.draining) return;
session.draining = true;
try {
while (session.eventQueue.length > 0) {
const event = session.eventQueue.shift();
if (event.process !== session.process) continue;
if (event.type === 'output') {
const theme = consumeTerminalThemeQueries(session.pendingThemeControlSequence, event.data, {
themeMode: session.themeMode,
background: session.terminalBackground,
foreground: session.terminalForeground,
modeEnabled: session.themeModeEnabled,
}, { respondToPrimaryDeviceAttributes: true });
session.pendingThemeControlSequence = theme.pending;
session.themeModeEnabled = theme.modeEnabled;
for (const response of theme.responses) session.process?.write(response);
const sanitized = sanitizeTerminalHistoryChunk(session.pendingHistoryControlSequence, event.data);
session.pendingHistoryControlSequence = sanitized.pending;
session.history = trimHistory(session.history + sanitized.visible);
session.lastActivity = Date.now();
publish(session, { t: 'output', d: event.data, ...(sanitized.visible !== event.data ? { r: sanitized.visible } : {}) });
} else {
session.status = 'exited';
session.exitCode = Number.isInteger(event.exitCode) ? event.exitCode : null;
session.signal = Number.isInteger(event.signal) ? event.signal : null;
session.process = null;
publish(session, { t: 'exit', exitCode: session.exitCode, signal: session.signal });
}
}
} finally { session.draining = false; }
};
const wire = (session, ptyProcess) => {
ptyProcess.onData((data) => { session.eventQueue.push({ type: 'output', process: ptyProcess, data }); drainEvents(session); });
ptyProcess.onExit(({ exitCode, signal }) => { session.eventQueue.push({ type: 'exit', process: ptyProcess, exitCode, signal }); drainEvents(session); });
};
// A working directory that no longer exists (a deleted worktree) is the one
// rejection the client can recover from by moving the session to its
// project, so the response names it. Every other rejection stays generic.
const invalidWorkingDirectory = (code) => Object.assign(new Error('Invalid working directory'), code ? { code } : {});
const validateCwd = async (cwd) => {
if (typeof cwd !== 'string' || !cwd.trim()) throw new Error('cwd is required');
let stats;
try { stats = await fs.promises.stat(cwd); }
catch (error) { throw invalidWorkingDirectory(error?.code === 'ENOENT' || error?.code === 'ENOTDIR' ? TERMINAL_CWD_MISSING_CODE : undefined); }
if (!stats?.isDirectory()) throw invalidWorkingDirectory();
};
const errorBody = (error, fallback) => ({ error: error?.message || fallback, ...(typeof error?.code === 'string' ? { code: error.code } : {}) });
const applyAppearance = (session, { themeMode, terminalBackground, terminalForeground }) => {
const previous = [session.themeMode, session.terminalBackground, session.terminalForeground];
if (themeMode === 'light' || themeMode === 'dark') session.themeMode = themeMode;
if (typeof terminalBackground === 'string') session.terminalBackground = terminalBackground;
if (typeof terminalForeground === 'string') session.terminalForeground = terminalForeground;
const changed = previous[0] !== session.themeMode || previous[1] !== session.terminalBackground || previous[2] !== session.terminalForeground;
if (changed && session.themeModeEnabled) {
try { session.process?.write(terminalThemeModeReport(session.themeMode)); } catch { /* process exited */ }
}
};
const startSession = async (session, { cwd, cols, rows, themeMode = 'dark', terminalBackground, terminalForeground, shell, loginShell, mode = INTERACTIVE_TERMINAL_MODE, command = null, purpose = TERMINAL_PURPOSE }, clear = true) => {
await validateCwd(cwd);
const spawned = await spawnPty({ cwd, cols, rows, themeMode, shell, loginShell, mode, command });
if (clear) { session.history = ''; session.pendingHistoryControlSequence = ''; session.pendingThemeControlSequence = ''; session.themeModeEnabled = false; }
session.cwd = cwd; session.cols = cols; session.rows = rows; session.process = spawned.process;
session.backend = spawned.backend; session.shell = spawned.shell; session.loginShell = spawned.loginShell; session.status = 'running'; session.exitCode = null; session.signal = null;
session.mode = mode; session.command = mode === COMMAND_TERMINAL_MODE ? command : null;
session.purpose = purpose;
session.themeMode = themeMode === 'light' ? 'light' : 'dark'; session.terminalBackground = terminalBackground; session.terminalForeground = terminalForeground;
session.lastActivity = Date.now(); session.eventQueue.length = 0;
wire(session, spawned.process);
return spawned.process;
};
const createSession = async ({ sessionId, cwd, cols = 80, rows = 24, themeMode, terminalBackground, terminalForeground, shell = 'auto', loginShell = false, mode, command, purpose }) => {
if (!validateSize(cols, 1000) || !validateSize(rows, 500)) throw new Error('Invalid terminal dimensions');
if (typeof loginShell !== 'boolean') throw new Error('Invalid terminal login mode');
const normalizedShell = normalizeTerminalShell(shell);
if (!normalizedShell) throw new Error('Invalid terminal shell');
const launchMode = normalizeCreateMode({ mode, command });
const normalizedPurpose = normalizeTerminalPurpose(purpose);
const id = typeof sessionId === 'string' && sessionId.trim() ? sessionId.trim() : randomUUID();
if (id.length > 128) throw new Error('Invalid terminal session id');
const existing = sessions.get(id);
const resolvedCwd = path.resolve(cwd);
if (existing?.status === 'running') {
if (path.resolve(existing.cwd) !== resolvedCwd) throw new Error('Terminal session belongs to a different working directory');
if (!isPurposeActionMatch(getSessionPurpose(existing), normalizedPurpose)) throw new Error('Terminal session is already running with a different purpose');
if (normalizedPurpose.type === 'project-action') { applyAppearance(existing, { themeMode, terminalBackground, terminalForeground }); return existing; }
if ((existing.mode ?? INTERACTIVE_TERMINAL_MODE) !== launchMode.mode) throw new Error('Terminal session is already running with a different mode');
if (launchMode.mode === COMMAND_TERMINAL_MODE && existing.command !== launchMode.command) throw new Error('Terminal session is already running with a different command');
applyAppearance(existing, { themeMode, terminalBackground, terminalForeground });
return existing;
}
const runningActionSession = findRunningActionSession(sessions, resolvedCwd, normalizedPurpose, path);
if (runningActionSession) {
applyAppearance(runningActionSession, { themeMode, terminalBackground, terminalForeground });
return runningActionSession;
}
const pending = pendingSessionCreates.get(id);
if (pending) {
if (pending.cwd !== resolvedCwd) throw new Error('Terminal session belongs to a different working directory');
if (!isPurposeActionMatch(pending.purpose, normalizedPurpose)) throw new Error('Terminal session is already being created with a different purpose');
if (normalizedPurpose.type === 'project-action') return pending.promise;
if (pending.shell !== normalizedShell) throw new Error('Terminal session is already being created with a different shell');
if (pending.loginShell !== loginShell) throw new Error('Terminal session is already being created with a different login mode');
if (pending.mode !== launchMode.mode) throw new Error('Terminal session is already being created with a different mode');
if (launchMode.mode === COMMAND_TERMINAL_MODE && pending.command !== launchMode.command) throw new Error('Terminal session is already being created with a different command');
const session = await pending.promise;
applyAppearance(session, { themeMode, terminalBackground, terminalForeground });
return session;
}
const pendingActionCreate = findPendingActionCreate(pendingSessionCreates, resolvedCwd, normalizedPurpose);
if (pendingActionCreate) {
const session = await pendingActionCreate.promise;
applyAppearance(session, { themeMode, terminalBackground, terminalForeground });
return session;
}
const superseded = normalizedPurpose.type === 'project-action'
? [...sessions.values()].filter((session) => session.id !== id && session.status === 'exited'
&& path.resolve(session.cwd) === resolvedCwd && isPurposeActionMatch(getSessionPurpose(session), normalizedPurpose))
: [];
if (!existing && sessions.size - superseded.length + pendingSessionCreates.size >= MAX_SESSIONS) throw new Error('Maximum terminal sessions reached');
const pendingEntry = { cwd: resolvedCwd, shell: normalizedShell, loginShell, mode: launchMode.mode, command: launchMode.command, purpose: normalizedPurpose, cancelled: false, promise: null };
const creation = (async () => {
const session = existing ?? { id, sequence: 0, history: '', pendingHistoryControlSequence: '', pendingThemeControlSequence: '', eventQueue: [], draining: false, createdAt: Date.now() };
const ptyProcess = await startSession(session, { cwd, cols, rows, themeMode, terminalBackground, terminalForeground, shell: normalizedShell, loginShell, mode: launchMode.mode, command: launchMode.command, purpose: normalizedPurpose });
if (pendingEntry.cancelled) {
session.process = null;
await terminateProcess(ptyProcess, true);
throw new Error('Terminal session was closed during creation');
}
for (const previous of superseded) {
if (sessions.get(previous.id) !== previous || previous.status !== 'exited') continue;
sessions.delete(previous.id);
closeAttachments(previous.id, 'SUPERSEDED', 'Terminal replaced by a new action run');
}
sessions.set(id, session);
return session;
})();
pendingEntry.promise = creation;
pendingSessionCreates.set(id, pendingEntry);
try { return await creation; }
finally { if (pendingSessionCreates.get(id) === pendingEntry) pendingSessionCreates.delete(id); }
};
wsServer.on('connection', (socket) => {
const connection = { socket, attachments: new Map() };
connections.add(connection);
send(socket, { t: 'hello', v: 3 });
const heartbeat = setInterval(() => { try { socket.ping(); } catch { /* closed */ } }, TERMINAL_INPUT_WS_HEARTBEAT_INTERVAL_MS);
socket.on('message', (raw, isBinary) => {
if (!isBinary) { send(socket, { t: 'error', v: 3, code: 'BAD_FRAME', message: 'Binary control frame required', fatal: false }); return; }
const message = readTerminalWsControlFrame(raw);
if (!message || message.v !== 3 || typeof message.t !== 'string') { send(socket, { t: 'error', v: 3, code: 'BAD_FRAME', message: 'Invalid terminal frame', fatal: false }); return; }
if (message.t === 'ping') { send(socket, { t: 'pong', v: 3 }); return; }
if (message.t === 'hello') return;
const id = typeof message.s === 'string' ? message.s : '';
if (!id) { send(socket, { t: 'error', v: 3, code: 'BAD_FRAME', message: 'Session id required', fatal: false }); return; }
if (message.t === 'detach') { connection.attachments.delete(id); return; }
const session = sessions.get(id);
if (!session) { send(socket, { t: 'error', v: 3, s: id, code: 'SESSION_NOT_FOUND', message: 'Terminal session not found', fatal: true }); return; }
if (message.t === 'attach') {
const attachment = { initializing: true, pending: [] };
connection.attachments.set(id, attachment);
const initial = snapshot(session);
send(socket, initial);
for (const event of attachment.pending) if (event.q > initial.q) send(socket, event);
attachment.pending.length = 0; attachment.initializing = false;
return;
}
if (message.t === 'write') {
if (typeof message.d !== 'string' || !message.d || message.d.length > MAX_INPUT_CHARS) { send(socket, { t: 'error', v: 3, s: id, code: 'BAD_INPUT', message: 'Invalid terminal input', fatal: false }); return; }
if (session.status !== 'running' || !session.process) { send(socket, { t: 'error', v: 3, s: id, code: 'NOT_RUNNING', message: 'Terminal is not running', fatal: false }); return; }
try { session.process.write(message.d); session.lastActivity = Date.now(); } catch { send(socket, { t: 'error', v: 3, s: id, code: 'WRITE_FAILED', message: 'Failed to write to terminal', fatal: false }); }
}
});
const cleanup = () => { clearInterval(heartbeat); connection.attachments.clear(); connections.delete(connection); };
socket.on('close', cleanup); socket.on('error', () => {});
});
const upgradeHandler = (req, socket, head) => {
if (parseRequestPathname(req.url) !== TERMINAL_WS_PATH) return;
const accept = () => {
if (!wsServer) { rejectWebSocketUpgrade(socket, 500, 'Terminal WebSocket unavailable'); return; }
try {
wsServer.handleUpgrade(req, socket, head, (ws) => wsServer.emit('connection', ws, req));
} catch { rejectWebSocketUpgrade(socket, 500, 'Upgrade failed'); }
};
const checkOrigin = () => {
try {
const result = isRequestOriginAllowed(req);
if (!(result instanceof Promise)) {
if (result) accept();
else rejectWebSocketUpgrade(socket, 403, 'Invalid origin');
return;
}
void result.then((allowed) => {
if (allowed) accept();
else rejectWebSocketUpgrade(socket, 403, 'Invalid origin');
}).catch(() => rejectWebSocketUpgrade(socket, 500, 'Upgrade failed'));
} catch { rejectWebSocketUpgrade(socket, 500, 'Upgrade failed'); }
};
if (!uiAuthController?.enabled) { accept(); return; }
try {
const result = uiAuthController.ensureSessionToken(req, null);
if (!(result instanceof Promise)) {
if (result) checkOrigin();
else rejectWebSocketUpgrade(socket, 401, 'UI authentication required');
return;
}
void result.then((sessionToken) => {
if (sessionToken) checkOrigin();
else rejectWebSocketUpgrade(socket, 401, 'UI authentication required');
}).catch(() => rejectWebSocketUpgrade(socket, 500, 'Upgrade failed'));
} catch { rejectWebSocketUpgrade(socket, 500, 'Upgrade failed'); }
};
server.on('upgrade', upgradeHandler);
app.get('/api/terminal/shells', async (_req, res) => {
try {
const shells = await shellResolver.list();
res.json(shells.map(({ id, name, supportsLogin }) => ({ id, name, supportsLogin })));
} catch (error) {
res.status(500).json({ error: error?.message || 'Failed to list terminal shells' });
}
});
app.get('/api/terminal/sessions', (req, res) => {
const rawCwd = typeof req.query?.cwd === 'string' ? req.query.cwd.trim() : '';
const cwdFilter = rawCwd ? path.resolve(rawCwd) : null;
const list = [];
for (const session of sessions.values()) {
if (cwdFilter && path.resolve(session.cwd) !== cwdFilter) continue;
list.push({
sessionId: session.id,
cwd: session.cwd,
status: session.status,
createdAt: Number.isInteger(session.createdAt) ? session.createdAt : null,
mode: session.mode ?? INTERACTIVE_TERMINAL_MODE,
purpose: getSessionPurpose(session),
});
}
res.json({ sessions: list });
});
app.post('/api/terminal/touch', (req, res) => {
const rawIds = Array.isArray(req.body?.sessionIds) ? req.body.sessionIds : [];
const now = Date.now();
let touched = 0;
for (const id of rawIds) {
if (typeof id !== 'string') continue;
const session = sessions.get(id);
if (!session) continue;
session.lastActivity = now;
touched += 1;
}
res.json({ touched });
});
app.post('/api/terminal/create', async (req, res) => {
try {
const session = await createSession(req.body ?? {});
res.json({
sessionId: session.id,
cols: session.cols,
rows: session.rows,
status: session.status,
mode: session.mode ?? INTERACTIVE_TERMINAL_MODE,
purpose: getSessionPurpose(session),
});
}
catch (error) { res.status(error?.message === 'Maximum terminal sessions reached' ? 429 : 400).json(errorBody(error, 'Failed to create terminal session')); }
});
app.post('/api/terminal/:sessionId/resize', (req, res) => {
const session = sessions.get(req.params.sessionId);
if (!session) return res.status(404).json({ error: 'Terminal session not found' });
const { cols, rows } = req.body ?? {};
if (!validateSize(cols, 1000) || !validateSize(rows, 500)) return res.status(400).json({ error: 'Invalid terminal dimensions' });
try { if (session.status === 'running') session.process?.resize(cols, rows); session.cols = cols; session.rows = rows; res.json({ success: true, cols, rows }); }
catch (error) { res.status(500).json({ error: error?.message || 'Failed to resize terminal' }); }
});
app.post('/api/terminal/:sessionId/appearance', (req, res) => {
const session = sessions.get(req.params.sessionId);
if (!session) return res.status(404).json({ error: 'Terminal session not found' });
applyAppearance(session, req.body ?? {});
res.json({ success: true });
});
app.post('/api/terminal/:sessionId/restart', async (req, res) => {
const session = sessions.get(req.params.sessionId);
if (!session) return res.status(404).json({ error: 'Terminal session not found' });
if ((session.mode ?? INTERACTIVE_TERMINAL_MODE) === COMMAND_TERMINAL_MODE) return res.status(400).json({ error: 'Command-mode terminal sessions cannot be restarted' });
const cwd = req.body?.cwd ?? session.cwd;
const cols = req.body?.cols ?? session.cols;
const rows = req.body?.rows ?? session.rows;
const themeMode = req.body?.themeMode ?? session.themeMode;
const terminalBackground = req.body?.terminalBackground ?? session.terminalBackground;
const terminalForeground = req.body?.terminalForeground ?? session.terminalForeground;
const shell = req.body?.shell ?? 'auto';
const loginShell = req.body?.loginShell ?? false;
const previousRestart = pendingSessionRestarts.get(session.id) ?? Promise.resolve();
const restart = previousRestart.catch(() => {}).then(async () => {
await validateCwd(cwd);
if (!validateSize(cols, 1000) || !validateSize(rows, 500)) throw new Error('Invalid terminal dimensions');
if (typeof loginShell !== 'boolean') throw new Error('Invalid terminal login mode');
const oldProcess = session.process;
const spawned = await spawnPty({ cwd, cols, rows, themeMode, shell, loginShell });
session.process = spawned.process; session.backend = spawned.backend; session.shell = spawned.shell; session.loginShell = spawned.loginShell; session.cwd = cwd; session.cols = cols; session.rows = rows;
session.history = ''; session.pendingHistoryControlSequence = ''; session.pendingThemeControlSequence = ''; session.themeModeEnabled = false; session.status = 'running'; session.exitCode = null; session.signal = null; session.eventQueue.length = 0;
session.themeMode = themeMode === 'light' ? 'light' : 'dark'; session.terminalBackground = terminalBackground; session.terminalForeground = terminalForeground;
wire(session, spawned.process); void terminateProcess(oldProcess); publish(session, { t: 'restarted', history: '' });
});
pendingSessionRestarts.set(session.id, restart);
try {
await restart;
res.json({ sessionId: session.id, cols, rows, status: session.status });
} catch (error) { res.status(400).json(errorBody(error, 'Failed to restart terminal')); }
finally { if (pendingSessionRestarts.get(session.id) === restart) pendingSessionRestarts.delete(session.id); }
});
app.delete('/api/terminal/:sessionId', async (req, res) => {
const session = sessions.get(req.params.sessionId);
if (!session) {
const pending = pendingSessionCreates.get(req.params.sessionId);
if (!pending) return res.status(404).json({ error: 'Terminal session not found' });
pending.cancelled = true;
try { await pending.promise; }
catch (error) {
if (error?.message !== 'Terminal session was closed during creation') throw error;
}
return res.json({ success: true });
}
sessions.delete(session.id);
closeAttachments(session.id, 'CLOSED', 'Terminal closed');
await terminateProcess(session.process);
res.json({ success: true });
});
app.post('/api/terminal/force-kill', (req, res) => {
const { sessionId, cwd } = req.body ?? {}; let killedCount = 0;
const killedSessionIds = [];
for (const [id, session] of sessions) {
if ((sessionId && id !== sessionId) || (!sessionId && cwd && session.cwd !== cwd)) continue;
sessions.delete(id); closeAttachments(id, 'KILLED', 'Terminal was killed'); void terminateProcess(session.process, true); killedSessionIds.push(id); killedCount += 1;
}
res.json({ success: true, killedCount, killedSessionIds });
});
const idleSweep = setInterval(() => {
const now = Date.now();
for (const [id, session] of sessions) {
const attached = [...connections].some((connection) => connection.attachments.has(id));
if (!attached && now - session.lastActivity > IDLE_TIMEOUT_MS) {
sessions.delete(id); closeAttachments(id, 'IDLE_TIMEOUT', 'Terminal expired after being idle'); void terminateProcess(session.process, true);
}
}
}, 5 * 60 * 1000);
const shutdown = async () => {
server.off('upgrade', upgradeHandler); clearInterval(idleSweep);
await Promise.allSettled([...pendingSessionRestarts.values()]);
for (const session of sessions.values()) void terminateProcess(session.process, true);
sessions.clear();
await Promise.allSettled([...pendingTerminations]);
if (!wsServer) return;
for (const client of wsServer.clients) client.terminate();
await Promise.race([
new Promise((resolve) => wsServer.close(resolve)),
new Promise((resolve) => setTimeout(resolve, 1000)),
]);
wsServer = null;
};
return { shutdown };
}