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' ;
2026-09-05 03:04:36 -06:00
import { buildTerminalShellLaunch , createTerminalShellResolver , normalizeTerminalShell } from './shells.js' ;
2026-09-07 12:18:22 +03:00
import { stripAppImageArgv0Leak , resolvePosixPtyLaunch } 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 ;
2026-09-05 03:04:36 -06:00
const INTERACTIVE_TERMINAL_MODE = 'interactive' ;
const COMMAND_TERMINAL_MODE = 'command' ;
2026-09-05 21:26:21 +03:00
// 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' ;
2026-09-05 03:04:36 -06:00
const TERMINAL_PURPOSE = Object . freeze ({ type : 'terminal' });
const MAX_PURPOSE_ID_CHARS = 128 ;
const OBJECT_TAG = '[object Object]' ;
2026-07-17 13:17:21 +03:00
const validateSize = ( value , max ) => Number . isInteger ( value ) && value >= 1 && value <= max ;
2026-09-05 03:04:36 -06:00
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 ;
};
2026-07-17 13:17:21 +03:00
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-09-05 03:04:36 -06:00
const spawnPty = async ({ cwd , cols , rows , themeMode , shell , loginShell , mode , command }) => {
2026-07-17 13:17:21 +03:00
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 ) {
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' };
2026-09-07 12:18:22 +03:00
// 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 ;
2026-07-17 13:17:21 +03:00
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).
stripAppImageArgv0Leak ( env );
2026-09-05 03:04:36 -06:00
const shellLaunch = buildTerminalShellLaunch ( executable , { mode , command , loginShell });
2026-09-07 12:18:22 +03:00
// 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 );
2026-09-05 03:04:36 -06:00
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 };
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 ,
2026-09-06 02:14:18 +03:00
// 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 ,
2026-07-17 13:17:21 +03:00
status : session . status , exitCode : session . exitCode , signal : session . signal ,
2026-09-05 03:04:36 -06:00
mode : session . mode ?? INTERACTIVE_TERMINAL_MODE , purpose : getSessionPurpose ( session ),
2026-07-17 13:17:21 +03:00
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-09-05 21:26:21 +03:00
// 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 } : {});
2026-07-17 13:17:21 +03:00
const validateCwd = async ( cwd ) => {
if ( typeof cwd !== 'string' || ! cwd . trim ()) throw new Error ( 'cwd is required' );
2026-09-05 21:26:21 +03:00
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 ();
2026-07-17 13:17:21 +03:00
};
2026-09-05 21:26:21 +03:00
const errorBody = ( error , fallback ) => ({ error : error ? . message || fallback , ...( typeof error ? . code === 'string' ? { code : error . code } : {}) });
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-09-05 03:04:36 -06:00
const startSession = async ( session , { cwd , cols , rows , themeMode = 'dark' , terminalBackground , terminalForeground , shell , loginShell , mode = INTERACTIVE_TERMINAL_MODE , command = null , purpose = TERMINAL_PURPOSE }, clear = true ) => {
2026-07-17 13:17:21 +03:00
await validateCwd ( cwd );
2026-09-05 03:04:36 -06:00
const spawned = await spawnPty ({ cwd , cols , rows , themeMode , shell , loginShell , mode , command });
2026-07-17 13:17:21 +03:00
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 ;
2026-09-05 03:04:36 -06:00
session . mode = mode ; session . command = mode === COMMAND_TERMINAL_MODE ? command : null ;
session . purpose = purpose ;
2026-07-17 13:17:21 +03:00
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-09-05 03:04:36 -06:00
return spawned . process ;
2026-07-17 13:17:21 +03:00
};
2026-03-31 18:47:00 +03:00
2026-09-05 03:04:36 -06:00
const createSession = async ({ sessionId , cwd , cols = 80 , rows = 24 , themeMode , terminalBackground , terminalForeground , shell = 'auto' , loginShell = false , mode , command , purpose }) => {
2026-07-17 13:17:21 +03:00
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' );
2026-09-05 03:04:36 -06:00
const launchMode = normalizeCreateMode ({ mode , command });
const normalizedPurpose = normalizeTerminalPurpose ( purpose );
2026-07-17 13:17:21 +03:00
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' );
2026-09-05 03:04:36 -06:00
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' );
2026-07-17 13:17:21 +03:00
applyAppearance ( existing , { themeMode , terminalBackground , terminalForeground });
return existing ;
}
2026-09-05 03:04:36 -06:00
const runningActionSession = findRunningActionSession ( sessions , resolvedCwd , normalizedPurpose , path );
if ( runningActionSession ) {
applyAppearance ( runningActionSession , { themeMode , terminalBackground , terminalForeground });
return runningActionSession ;
}
2026-07-17 13:17:21 +03:00
const pending = pendingSessionCreates . get ( id );
if ( pending ) {
if ( pending . cwd !== resolvedCwd ) throw new Error ( 'Terminal session belongs to a different working directory' );
2026-09-05 03:04:36 -06:00
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 ;
2026-07-17 13:17:21 +03:00
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' );
2026-09-05 03:04:36 -06:00
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' );
2026-07-17 13:17:21 +03:00
const session = await pending . promise ;
applyAppearance ( session , { themeMode , terminalBackground , terminalForeground });
return session ;
}
2026-09-05 03:04:36 -06:00
const pendingActionCreate = findPendingActionCreate ( pendingSessionCreates , resolvedCwd , normalizedPurpose );
if ( pendingActionCreate ) {
const session = await pendingActionCreate . promise ;
applyAppearance ( session , { themeMode , terminalBackground , terminalForeground });
return session ;
}
2026-09-05 12:05:39 +03:00
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' );
2026-09-05 03:04:36 -06:00
const pendingEntry = { cwd : resolvedCwd , shell : normalizedShell , loginShell , mode : launchMode . mode , command : launchMode . command , purpose : normalizedPurpose , cancelled : false , promise : null };
2026-07-17 13:17:21 +03:00
const creation = ( async () => {
2026-08-24 14:28:45 +03:00
const session = existing ?? { id , sequence : 0 , history : '' , pendingHistoryControlSequence : '' , pendingThemeControlSequence : '' , eventQueue : [], draining : false , createdAt : Date . now () };
2026-09-05 03:04:36 -06:00
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' );
}
2026-09-05 12:05:39 +03:00
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' );
}
2026-07-17 13:17:21 +03:00
sessions . set ( id , session );
return session ;
})();
2026-09-05 03:04:36 -06:00
pendingEntry . promise = creation ;
2026-07-17 13:17:21 +03:00
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 ;
2026-09-01 16:37:40 +03:00
const accept = () => {
if ( ! wsServer ) { rejectWebSocketUpgrade ( socket , 500 , 'Terminal WebSocket unavailable' ); return ; }
2026-03-31 18:47:00 +03:00
try {
2026-07-17 13:17:21 +03:00
wsServer . handleUpgrade ( req , socket , head , ( ws ) => wsServer . emit ( 'connection' , ws , req ));
} catch { rejectWebSocketUpgrade ( socket , 500 , 'Upgrade failed' ); }
2026-09-01 16:37:40 +03:00
};
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' ); }
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-08-24 14:28:45 +03:00
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 ,
2026-09-05 03:04:36 -06:00
mode : session . mode ?? INTERACTIVE_TERMINAL_MODE ,
purpose : getSessionPurpose ( session ),
2026-08-24 14:28:45 +03:00
});
}
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 });
});
2026-07-17 13:17:21 +03:00
app . post ( '/api/terminal/create' , async ( req , res ) => {
2026-09-05 03:04:36 -06:00
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 ),
});
}
2026-09-05 21:26:21 +03:00
catch ( error ) { res . status ( error ? . message === 'Maximum terminal sessions reached' ? 429 : 400 ). json ( errorBody ( error , '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' });
2026-09-05 03:04:36 -06:00
if (( session . mode ?? INTERACTIVE_TERMINAL_MODE ) === COMMAND_TERMINAL_MODE ) return res . status ( 400 ). json ({ error : 'Command-mode terminal sessions cannot be restarted' });
2026-07-17 13:17:21 +03:00
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 });
2026-09-05 21:26:21 +03:00
} catch ( error ) { res . status ( 400 ). json ( errorBody ( error , 'Failed to restart terminal' )); }
2026-07-17 13:17:21 +03:00
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 );
2026-09-05 03:04:36 -06:00
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 });
}
2026-07-17 13:17:21 +03:00
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 };
}