Files
apes-sidebar/src/pty-manager.ts
T

470 lines
19 KiB
TypeScript

/**
* PTY session table for the sidebar terminals. One node-pty process per
* `${sessionId}:${tabId}` key; processes survive WebSocket disconnects
* (page refresh, tab switch) and reconnect to the same process by key.
* Output is mirrored into a bounded transcript ring (capped bytes) so a new
* connection replays history before live data. Sessions die only when the
* tab is closed or the plugin tears down.
*/
import { chmodSync, existsSync } from 'node:fs'
import { dirname, join, win32 as win32Path } from 'node:path'
import { createRequire } from 'node:module'
import { userInfo } from 'node:os'
import type { IPty } from 'node-pty'
import { loadRequiredNodePty, type NodePtyModule } from './pty-deps.ts'
import { SidebarError } from './wire.ts'
/** Per-terminal transcript bound (bytes kept for replay). */
const TRANSCRIPT_LIMIT = 1 << 20
/**
* Restore the executable bit pnpm strips from node-pty's prebuilt
* spawn-helper (the macOS helper that forks and sets up the pty). Without it
* every spawn fails with `posix_spawnp failed`. Idempotent; mirrors
* @deepseek-ai/dsh-terminal-bash's ensure-spawn-helper postinstall, run at
* plugin activation so link-installed deployments get the fix too.
*/
export function ensureSpawnHelper(): void {
if (process.platform === 'win32') return
try {
const require = createRequire(import.meta.url)
const entry = require.resolve('node-pty')
const packageRoot = dirname(dirname(entry))
const candidates = [
join(packageRoot, 'prebuilds', `${process.platform}-${process.arch}`, 'spawn-helper'),
join(packageRoot, 'build', 'Release', 'spawn-helper'),
]
for (const helper of candidates) {
if (existsSync(helper)) chmodSync(helper, 0o755)
}
} catch {
// Resolution or chmod failure: the terminal surfaces its own spawn error.
}
}
/** One live terminal. */
export interface SidebarPty {
/** `${sessionId}:${tabId}` registry key. */
key: string
sessionId: string
tabId: string
/** The working directory the process was SPAWNED with (a reconnect that
* resolves a different authoritative cwd respawns instead of reusing —
* the page-load hydrate race can attach the real cwd after the first
* connect, and a shell in the wrong directory must not linger). */
cwd: string
pty: IPty
/** Output accumulated since spawn (bounded; head dropped when over the limit). */
transcript: string
/** Whether the top-level process exited (transcript stays replayable). */
exited: boolean
exitCode?: number | null
}
/**
* The terminal registry. `maxPerSession` bounds concurrent processes per
* conversation (the client caps tabs at the same number).
*
* Lifecycle of a UI-tab pty when its WebSocket drops:
* - **Close frame** (`{type:'close'}`): the user closed the tab → schedule a
* 0-ms close (quota released immediately).
* - **Park frame** (`{type:'park'}`): the user switched to another
* conversation; the tab is still open in its session's persisted state but
* its view unmounted → mark the pty as parked (no auto-close countdown).
* The pty stays alive until the user switches back (a reconnecting view
* calls `open()` which clears the parked state) or the tab is later closed
* (a `{type:'close'}` frame from a fresh connection). Without `park`, a
* bare socket drop would start the reconnect-grace countdown and kill the
* shell after `reconnectGraceMs` — wrong for a session switch, where the
* user is still actively using the app, just in another conversation.
* - **Bare socket drop** (no frame): page refresh, crash, plugin teardown →
* schedule a close after `reconnectGraceMs` so a quick reconnect reattaches
* the same shell.
*/
export class PtyManager {
private readonly sessions = new Map<string, SidebarPty>()
private readonly pendingCloses = new Map<string, ReturnType<typeof setTimeout>>()
/** Tabs whose view unmounted because the user switched conversations — the
* tab is still open in its session's state, so the pty must NOT enter the
* reconnect-grace countdown. Cleared by `cancelClose` (a reconnecting
* view's `open()` cancels it) or by `scheduleClose` (an explicit close
* frame still kills a parked pty). */
private readonly parked = new Set<string>()
constructor(
private readonly shell: string,
private readonly maxPerSession: number,
private readonly shellArgs: string[] = [],
/** The loaded node-pty module (injected so a broken install degrades instead of crashing the plugin). */
private readonly nodePty: NodePtyModule = loadRequiredNodePty(),
) {}
/** All live terminal keys of one session. */
keysOf(sessionId: string): string[] {
const keys: string[] = []
for (const handle of this.sessions.values()) {
if (handle.sessionId === sessionId) keys.push(handle.key)
}
return keys
}
/**
* Open (or reuse) the terminal for a session/tab key. A handle whose
* process already exited is replaced with a fresh spawn (reconnecting a
* dead terminal must yield a live shell, not an input sink), and so is a
* live handle whose spawn cwd differs from the now-authoritative one (the
* first connect of a page load can arrive before the session hydrates, so
* it fell back to the process cwd — reconnecting with the real cwd must
* restart the shell in the right directory). Reopening also cancels any
* pending scheduled close (a reconnect within the grace window keeps the
* process alive).
* @param sessionId - conversation id.
* @param tabId - client tab id.
* @param cwd - initial working directory (the session's cwd).
* @param cols - initial terminal width.
* @param rows - initial terminal height.
* @returns the live handle.
* @throws {SidebarError} pty-error when the per-session cap is reached.
*/
open(
sessionId: string,
tabId: string,
cwd: string,
cols: number,
rows: number,
shell?: string,
shellArgs?: string[],
): SidebarPty {
const key = `${sessionId}:${tabId}`
this.cancelClose(key)
const existing = this.sessions.get(key)
if (existing !== undefined && !existing.exited && existing.cwd === cwd) return existing
if (existing !== undefined) this.close(key)
// Zombie cleanup: a session's exited handles (shell closed, tab dropped
// on an old host without the close frame) must not eat the quota.
for (const [candidate, handle] of [...this.sessions]) {
if (handle.sessionId === sessionId && handle.exited) this.close(candidate)
}
if (this.keysOf(sessionId).length >= this.maxPerSession) {
throw new SidebarError('pty-error', `terminal limit reached (${this.maxPerSession}) for this session`, 400)
}
const executable = resolveShellExecutable(shell ?? this.shell)
const handle: SidebarPty = {
key,
sessionId,
tabId,
cwd,
pty: this.nodePty.spawn(executable, shellSpawnArgs(shellArgs ?? this.shellArgs), {
name: 'xterm-256color',
cols: Math.max(2, Math.floor(cols)),
rows: Math.max(2, Math.floor(rows)),
cwd,
env: { ...process.env },
}),
transcript: '',
exited: false,
}
handle.pty.onData((data) => {
handle.transcript += data
if (handle.transcript.length > TRANSCRIPT_LIMIT) {
handle.transcript = handle.transcript.slice(handle.transcript.length - TRANSCRIPT_LIMIT)
}
})
handle.pty.onExit(({ exitCode }) => {
handle.exited = true
handle.exitCode = exitCode
})
this.sessions.set(key, handle)
return handle
}
/**
* Schedule the terminal's destruction after `delayMs`. A tab close sends
* delay 0 (release the quota immediately); a bare socket drop (refresh,
* crash) uses the grace period so a quick reconnect keeps the process.
* `open()` cancels any pending close. Clears the parked state — an explicit
* close frame on a parked pty (the user switched back and closed the tab)
* still kills it.
*/
scheduleClose(key: string, delayMs: number): void {
const handle = this.sessions.get(key)
if (handle === undefined) return
this.cancelClose(key)
const timer = setTimeout(() => { this.close(key) }, delayMs)
this.pendingCloses.set(key, timer)
}
/**
* Park a terminal: the owning tab's view unmounted because the user
* switched to another conversation, but the tab is still open in its
* session's persisted state. Cancels any pending grace close and marks
* the pty so the host's `ws.on('close')` handler does NOT start the
* reconnect-grace countdown — the pty stays alive until the user switches
* back (a reconnecting view's `open()` clears this) or explicitly closes
* the tab (a `{type:'close'}` frame's `scheduleClose` clears this).
*/
park(key: string): void {
if (this.sessions.get(key) === undefined) return
this.cancelClose(key)
this.parked.add(key)
}
/** Whether this pty was parked (its view unmounted for a session switch). */
isParked(key: string): boolean {
return this.parked.has(key)
}
/** Cancel a pending scheduled close (the terminal is being reopened).
* Also clears the parked state — a reconnecting view reattaches a parked
* pty and resumes normal lifecycle. */
cancelClose(key: string): void {
const timer = this.pendingCloses.get(key)
if (timer !== undefined) {
clearTimeout(timer)
this.pendingCloses.delete(key)
}
this.parked.delete(key)
}
/** Resolve a live handle by key, or undefined. */
get(key: string): SidebarPty | undefined {
return this.sessions.get(key)
}
/** Close a terminal and drop its state (the owning tab was closed). */
close(key: string): void {
this.cancelClose(key)
const handle = this.sessions.get(key)
if (handle === undefined) return
this.sessions.delete(key)
try {
handle.pty.kill()
} catch {
// Already exited or gone; nothing left to kill.
}
}
/** Close every terminal (plugin teardown). */
disposeAll(): void {
for (const timer of this.pendingCloses.values()) clearTimeout(timer)
this.pendingCloses.clear()
for (const key of [...this.sessions.keys()]) this.close(key)
}
}
/**
* Inputs for {@link defaultShell} resolution. Every field is optional and
* defaults to the live process, which keeps the no-argument call sites
* working while tests (and exotic embedders) can pin the platform, the
* environment, and the existence probe independently — the Windows chain
* never executes on the ubuntu CI runners, so it is only testable through
* these injection points.
*/
export interface ShellResolutionOptions {
/** Platform override (defaults to `process.platform`). */
platform?: NodeJS.Platform
/** Environment override; the resolver only reads SHELL, DSH_SIDEBAR_SHELL, PATH, ProgramW6432, ProgramFiles, LOCALAPPDATA. */
env?: NodeJS.ProcessEnv
/** Explicitly configured shell (the `shell` config field); wins over every automatic source. Empty means unset. */
explicit?: string
/** File-existence probe override (defaults to `existsSync`). */
exists?: (path: string) => boolean
}
/** Inputs for resolving one configured shell into the executable path passed
* to node-pty. Injectable so the Windows-only search semantics stay covered
* on POSIX CI runners. */
export interface ShellExecutableResolutionOptions {
/** Platform override (defaults to `process.platform`). */
platform?: NodeJS.Platform
/** Environment override; Windows reads PATH/PATHEXT/SystemRoot plus the
* PowerShell well-known-location variables. */
env?: NodeJS.ProcessEnv
/** File-existence probe override (defaults to `existsSync`). */
exists?: (path: string) => boolean
}
/** Read one Windows environment value case-insensitively. Real
* `process.env` has case-insensitive lookup on Windows, but injected objects
* and some embedders do not preserve that behavior. */
function windowsEnv(env: NodeJS.ProcessEnv, name: string): string | undefined {
const direct = env[name]
if (direct !== undefined) return direct
const lowered = name.toLowerCase()
for (const [key, value] of Object.entries(env)) {
if (key.toLowerCase() === lowered) return value
}
return undefined
}
/**
* Candidate directories that may contain a `pwsh.exe` on Windows: PATH
* entries first, then the well-known machine/user install locations
* (including preview channels and per-user MSI/portable layouts). The
* machine-scope search reads both `ProgramW6432` and `ProgramFiles` so a
* 32-bit Node process — whose `ProgramFiles` points at `(x86)` — still
* finds a 64-bit PowerShell 7 install. De-duped while preserving priority
* order.
*/
function windowsPwshCandidateDirs(env: NodeJS.ProcessEnv): string[] {
const dirs: string[] = []
const pathEntries = windowsEnv(env, 'PATH')
if (pathEntries !== undefined) {
// The win32 branch always uses the Windows PATH separator; hardcoding it
// keeps the function testable from POSIX runners without a delimiter
// injection point.
for (const entry of pathEntries.split(';')) {
const trimmed = entry.trim()
if (trimmed !== '') dirs.push(trimmed)
}
}
for (const programFiles of [windowsEnv(env, 'ProgramW6432'), windowsEnv(env, 'ProgramFiles')]) {
if (programFiles === undefined || programFiles.trim() === '') continue
dirs.push(join(programFiles, 'PowerShell', '7'))
dirs.push(join(programFiles, 'PowerShell', '7-preview'))
}
const localAppData = windowsEnv(env, 'LOCALAPPDATA')
if (localAppData !== undefined && localAppData.trim() !== '') {
dirs.push(join(localAppData, 'Microsoft', 'PowerShell', '7'))
dirs.push(join(localAppData, 'Microsoft', 'PowerShell', '7-preview'))
dirs.push(join(localAppData, 'Programs', 'PowerShell', '7'))
dirs.push(join(localAppData, 'Programs', 'PowerShell', '7-preview'))
}
return [...new Set(dirs)]
}
/**
* Resolve the configured shell executable before handing it to node-pty.
*
* POSIX node-pty uses `execvp`, so bare commands already follow PATH and are
* passed through unchanged. Windows' native backend does not consistently
* apply the shell's PATHEXT lookup to a bare value (`pwsh` / `cmd` can fail
* with the opaque `File not found:` error), so perform the lookup ourselves:
*
* - an explicit path is accepted as-is when it exists (or with a PATHEXT
* suffix when the user omitted `.exe`),
* - a bare name is searched through PATH, System32, and PowerShell's known
* install directories,
* - failure becomes a stable, actionable pty-error instead of a native
* backend string with no mention of the configured shell.
*/
export function resolveShellExecutable(
shell: string,
options: ShellExecutableResolutionOptions = {},
): string {
const configured = shell.trim()
const platform = options.platform ?? process.platform
if (platform !== 'win32' || configured === '') return configured
const env = options.env ?? process.env
const exists = options.exists ?? existsSync
const rawPathext = windowsEnv(env, 'PATHEXT')
const executableExts = (rawPathext ?? '.COM;.EXE')
.split(';')
.map(extension => extension.trim())
// node-pty ultimately calls CreateProcess; batch files need an
// intermediate cmd.exe and therefore are not valid shell executables.
.filter(extension => /^\.(?:com|exe)$/i.test(extension))
if (executableExts.length === 0) executableExts.push('.EXE', '.COM')
const hasExtension = win32Path.extname(configured) !== ''
const names = hasExtension
? [configured]
: executableExts.map(extension => configured + extension.toLowerCase())
const hasPath = win32Path.isAbsolute(configured) || /[\\/]/.test(configured)
const candidates: string[] = []
if (hasPath) {
candidates.push(...names)
} else {
const path = windowsEnv(env, 'PATH')
if (path !== undefined) {
for (const dir of path.split(';').map(entry => entry.trim()).filter(Boolean)) {
for (const name of names) candidates.push(win32Path.join(dir, name))
}
}
const systemRoot = windowsEnv(env, 'SystemRoot')
if (systemRoot !== undefined && systemRoot.trim() !== '') {
for (const name of names) candidates.push(win32Path.join(systemRoot, 'System32', name))
}
if (/^pwsh(?:\.exe)?$/i.test(configured)) {
for (const dir of windowsPwshCandidateDirs(env)) {
candidates.push(win32Path.join(dir, 'pwsh.exe'))
}
}
}
for (const candidate of [...new Set(candidates)]) {
if (exists(candidate)) return candidate
}
throw new SidebarError('pty-error', `shell executable not found: "${configured}"`)
}
/**
* The interactive shell for this platform, resolved like a terminal
* emulator: an explicitly configured shell (the `shell` config field) wins,
* then `$SHELL` on POSIX (deployment override), then the account's login
* shell from passwd, then `/bin/bash`. The passwd step matters because
* service managers and container inits often start dsh without `SHELL`, and
* the tab should still open the user's login shell (e.g. zsh) instead of
* silently degrading to bash.
*
* Windows previously short-circuited to `powershell.exe` (the inbox 5.1)
* before any resolution, so PowerShell 7 users always got a legacy shell
* without `??`/`?.`/ternary and with poor ANSI/UTF-8 defaults. The Windows
* chain is now: explicit shell → `DSH_SIDEBAR_SHELL` env override → first
* `pwsh.exe` found on PATH or in a known install directory → the 5.1
* fallback (machines without PowerShell 7 keep working).
*/
export function defaultShell(options: ShellResolutionOptions = {}): string {
const platform = options.platform ?? process.platform
const env = options.env ?? process.env
const exists = options.exists ?? existsSync
const explicit = options.explicit
if (explicit !== undefined && explicit.trim() !== '') return explicit.trim()
if (platform === 'win32') {
const envShell = env.DSH_SIDEBAR_SHELL
if (envShell !== undefined && envShell.trim() !== '') return envShell.trim()
for (const dir of windowsPwshCandidateDirs(env)) {
const candidate = join(dir, 'pwsh.exe')
if (exists(candidate)) return candidate
}
return 'powershell.exe'
}
const envShell = env.SHELL
if (envShell !== undefined && envShell.trim() !== '') return envShell.trim()
// userInfo() throws when the uid has no passwd entry (rare chroots);
// without a login shell there is nothing better than the bash default.
try {
const loginShell = userInfo().shell
if (typeof loginShell === 'string' && loginShell.trim() !== '') return loginShell
} catch {
// no passwd entry: fall through to /bin/bash
}
return '/bin/bash'
}
/**
* A short display name for a shell executable, used as the terminal tab
* title. `/bin/zsh` → `zsh`, `C:\...\powershell.exe` → `powershell`.
* Falls back to the raw value when no basename can be derived.
*/
export function shellDisplayName(shell: string): string {
const normalized = shell.replace(/\\/g, '/')
const base = normalized.slice(normalized.lastIndexOf('/') + 1)
if (base === '') return shell
return base.replace(/\.(exe|cmd|bat)$/i, '')
}
/**
* Spawn arguments that make the shell behave like a terminal-emulator tab:
* POSIX shells start as login shells (`-l`) so they read the profile files
* (`~/.profile`, `~/.zprofile`); Windows PowerShell takes no login flag.
*
* When explicit `configured` args are supplied they REPLACE the platform
* defaults entirely, giving deployments full control over shell startup.
*/
export function shellSpawnArgs(configured: string[] = []): string[] {
if (configured.length > 0) return [...configured]
return process.platform === 'win32' ? [] : ['-l']
}