2026-07-17 13:17:21 +03:00
import { randomUUID } from 'node:crypto' ;
2026-03-31 18:47:00 +03:00
import { WebSocketServer } from 'ws' ;
import {
2026-07-17 13:17:21 +03:00
TERMINAL_WS_MAX_PAYLOAD_BYTES ,
TERMINAL_WS_PATH ,
createTerminalWsControlFrame ,
2026-06-26 19:27:53 +03:00
parseRequestPathname ,
2026-07-17 13:17:21 +03:00
readTerminalWsControlFrame ,
2026-06-26 19:27:53 +03:00
} from './terminal-ws-protocol.js' ;
2026-07-17 13:17:21 +03:00
import { sanitizeTerminalHistoryChunk } from './history.js' ;
import { consumeTerminalThemeQueries , terminalThemeModeReport } from './theme-response.js' ;
import { createTerminalShellResolver , getTerminalShellLoginArgs , normalizeTerminalShell } from './shells.js' ;
2026-08-03 09:12:35 +00:00
import { stripAppImageArgv0Leak , resolveLinuxPtyLaunch } from '../inherited-env.js' ;
2026-07-17 13:17:21 +03:00
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 validateSize = ( value , max ) => Number . isInteger ( value ) && value >= 1 && value <= max ;
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' );
};
2026-03-31 18:47:00 +03:00
export function createTerminalRuntime ({
2026-07-17 13:17:21 +03:00
app , server , fs , path , uiAuthController , buildAugmentedPath , searchPathFor , isExecutable ,
isRequestOriginAllowed , rejectWebSocketUpgrade , TERMINAL_INPUT_WS_HEARTBEAT_INTERVAL_MS ,
loadPtyProvider , terminalTerminationGraceMs = TERMINATION_GRACE_MS ,
2026-03-31 18:47:00 +03:00
}) {
2026-07-17 13:17:21 +03:00
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' ;
2026-03-31 18:47:00 +03:00
let ptyProviderPromise = null ;
2026-07-17 13:17:21 +03:00
let wsServer = new WebSocketServer ({ noServer : true , maxPayload : TERMINAL_WS_MAX_PAYLOAD_BYTES });
const shellResolver = createTerminalShellResolver ({ fs , path , searchPathFor , isExecutable , buildAugmentedPath });
2026-03-31 18:47:00 +03:00
2026-07-17 13:17:21 +03:00
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 */ }
2026-03-31 18:47:00 +03:00
}
2026-07-17 13:17:21 +03:00
const pty = await import ( 'node-pty' );
return { spawn : pty . spawn , backend : 'node-pty' };
})();
2026-03-31 18:47:00 +03:00
}
2026-07-17 13:17:21 +03:00
return ptyProviderPromise ;
2026-03-31 18:47:00 +03:00
};
2026-07-17 13:17:21 +03:00
const spawnPty = async ({ cwd , cols , rows , themeMode , shell , loginShell }) => {
const provider = await getPtyProvider ();
const resolvedShell = await shellResolver . resolve ( shell );
2026-03-31 18:47:00 +03:00
let lastError = null ;
2026-07-17 13:17:21 +03:00
for ( const executable of resolvedShell . executables ) {
const args = loginShell ? getTerminalShellLoginArgs ( executable ) : [];
if ( ! args ) throw new Error ( `Terminal shell " ${ resolvedShell . id } " does not support login mode` );
2026-03-31 18:47:00 +03:00
try {
2026-07-17 13:17:21 +03:00
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 ;
2026-08-03 08:54:39 +00:00
// AppImage exports ARGV0; zsh would otherwise rewrite argv[0] for every command (#2588).
2026-08-03 09:12:35 +00:00
// bun-pty also merges the native OS environ, so wrap with `env -u ARGV0` on Linux.
2026-08-03 08:54:39 +00:00
stripAppImageArgv0Leak ( env );
2026-08-03 09:12:35 +00:00
const launch = resolveLinuxPtyLaunch ( executable , args );
2026-07-17 13:17:21 +03:00
const options = { name : 'xterm-256color' , cwd , cols , rows , env , ...( process . platform === 'win32' ? { useConpty : true } : {}) };
2026-08-03 09:12:35 +00:00
return { process : provider . spawn ( launch . executable , launch . args , options ), backend : provider . backend , shell : resolvedShell . id , loginShell };
2026-07-17 13:17:21 +03:00
} catch ( error ) { lastError = error ; }
}
throw lastError ?? new Error ( 'No executable shell found' );
2026-03-31 18:47:00 +03:00
};
2026-07-17 13:17:21 +03:00
const killProcess = ( ptyProcess , force = false ) => {
2026-04-29 17:03:38 -04:00
if ( ! ptyProcess ) return ;
2026-07-17 13:17:21 +03:00
if ( process . platform !== 'win32' && Number . isInteger ( ptyProcess . pid ) && ptyProcess . pid > 0 ) {
try { process . kill ( - ptyProcess . pid , force ? 'SIGKILL' : 'SIGTERM' ); } catch { /* already gone */ }
2026-04-29 17:03:38 -04:00
}
2026-07-17 13:17:21 +03:00
try { ptyProcess . kill ( force ? 'SIGKILL' : undefined ); } catch { /* already gone */ }
2026-04-29 17:03:38 -04:00
};
2026-07-17 13:17:21 +03:00
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 ;
2026-03-31 18:47:00 +03:00
};
2026-07-17 13:17:21 +03:00
const send = ( socket , message ) => {
if ( socket ? . readyState !== 1 ) return false ;
try { socket . send ( createTerminalWsControlFrame ( message ), { binary : true }); return true ; } catch { return false ; }
};
2026-05-23 17:15:16 +07:00
2026-07-17 13:17:21 +03:00
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 });
2026-05-23 17:15:16 +07:00
}
};
2026-07-17 13:17:21 +03:00
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 ,
runtime , ptyBackend : session . backend ,
2026-03-31 18:47:00 +03:00
});
2026-07-17 13:17:21 +03:00
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 );
}
};
2026-03-31 18:47:00 +03:00
2026-07-17 13:17:21 +03:00
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 ,
2026-08-03 12:29:32 +03:00
}, { respondToPrimaryDeviceAttributes : true });
2026-07-17 13:17:21 +03:00
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 });
2026-03-31 18:47:00 +03:00
}
2026-07-17 13:17:21 +03:00
}
} finally { session . draining = false ; }
};
2026-03-31 18:47:00 +03:00
2026-07-17 13:17:21 +03:00
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 ); });
};
2026-04-02 00:31:21 +08:00
2026-07-17 13:17:21 +03:00
const validateCwd = async ( cwd ) => {
if ( typeof cwd !== 'string' || ! cwd . trim ()) throw new Error ( 'cwd is required' );
const stats = await fs . promises . stat ( cwd ). catch (() => null );
if ( ! stats ? . isDirectory ()) throw new Error ( 'Invalid working directory' );
};
2026-04-02 00:31:21 +08:00
2026-07-17 13:17:21 +03:00
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 */ }
}
};
2026-03-31 18:47:00 +03:00
2026-07-17 13:17:21 +03:00
const startSession = async ( session , { cwd , cols , rows , themeMode = 'dark' , terminalBackground , terminalForeground , shell , loginShell }, clear = true ) => {
await validateCwd ( cwd );
const spawned = await spawnPty ({ cwd , cols , rows , themeMode , shell , loginShell });
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 . themeMode = themeMode === 'light' ? 'light' : 'dark' ; session . terminalBackground = terminalBackground ; session . terminalForeground = terminalForeground ;
session . lastActivity = Date . now (); session . eventQueue . length = 0 ;
wire ( session , spawned . process );
};
2026-03-31 18:47:00 +03:00
2026-07-17 13:17:21 +03:00
const createSession = async ({ sessionId , cwd , cols = 80 , rows = 24 , themeMode , terminalBackground , terminalForeground , shell = 'auto' , loginShell = false }) => {
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 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' );
applyAppearance ( existing , { themeMode , terminalBackground , terminalForeground });
return existing ;
}
const pending = pendingSessionCreates . get ( id );
if ( pending ) {
if ( pending . cwd !== resolvedCwd ) throw new Error ( 'Terminal session belongs to a different working directory' );
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' );
const session = await pending . promise ;
applyAppearance ( session , { themeMode , terminalBackground , terminalForeground });
return session ;
}
if ( ! existing && sessions . size + pendingSessionCreates . size >= MAX_SESSIONS ) throw new Error ( 'Maximum terminal sessions reached' );
const creation = ( async () => {
const session = existing ?? { id , sequence : 0 , history : '' , pendingHistoryControlSequence : '' , pendingThemeControlSequence : '' , eventQueue : [], draining : false };
await startSession ( session , { cwd , cols , rows , themeMode , terminalBackground , terminalForeground , shell : normalizedShell , loginShell });
sessions . set ( id , session );
return session ;
})();
const pendingEntry = { cwd : resolvedCwd , shell : normalizedShell , loginShell , promise : creation };
pendingSessionCreates . set ( id , pendingEntry );
try { return await creation ; }
finally { if ( pendingSessionCreates . get ( id ) === pendingEntry ) pendingSessionCreates . delete ( id ); }
};
2026-03-31 18:47:00 +03:00
2026-07-17 13:17:21 +03:00
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 ;
2026-03-31 18:47:00 +03:00
return ;
}
2026-07-17 13:17:21 +03:00
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 }); }
2026-03-31 18:47:00 +03:00
}
});
2026-07-17 13:17:21 +03:00
const cleanup = () => { clearInterval ( heartbeat ); connection . attachments . clear (); connections . delete ( connection ); };
socket . on ( 'close' , cleanup ); socket . on ( 'error' , () => {});
2026-03-31 18:47:00 +03:00
});
2026-05-12 04:01:31 -04:00
const upgradeHandler = ( req , socket , head ) => {
2026-07-17 13:17:21 +03:00
if ( parseRequestPathname ( req . url ) !== TERMINAL_WS_PATH ) return ;
void ( async () => {
2026-03-31 18:47:00 +03:00
try {
if ( uiAuthController ? . enabled ) {
2026-07-17 13:17:21 +03:00
if ( ! await uiAuthController . ensureSessionToken ( req , null )) { rejectWebSocketUpgrade ( socket , 401 , 'UI authentication required' ); return ; }
if ( ! await isRequestOriginAllowed ( req )) { rejectWebSocketUpgrade ( socket , 403 , 'Invalid origin' ); return ; }
2026-03-31 18:47:00 +03:00
}
2026-07-17 13:17:21 +03:00
if ( ! wsServer ) { rejectWebSocketUpgrade ( socket , 500 , 'Terminal WebSocket unavailable' ); return ; }
wsServer . handleUpgrade ( req , socket , head , ( ws ) => wsServer . emit ( 'connection' , ws , req ));
} catch { rejectWebSocketUpgrade ( socket , 500 , 'Upgrade failed' ); }
})();
2026-05-12 04:01:31 -04:00
};
server . on ( 'upgrade' , upgradeHandler );
2026-03-31 18:47:00 +03:00
2026-07-17 13:17:21 +03:00
app . get ( '/api/terminal/shells' , async ( _req , res ) => {
2026-03-31 18:47:00 +03:00
try {
2026-07-17 13:17:21 +03:00
const shells = await shellResolver . list ();
res . json ( shells . map (({ id , name , supportsLogin }) => ({ id , name , supportsLogin })));
2026-03-31 18:47:00 +03:00
} catch ( error ) {
2026-07-17 13:17:21 +03:00
res . status ( 500 ). json ({ error : error ? . message || 'Failed to list terminal shells' });
2026-05-12 04:01:47 -04:00
}
2026-03-31 18:47:00 +03:00
});
2026-07-17 13:17:21 +03:00
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 }); }
catch ( error ) { res . status ( error ? . message === 'Maximum terminal sessions reached' ? 429 : 400 ). json ({ error : error ? . message || 'Failed to create terminal session' }); }
2026-03-31 18:47:00 +03:00
});
app . post ( '/api/terminal/:sessionId/resize' , ( req , res ) => {
2026-07-17 13:17:21 +03:00
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' }); }
2026-03-31 18:47:00 +03:00
});
2026-07-17 13:17:21 +03:00
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 });
2026-03-31 18:47:00 +03:00
});
app . post ( '/api/terminal/:sessionId/restart' , async ( req , res ) => {
2026-07-17 13:17:21 +03:00
const session = sessions . get ( req . params . sessionId );
if ( ! session ) return res . status ( 404 ). json ({ error : 'Terminal session not found' });
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 ;
2026-08-03 12:29:32 +03:00
wire ( session , spawned . process ); void terminateProcess ( oldProcess ); publish ( session , { t : 'restarted' , history : '' });
2026-07-17 13:17:21 +03:00
});
pendingSessionRestarts . set ( session . id , restart );
2026-03-31 18:47:00 +03:00
try {
2026-07-17 13:17:21 +03:00
await restart ;
res . json ({ sessionId : session . id , cols , rows , status : session . status });
} catch ( error ) { res . status ( 400 ). json ({ error : error ? . message || '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 ) return res . status ( 404 ). json ({ error : 'Terminal session not found' });
sessions . delete ( session . id );
closeAttachments ( session . id , 'CLOSED' , 'Terminal closed' );
await terminateProcess ( session . process );
res . json ({ success : true });
2026-03-31 18:47:00 +03:00
});
app . post ( '/api/terminal/force-kill' , ( req , res ) => {
2026-07-17 13:17:21 +03:00
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 ;
2026-03-31 18:47:00 +03:00
}
2026-07-17 13:17:21 +03:00
res . json ({ success : true , killedCount , killedSessionIds });
2026-03-31 18:47:00 +03:00
});
2026-07-17 13:17:21 +03:00
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 );
2026-03-31 18:47:00 +03:00
}
}
2026-07-17 13:17:21 +03:00
}, 5 * 60 * 1000 );
2026-03-31 18:47:00 +03:00
2026-07-17 13:17:21 +03:00
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 ;
2026-03-31 18:47:00 +03:00
};
return { shutdown };
}