Files
apes-sidebar/src/pty-manager.ts
T
Menghuan1918 d1cdca3324 terminal: keep the pty alive while its tab is open, whatever the view does
The close frame was sent on every unmount, so switching conversations (or
refreshing) killed the shell even though the tab was still open; coming
back spawned a fresh terminal. Now the unmount close frame only fires when
the tab is truly gone (checked against the per-session store state), so a
bare unmount hands the process to the host's reconnect grace and the next
connect reattaches the SAME shell with its transcript. Closing a tab
releases the quota immediately: the socket-open path keeps the close
frame, and a new pty.close API covers the socket-down (reconnect loop)
case that previously left a 30s zombie holding the quota.

The host also records the spawn cwd and respawns when a reconnect carries
a different authoritative cwd — the page-load hydrate race can attach the
real session cwd after the first connect fell back to the process cwd, and
a shell in the wrong directory must not linger.
2026-08-08 17:24:08 +08:00

200 lines
7.3 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 } from 'node:path'
import { createRequire } from 'node:module'
import * as nodePty from 'node-pty'
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-pty-local'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: nodePty.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).
*/
export class PtyManager {
private readonly sessions = new Map<string, SidebarPty>()
private readonly pendingCloses = new Map<string, ReturnType<typeof setTimeout>>()
constructor(
private readonly shell: string,
private readonly maxPerSession: number,
) {}
/** 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): 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 handle: SidebarPty = {
key,
sessionId,
tabId,
cwd,
pty: nodePty.spawn(this.shell, [], {
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.
*/
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)
}
/** Cancel a pending scheduled close (the terminal is being reopened). */
cancelClose(key: string): void {
const timer = this.pendingCloses.get(key)
if (timer !== undefined) {
clearTimeout(timer)
this.pendingCloses.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)
}
}
/** The interactive shell for this platform (empty SHELL falls back). */
export function defaultShell(): string {
if (process.platform === 'win32') return 'powershell.exe'
const shell = process.env.SHELL
return shell !== undefined && shell.trim() !== '' ? shell : '/bin/bash'
}