/** Local `node-pty` session: bounded output, readiness, signals, and teardown. */ import { constants } from 'node:os' import { Buffer } from 'node:buffer' import type { IDisposable, IPty } from 'node-pty' import type { PtyBackendSession, PtyReadRequest, PtyReadResult, PtySendOperation, PtySendRead, PtySendRequest, PtySendResult, PtySessionStatus, PtySignal, PtySignalResult, PtyWaitReason, } from '@deepseek-ai/dsh-pty' import type { ResolvedConfig } from './config.ts' import type { ProcessIdentity, ProcessInspector } from './process-inspector.ts' import { TerminalSanitizer } from './sanitize.ts' function delay(ms: number): Promise { return new Promise(resolve => setTimeout(resolve, ms)) } function utf8Tail(text: string, maxBytes: number): { text: string; truncated: boolean } { if (Buffer.byteLength(text) <= maxBytes) return { text, truncated: false } const chars = Array.from(text) let bytes = 0 let start = chars.length while (start > 0) { const next = Buffer.byteLength(chars[start - 1] as string) if (bytes + next > maxBytes) break bytes += next start -= 1 } return { text: chars.slice(start).join(''), truncated: true } } class BoundedTextBuffer { private value = '' private dropped = false constructor( private readonly maxBytes: number, private readonly maxLines?: number, ) {} append(text: string): void { if (text.length === 0) return this.value += text if (this.maxLines !== undefined) { const lines = this.value.split('\n') if (lines.length > this.maxLines) { this.value = lines.slice(lines.length - this.maxLines).join('\n') this.dropped = true } } const tail = utf8Tail(this.value, this.maxBytes) this.value = tail.text this.dropped ||= tail.truncated } consume(): PtySendRead { const delta = this.value const truncated = this.dropped this.value = '' this.dropped = false return { delta, truncated } } snapshot(): { text: string; truncated: boolean } { return { text: this.value, truncated: this.dropped } } } class LocalSendOperation implements PtySendOperation { private readonly output: BoundedTextBuffer private readonly promise: PromiseWithResolvers private finished = false private initialForegroundLeftWait: boolean constructor( maxBytes: number, readonly startedAt: number, private readonly initialForegroundPgid: number | undefined, initialForegroundWasWaiting: boolean, private readonly onCancel: () => void, ) { this.output = new BoundedTextBuffer(maxBytes) this.promise = Promise.withResolvers() this.initialForegroundLeftWait = !initialForegroundWasWaiting } get done(): Promise { return this.promise.promise } append(text: string): void { if (!this.finished) this.output.append(text) } settle(waitReason: PtyWaitReason, sessionStatus: PtySessionStatus, inheritedTruncation: boolean): void { if (this.finished) return this.finished = true const read = this.output.snapshot() this.promise.resolve({ viewport: read.text, waitReason, sessionStatus, truncated: read.truncated || inheritedTruncation, }) } fail(error: unknown): void { if (this.finished) return this.finished = true this.promise.reject(error) } readOutput(): PtySendRead { return this.output.consume() } acceptsStdinWait(pgid: number, waiting: boolean): boolean { // The same group may still expose the wait that existed before terminal.write. // Observe every poll so a departure before the exact-settlement threshold // still makes a later return to that wait post-write evidence. if (pgid !== this.initialForegroundPgid) return waiting if (!waiting) this.initialForegroundLeftWait = true return waiting && this.initialForegroundLeftWait } cancel(): boolean { if (this.finished) return false this.onCancel() return true } } function signalName(number: number | undefined): NodeJS.Signals | null { if (number === undefined || number === 0) return null for (const [name, value] of Object.entries(constants.signals)) { if (value === number) return name as NodeJS.Signals } return null } /** Backend session wrapping one `node-pty` process and its captured process tree. */ export class LocalPtySession implements PtyBackendSession { motd = '' readonly pid: number private readonly sanitizer: TerminalSanitizer private readonly scrollback: BoundedTextBuffer private readonly exitPromise: PromiseWithResolvers = Promise.withResolvers() private readonly dataDisposable: IDisposable private readonly exitDisposable: IDisposable private statusValue: PtySessionStatus = { kind: 'running' } private active: LocalSendOperation | undefined private activeTimer: NodeJS.Timeout | undefined private activeAbort: (() => void) | undefined private promptSeen = false private promptTextSeen = false private shellPgid: number | undefined private initializing = false private lastOutputAt = Date.now() private closing = false private closePromise: Promise | undefined constructor( private readonly terminal: IPty, private readonly inspector: ProcessInspector, private readonly config: ResolvedConfig, ) { this.pid = terminal.pid this.sanitizer = new TerminalSanitizer(config.maxReadBytes) this.scrollback = new BoundedTextBuffer(config.scrollbackMaxBytes, config.scrollbackLines) this.dataDisposable = terminal.onData((data) => { this.onData(data) }) this.exitDisposable = terminal.onExit(({ exitCode, signal }) => { const tail = this.sanitizer.flush() this.appendOutput(tail) this.statusValue = { kind: 'exited', exitCode, signal: signalName(signal) } this.settleActive('session_exit') this.exitPromise.resolve() }) } /** * Capture startup output through the same readiness contract as later sends. * @param signal - optional cancellation while the shell reaches its first prompt. * @returns Resolves after startup readiness; rejects on exit or readiness timeout. */ async initialize(signal?: AbortSignal): Promise { this.initializing = true try { const operation = this.startSend({ text: '', submit: false, ...signal !== undefined ? { signal } : {} }) const result = await operation.done if (result.waitReason === 'session_exit') throw new Error('PTY shell exited during startup') if (result.waitReason === 'timeout') throw new Error('PTY shell did not reach readiness before startup timeout') this.motd = result.viewport } catch (error: unknown) { signal?.throwIfAborted() throw error } finally { this.initializing = false } } startSend(request: PtySendRequest): PtySendOperation { if (this.closing) throw new Error('PTY session is closing') if (this.statusValue.kind === 'exited') throw new Error('PTY session has exited') if (this.active !== undefined) throw new Error('PTY session already has an active send') if (request.signal?.aborted === true) throw new Error('PTY send aborted before write') const initialForegroundPgid = this.inspector.foregroundPgid(this.pid) const initialForegroundWasWaiting = initialForegroundPgid !== undefined && this.inspector.isStdinWaiting(initialForegroundPgid) const operation = new LocalSendOperation( this.config.maxReadBytes, Date.now(), initialForegroundPgid, initialForegroundWasWaiting, () => { this.interrupt(operation) }, ) this.active = operation this.lastOutputAt = Date.now() this.promptSeen = false this.promptTextSeen = false if (request.signal !== undefined) { const onAbort = (): void => { operation.cancel() } request.signal.addEventListener('abort', onAbort, { once: true }) this.activeAbort = () => request.signal?.removeEventListener('abort', onAbort) } try { if (request.text.length > 0) this.terminal.write(request.text) if (request.submit) this.terminal.write('\r') } catch (error: unknown) { this.clearActive() operation.fail(error) return operation } this.activeTimer = setInterval(() => { this.pollReadiness(operation) }, this.config.pollIntervalMs) return operation } read(request: PtyReadRequest): PtyReadResult { const snapshot = this.scrollback.snapshot() const lines = snapshot.text.split('\n') const totalLines = snapshot.text.length === 0 ? 0 : lines.length const offset = request.offset ?? 0 const count = request.count ?? 500 if (!Number.isSafeInteger(offset) || offset < 0) throw new Error('PTY read offset must be a non-negative safe integer') if (!Number.isSafeInteger(count) || count <= 0) throw new Error('PTY read count must be a positive safe integer') if (offset >= totalLines) { return { text: '', totalLines, lineBegin: offset, lineEnd: offset, truncated: snapshot.truncated } } const end = totalLines - offset const start = Math.max(0, end - count) const requested = lines.slice(start, end).join('\n') const bounded = utf8Tail(requested, this.config.maxReadBytes) const returnedLines = bounded.text.length === 0 ? 0 : bounded.text.split('\n').length return { text: bounded.text, totalLines, lineBegin: offset, lineEnd: offset + returnedLines, truncated: snapshot.truncated || bounded.truncated, } } signal(signal: PtySignal): Promise { return Promise.resolve().then(() => { const pgid = this.inspector.foregroundPgid(this.pid) if (pgid === undefined) throw new Error(`cannot resolve foreground process group for PTY ${this.pid}`) if (signal === 'SIGKILL' && pgid === this.pid) { throw new Error('refusing to SIGKILL the PTY shell; use terminal_close') } this.inspector.signalGroup(pgid, signal) return { delivered: true, targetPgid: pgid } }) } status(): PtySessionStatus { return this.statusValue } close(reason: string): Promise { this.closing = true if (this.closePromise !== undefined) return this.closePromise const closing = this.closeOnce(reason).catch((error: unknown) => { this.closePromise = undefined this.failActive(error) throw error }) this.closePromise = closing return closing } private onData(data: string): void { const sanitized = this.sanitizer.push(data) this.appendOutput(sanitized.text) if (sanitized.prompt) { const foregroundPgid = this.inspector.foregroundPgid(this.pid) if (this.shellPgid === undefined) this.shellPgid = foregroundPgid // Bash can print PROMPT_COMMAND before the kernel publishes its return // to the foreground process group. Retain the marker; polling below is // the authority that accepts it only after bash owns the foreground. this.promptSeen = true this.promptTextSeen = sanitized.promptText === true this.lastOutputAt = Date.now() } else if (this.promptSeen && sanitized.promptText === true) { this.promptTextSeen = true } } private appendOutput(text: string): void { if (text.length === 0) return this.lastOutputAt = Date.now() this.scrollback.append(text) this.active?.append(text) } private pollReadiness(operation: LocalSendOperation): void { if (this.active !== operation) return if (this.statusValue.kind === 'exited') { this.settleActive('session_exit') return } if (this.promptSeen && this.promptTextSeen && Date.now() - this.lastOutputAt >= this.config.pollIntervalMs) { const pgid = this.inspector.foregroundPgid(this.pid) if (this.shellPgid !== undefined && pgid === this.shellPgid) { this.settleActive('stdin_read') return } } const elapsed = Date.now() - operation.startedAt const startupHasOutput = !this.initializing || this.scrollback.snapshot().text.length > 0 let acceptsStdinWait = false if (startupHasOutput) { const pgid = this.inspector.foregroundPgid(this.pid) acceptsStdinWait = pgid !== undefined && operation.acceptsStdinWait(pgid, this.inspector.isStdinWaiting(pgid)) } if (elapsed >= this.config.exactProbeAfterMs && acceptsStdinWait) { this.settleActive('stdin_read') return } // A prompt candidate can race bash's foreground handoff, but an interactive // child also inherits PROMPT_COMMAND. Silence therefore remains the bound // on waiting for shell ownership instead of letting a child marker suppress // readiness until the absolute timeout. When a prompt marker was seen, the // configured grace holds the fallback past the silence bound so polls in // that window can observe the foreground handoff and settle as stdin_read. const idleFor = Date.now() - this.lastOutputAt const handoffGrace = this.promptSeen ? this.config.handoffGraceMs : 0 if (startupHasOutput && idleFor >= this.config.idleSilenceMs + handoffGrace) { this.settleActive('inferred_idle') return } if (elapsed >= this.config.timeoutMs) this.settleActive('timeout') } private settleActive(waitReason: PtyWaitReason): void { const operation = this.active if (operation === undefined) return const scrollbackTruncated = this.scrollback.snapshot().truncated this.clearActive() operation.settle(waitReason, this.statusValue, scrollbackTruncated) } private stopPolling(): void { if (this.activeTimer !== undefined) clearInterval(this.activeTimer) this.activeTimer = undefined } private clearActive(): void { this.stopPolling() this.activeAbort?.() this.activeAbort = undefined this.active = undefined } private failActive(error: unknown): void { const operation = this.active if (operation === undefined) return this.clearActive() operation.fail(error) } private interrupt(operation: LocalSendOperation): void { if (this.active !== operation) return try { const pgid = this.inspector.foregroundPgid(this.pid) if (pgid === undefined) throw new Error(`cannot resolve foreground process group for PTY ${this.pid}`) this.inspector.signalGroup(pgid, 'SIGINT') } catch (error: unknown) { this.failActive(error) } } private survivors(members: ProcessIdentity[]): ProcessIdentity[] { return members.filter(member => this.inspector.isAlive(member)) } private descendants(): ProcessIdentity[] { return this.inspector.processTree(this.pid).filter(member => member.pid !== this.pid) } private async waitForExit(members: ProcessIdentity[]): Promise { const deadline = Date.now() + this.config.disposeGraceMs let survivors = this.survivors(members) while (survivors.length > 0 && Date.now() < deadline) { await delay(Math.min(25, Math.max(1, deadline - Date.now()))) survivors = this.survivors(members) } return survivors } private signalMembers(members: ProcessIdentity[], signal: 'SIGTERM' | 'SIGKILL'): void { for (const member of members) { try { this.inspector.signalProcess(member, signal) } catch (_alreadyExitedDuringSignal) { // Identity is rechecked by the inspector; a same-tick exit is success. } } } private unionMembers(...groups: ProcessIdentity[][]): ProcessIdentity[] { const members: ProcessIdentity[] = [] const seen = new Set() for (const group of groups) { for (const member of group) { const key = JSON.stringify([member.pid, member.started]) if (seen.has(key)) continue seen.add(key) members.push(member) } } return members } private async stopDescendants(): Promise { const captured = this.descendants() this.signalMembers(captured, 'SIGTERM') const capturedSurvivors = await this.waitForExit(captured) // A TERM-handling descendant may have forked while winding down. Rescan // while the shell can still reap every member, then kill both the fresh // tree and captured survivors that were reparented out of that tree. const members = this.unionMembers(capturedSurvivors, this.descendants()) this.signalMembers(members, 'SIGKILL') const survivors = await this.waitForExit(members) return this.survivors(this.unionMembers(survivors, this.descendants())) } private async stopShell(): Promise { try { this.terminal.kill('SIGTERM') } catch (_topLevelAlreadyExitedDuringTerm) { // The exit notification remains authoritative. } if (this.statusValue.kind === 'running') { await Promise.race([this.exitPromise.promise, delay(this.config.disposeGraceMs)]) } if (this.statusValue.kind === 'running') { try { this.terminal.kill('SIGKILL') } catch (_topLevelAlreadyExitedDuringKill) { // The exit notification remains authoritative. } await Promise.race([this.exitPromise.promise, delay(this.config.disposeGraceMs)]) } if (this.statusValue.kind === 'running') { throw new Error(`PTY cleanup failed; surviving pids: ${this.pid}`) } } private async closeOnce(reason: string): Promise { this.dataDisposable.dispose() // Stop readiness polling but retain the active operation: teardown settles // it as session_exit below, so an in-flight send is never mis-settled as // stdin_read/inferred_idle/timeout during the grace period. this.stopPolling() const survivors = await this.stopDescendants() if (survivors.length > 0) { throw new Error(`PTY cleanup failed (${reason}); surviving pids: ${survivors.map(member => member.pid).join(', ')}`) } await this.stopShell() this.settleActive('session_exit') this.exitDisposable.dispose() } }