Files
openchamber/packages/web/server/lib/terminal/runtime.js
T
𝖎𝖚𝖑𝖎𝖎𝖆 759af5a77d fix(sessions): recover sessions whose directory disappeared (#3365)
* fix(sessions): keep a shared chat directory until its last session is deleted

Deleting a root chat session removed its managed scratch directory even
when forks, side threads, or subagents still lived in it; OpenCode then
failed every prompt in those sessions with FileSystem.realPath NotFound.
The directory is now removed only once no other known session resolves
to it. The deleted subtree does not count, because the server cascade-
deletes it, and an unloaded global cache keeps the directory instead of
guessing.

Closes #3312.

* fix(sessions): relocate a session whose worktree directory disappeared

A worktree removed outside OpenChamber, by the agent or by hand, left its
sessions pointed at a path that no longer exists: every terminal create
and restart failed with "Invalid working directory" and the tab stayed
stuck, while Git, Files, and prompts kept targeting the dead path.

The terminal server now names that one rejection (TERMINAL_CWD_MISSING)
instead of substituting a directory of its own. The shared UI reuses the
archived-restore fallback for live sessions: a server-confirmed missing
directory moves the session and its stranded subtree to the project's
primary directory through the control-plane move, clears the worktree
hint, re-selects the session, and tells the user where it went. It runs
from a terminal failure and on activation of any session whose directory
is neither a project root nor a managed chat directory; available,
unknown, and failed probes leave everything untouched.

Closes #3338.

* fix(scripts): make oc-dev load again after the changelog cleanup

The changelog cleanup referenced fs.existsSync in a module that imports
existsSync by name and never binds fs, so every oc-dev invocation failed
with "fs is not defined" before reaching its action.

* fix(sessions): probe directory availability on disk, not through OpenCode path resolution

OpenCode's /path never checks that a directory exists: it echoes the
requested path and resolves its project through Git discovery that
swallows errors, so a deleted worktree came back as a valid location and
every missing-directory fallback (draft recovery, archived restore,
session relocation) stayed inert on a real server. The probe now asks
OpenChamber's own /api/fs/list, which stats the path and reports
not-found and not-directory explicitly; anything else stays unknown.

* fix(sidebar): keep a worktree whose directory is gone visible as missing

git keeps a worktree registered after its directory is deleted outside
git and marks it prunable; the list parser ignored that line, so a
deleted worktree looked alive, and nothing in the app asked for a new
listing anyway. The server now reports prunable, the UI keeps such a
worktree in the topology with worktreeStatus missing and a warning icon
on its sidebar group, and relocating a session out of a confirmed-
missing directory raises an in-app topology signal the sidebar
rediscovers on. Dropping the worktree instead would hide every session
that lived there, and a hidden session can never be opened or relocated.
No idle polling is added.

* fix(sessions): never relocate a session to the filesystem root

OpenCode files a directory outside any Git repository under its global
project, whose worktree is the filesystem root. A managed chat whose
directory vanished would otherwise be moved to /. The relocation now
refuses a root destination, and the activation probe recognizes chat
directories through the home-based check as well, so it does not depend
on the chats root having been resolved yet.

* test(sessions): mirror the relocation action in the issue-2039 session-actions mock

session-ui-store now imports relocateSessionFromMissingDirectory, and the
mocked module in this test listed every other action but not that one, so
the file failed on import.
2026-09-05 21:26:21 +03:00

574 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, resolveLinuxPtyLaunch } 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 explicit override is
// required because bun-pty also inherits Bun's native process environment.
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).
// bun-pty also merges the native OS environ, so wrap with `env -u ARGV0` on Linux.
stripAppImageArgv0Leak(env);
const shellLaunch = buildTerminalShellLaunch(executable, { mode, command, loginShell });
const launch = resolveLinuxPtyLaunch(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,
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 };
}