refactor: apply repository naming contract

Apply the accepted pre-release package, service, type, directory, and role renames as one repository-wide change.
This commit is contained in:
Tianyi Cui
2026-08-13 00:36:22 +08:00
parent 101df7cf58
commit a2d0f7f411
3281 changed files with 21730 additions and 21592 deletions

View File

@@ -0,0 +1,81 @@
/** Validated configuration for the local PTY backend. */
import z from '@deepseek-ai/schemastery'
/** Public plugin configuration. */
export interface Config {
/** Backend registry type (default: `shell`). */
backendType?: string
/** Interactive shell executable (default: `/bin/bash`). */
shellPath?: string
/** Shell arguments (default: `--noprofile --norc -i`). */
shellArgs?: string[]
/** Terminal rows. */
rows?: number
/** Terminal columns. */
cols?: number
/** Maximum retained logical lines. */
scrollbackLines?: number
/** Maximum retained UTF-8 bytes. */
scrollbackMaxBytes?: number
/** Maximum bytes returned by one read or settled viewport. */
maxReadBytes?: number
/** Readiness polling interval. */
pollIntervalMs?: number
/** Delay before Linux exact syscall probes. */
exactProbeAfterMs?: number
/** Silence duration that yields `inferred_idle`. */
idleSilenceMs?: number
/**
* Extra wait beyond `idleSilenceMs`, once a prompt marker was seen, for the shell to
* regain the foreground before `inferred_idle` settles; at least one `pollIntervalMs`.
*/
handoffGraceMs?: number
/** Absolute send wait bound. */
timeoutMs?: number
/** Grace before teardown escalates to `SIGKILL`. */
disposeGraceMs?: number
}
/** Configuration after Schemastery defaults. */
export type ResolvedConfig = Required<Config>
/** Schemastery config exposed by the plugin. */
export const Config: z<Config> = z.object({
backendType: z.string().default('shell'),
shellPath: z.string().default('/bin/bash'),
shellArgs: z.array(z.string()).default(['--noprofile', '--norc', '-i']),
rows: z.number().default(40),
cols: z.number().default(160),
scrollbackLines: z.number().default(10_000),
scrollbackMaxBytes: z.number().default(4 * 1024 * 1024),
maxReadBytes: z.number().default(256 * 1024),
pollIntervalMs: z.number().default(50),
exactProbeAfterMs: z.number().default(150),
idleSilenceMs: z.number().default(3_000),
handoffGraceMs: z.number().default(500),
timeoutMs: z.number().default(30_000),
disposeGraceMs: z.number().default(3_000),
})
/**
* Assert every numeric config field is a positive safe integer and bounds compose.
* @param config - Schemastery-resolved plugin configuration.
* @returns Narrows the input to the fully resolved configuration.
*/
export function validateConfig(config: Config): asserts config is ResolvedConfig {
const resolved = config as ResolvedConfig
if (resolved.backendType.length === 0) throw new Error('terminal-bash: backendType must be non-empty')
if (resolved.shellPath.length === 0) throw new Error('terminal-bash: shellPath must be non-empty')
for (const [name, value] of Object.entries(resolved)) {
if (typeof value === 'number' && (!Number.isSafeInteger(value) || value <= 0)) {
throw new Error(`terminal-bash: ${name} must be a positive safe integer`)
}
}
if (resolved.maxReadBytes > resolved.scrollbackMaxBytes) {
throw new Error('terminal-bash: maxReadBytes must not exceed scrollbackMaxBytes')
}
if (resolved.handoffGraceMs < resolved.pollIntervalMs) {
throw new Error('terminal-bash: handoffGraceMs must be at least pollIntervalMs so one readiness poll runs inside the grace window')
}
}

View File

@@ -0,0 +1,153 @@
/**
* Persistent shell PTY backend over the subprocess terminal primitive, shared
* sandbox policy, bounded output, and provider-owned session cleanup.
* @module @deepseek-ai/dsh-terminal-bash
*/
import { Context } from '@deepseek-ai/cordis'
import type { Agent } from '@deepseek-ai/dsh-agent'
import type { Session, SessionEvent } from '@deepseek-ai/dsh-session'
import { TerminalBackendCleanupError } from '@deepseek-ai/dsh-terminal'
import type { TerminalBackend, TerminalBackendSpawnSpec } from '@deepseek-ai/dsh-terminal'
import type { SubprocessTerminalHandle, SubprocessTerminalSpawnSpec } from '@deepseek-ai/dsh-subprocess'
import type { SandboxExecutionPolicy } from '@deepseek-ai/dsh-sandbox'
import { effectiveSandboxMode } from '@deepseek-ai/dsh-sandbox-policy'
import { type Config, type ResolvedConfig, validateConfig } from './config.ts'
import { LocalPtySession } from './session.ts'
import { CONTROLLED_PROMPT } from './sanitize.ts'
export { Config } from './config.ts'
export type { Config as TerminalLocalConfig } from './config.ts'
/** Cordis plugin name. */
export const name = 'terminal-bash'
/** Required services: PTY registry, shared confinement policy, and process substrate. */
export const inject = ['terminals', 'sandboxPolicy', 'subprocess']
interface SandboxModeFenceState {
pty: Context['terminals']
sandboxPolicy: Context['sandboxPolicy']
}
const sandboxModeFences = new WeakMap<Agent, SandboxModeFenceState>()
function ensureSandboxModeFence(ctx: Context, owner: Agent): void {
const existing = sandboxModeFences.get(owner)
if (existing !== undefined) {
existing.pty = ctx.terminals
existing.sandboxPolicy = ctx.sandboxPolicy
return
}
const state: SandboxModeFenceState = { pty: ctx.terminals, sandboxPolicy: ctx.sandboxPolicy }
sandboxModeFences.set(owner, state)
owner.ctx.on('internal/dispatch', (_mode, eventName, args) => {
if (eventName !== 'session/event') return
const [session, event] = args as [Session, SessionEvent]
if (session !== owner.session || event.type !== 'sandbox/mode') return
const currentMode = effectiveSandboxMode(session.events) ?? state.sandboxPolicy.defaultMode
if (event.data.mode === currentMode || !state.pty.hasOwnerActivity(owner)) return
throw new Error(
`cannot change sandbox mode from "${currentMode}" to "${event.data.mode}" while persistent terminal sessions are open or being created; wait for creation to settle and close them first`,
)
}, { global: true })
}
function childEnvironment(spec: TerminalBackendSpawnSpec): Record<string, string> {
// The subprocess provider supplies its own scrubbed ambient base; these are
// deliberate terminal-specific overrides layered after it.
return {
TERM: 'dumb',
PAGER: 'cat',
GIT_PAGER: 'cat',
PS1: CONTROLLED_PROMPT,
PROMPT_COMMAND: 'printf "\\033]133;D;%s\\007" "$?"',
BASH_SILENCE_DEPRECATION_WARNING: '1',
DSH_SHELL: '1',
DSH_SESSION_ID: spec.owner.id,
DSH_PTY_SESSION_ID: spec.sessionId,
}
}
function spawnArgv(ctx: Context, config: ResolvedConfig, policy: SandboxExecutionPolicy): string[] {
const argv = [config.shellPath, ...config.shellArgs]
if (policy.mode === 'danger-full-access') return argv
const sandbox = ctx.get('sandbox')
if (sandbox === undefined) {
throw new Error(`terminal-bash: sandbox mode "${policy.mode}" requires a ctx.sandbox provider in the execution world`)
}
// Re-state the discriminant because object spread does not preserve its narrowed type.
return sandbox.confine(argv, { ...policy, mode: policy.mode }).argv
}
// TODO(pty-initialize-race-home): Fold this outer abort race into
// LocalPtySession.initialize when the send-state consolidation lands; the
// session already owns the send lifecycle the race protects.
async function initializeSession(session: LocalPtySession, signal?: AbortSignal): Promise<void> {
if (signal === undefined) {
await session.initialize(signal)
return
}
const aborted = Promise.withResolvers<never>()
const onAbort = (): void => { aborted.reject(signal.reason) }
signal.addEventListener('abort', onAbort, { once: true })
try {
signal.throwIfAborted()
await Promise.race([session.initialize(signal), aborted.promise])
} finally {
signal.removeEventListener('abort', onAbort)
}
}
/** Local shell backend registered under the configured type. */
export class BashTerminalBackend implements TerminalBackend {
readonly type: string
constructor(
private readonly ctx: Context,
private readonly config: ResolvedConfig,
private readonly spawnTerminal: (
spec: SubprocessTerminalSpawnSpec,
) => Promise<SubprocessTerminalHandle> = spec => ctx.subprocess.spawnTerminal(spec),
private readonly createSession: (
terminal: SubprocessTerminalHandle,
config: ResolvedConfig,
) => LocalPtySession = (terminal, config) => new LocalPtySession(terminal, config),
) {
this.type = config.backendType
}
async spawn(spec: TerminalBackendSpawnSpec): Promise<LocalPtySession> {
spec.signal?.throwIfAborted()
ensureSandboxModeFence(this.ctx, spec.owner)
const policy = this.ctx.sandboxPolicy.resolve({ session: spec.owner.session })
const argv = spawnArgv(this.ctx, this.config, policy)
if (argv[0] === undefined) throw new Error('terminal-bash: sandbox returned empty argv')
const terminal = await this.spawnTerminal({
argv,
cwd: spec.cwd ?? policy.workspaceRoot,
env: childEnvironment(spec),
rows: this.config.rows,
cols: this.config.cols,
graceMs: this.config.disposeGraceMs,
signal: spec.signal,
})
const session = this.createSession(terminal, this.config)
try {
await initializeSession(session, spec.signal)
return session
} catch (error) {
try {
await session.close('PTY startup failed')
} catch (closeError: unknown) {
throw new TerminalBackendCleanupError(error, closeError)
}
throw error
}
}
}
/** Register the local PTY backend. */
export function apply(ctx: Context, config: Config): void {
validateConfig(config)
ctx.terminals.registerBackend(new BashTerminalBackend(ctx, config))
}

View File

@@ -0,0 +1,30 @@
/**
* Package-owned invariant companion for `@deepseek-ai/dsh-terminal-bash`.
* @module @deepseek-ai/dsh-terminal-bash/invariant
*/
/* jscpd:ignore-start */
import type { Context } from '@deepseek-ai/cordis'
import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants'
const PACKAGE_NAME = '@deepseek-ai/dsh-terminal-bash'
/** Cordis companion plugin name. */
export const name = 'terminal-bash-invariant'
/** Service required before the companion can reserve package ownership. */
export const inject = ['invariants']
/**
* No runtime invariant: readiness, terminal buffers, and process-tree state are private per-session
* implementation state, and the backend publishes no independent lifecycle stream or snapshot.
*/
const install: InvariantInstaller = () => {}
/**
* Register this package's invariant companion.
* @param ctx - Cordis context carrying the invariant service.
* @returns the installed registration's disposer after setup succeeds.
*/
export const apply = (ctx: Context): Promise<() => void> =>
Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install))
/* jscpd:ignore-end */

View File

@@ -0,0 +1,188 @@
/** Streaming terminal-control sanitizer for the line-oriented first release. */
import { Buffer } from 'node:buffer'
/** OSC marker emitted by the controlled bash before each prompt. */
export const PROMPT_MARKER_PREFIX = '133;D;'
/** Exact printable prompt emitted after the private marker. */
export const CONTROLLED_PROMPT = 'dsh> '
/** One sanitized chunk plus whether it contained the owned prompt marker. */
export interface SanitizedChunk {
text: string
prompt: boolean
/** Printable text after the latest owned marker in this chunk. */
promptTail?: string
}
/**
* Remove CSI/OSC/short escape sequences while preserving split-sequence carry.
* Full terminal emulation is deliberately deferred; ordinary line output and
* the private prompt marker are the supported contract.
*/
export class TerminalSanitizer {
private pending = ''
private discardMode: 'osc' | 'csi' | undefined
private discardOscEscape = false
private trailingCarriageReturn = false
private trackingPromptTail = false
constructor(private readonly maxPendingBytes: number) {}
/**
* Consume one decoded `node-pty` data chunk.
* @param chunk - decoded terminal data.
* @returns Printable text and whether the private prompt marker completed.
*/
push(chunk: string): SanitizedChunk {
this.pending += this.discardPrefix(chunk)
let text = ''
let prompt = false
let includePromptTail = this.trackingPromptTail
let promptTail = ''
let index = 0
const appendText = (value: string): void => {
text += value
if (this.trackingPromptTail) promptTail += value
}
while (index < this.pending.length) {
const escape = this.pending.indexOf('\x1b', index)
if (escape < 0) {
appendText(this.pending.slice(index))
index = this.pending.length
break
}
appendText(this.pending.slice(index, escape))
if (escape + 1 >= this.pending.length) {
index = escape
break
}
const kind = this.pending[escape + 1]
if (kind === ']') {
const bel = this.pending.indexOf('\x07', escape + 2)
const stringTerminator = this.pending.indexOf('\x1b\\', escape + 2)
let end = -1
if (bel >= 0 && stringTerminator >= 0) end = Math.min(bel + 1, stringTerminator + 2)
else if (bel >= 0) end = bel + 1
else if (stringTerminator >= 0) end = stringTerminator + 2
if (end < 0) {
index = escape
break
}
const terminatorBytes = this.pending[end - 1] === '\x07' ? 1 : 2
const content = this.pending.slice(escape + 2, end - terminatorBytes)
if (content.startsWith(PROMPT_MARKER_PREFIX)) {
prompt = true
this.trackingPromptTail = true
includePromptTail = true
promptTail = ''
}
index = end
continue
}
if (kind === '[') {
let end = escape + 2
while (end < this.pending.length) {
const code = this.pending.charCodeAt(end)
if (code >= 0x40 && code <= 0x7e) break
end += 1
}
if (end >= this.pending.length) {
index = escape
break
}
index = end + 1
continue
}
// Two-byte escape family (save/restore cursor and similar).
index = escape + 2
}
this.pending = this.pending.slice(index)
this.enforcePendingBound()
return {
text: this.normalizeText(text),
prompt,
...includePromptTail ? { promptTail } : {},
}
}
/**
* Flush a trailing printable fragment when the PTY exits.
* @returns Remaining printable text; incomplete escapes are discarded.
*/
flush(): string {
const text = this.pending.startsWith('\x1b') ? '' : this.pending
this.pending = ''
this.discardMode = undefined
this.discardOscEscape = false
this.trackingPromptTail = false
const normalized = this.normalizeText(text)
if (!this.trailingCarriageReturn) return normalized
this.trailingCarriageReturn = false
return `${normalized}\n`
}
private normalizeText(text: string): string {
let complete = this.trailingCarriageReturn ? `\r${text}` : text
this.trailingCarriageReturn = false
if (complete.endsWith('\r')) {
complete = complete.slice(0, -1)
this.trailingCarriageReturn = true
}
return normalizeTerminalText(complete)
}
private enforcePendingBound(): void {
if (Buffer.byteLength(this.pending) <= this.maxPendingBytes) return
this.discardMode = this.pending[1] === ']' ? 'osc' : 'csi'
this.pending = ''
}
private discardPrefix(chunk: string): string {
if (this.discardMode === undefined) return chunk
if (this.discardMode === 'csi') {
for (let index = 0; index < chunk.length; index += 1) {
const code = chunk.charCodeAt(index)
if (code >= 0x40 && code <= 0x7e) {
this.discardMode = undefined
return chunk.slice(index + 1)
}
}
return ''
}
let index = 0
if (this.discardOscEscape) {
this.discardOscEscape = false
if (chunk.startsWith('\\')) {
this.discardMode = undefined
return chunk.slice(1)
}
}
while (index < chunk.length) {
if (chunk[index] === '\x07') {
this.discardMode = undefined
return chunk.slice(index + 1)
}
if (chunk[index] === '\x1b') {
if (chunk[index + 1] === '\\') {
this.discardMode = undefined
return chunk.slice(index + 2)
}
if (index + 1 === chunk.length) this.discardOscEscape = true
}
index += 1
}
return ''
}
}
/**
* Normalize CRLF and standalone carriage returns for line-oriented rendering.
* @param text - sanitized terminal text.
* @returns Line-normalized text with BEL removed.
*/
export function normalizeTerminalText(text: string): string {
return text.replaceAll('\r\n', '\n').replaceAll('\r', '\n').replaceAll('\x07', '')
}

View File

@@ -0,0 +1,565 @@
/** Persistent PTY session over the subprocess seam's terminal primitive. */
import { Buffer } from 'node:buffer'
import type {
SubprocessOutcome,
SubprocessTerminalForeground,
SubprocessTerminalHandle,
} from '@deepseek-ai/dsh-subprocess'
import { TerminalError } from '@deepseek-ai/dsh-terminal'
import type {
TerminalBackendSession,
TerminalReadRequest,
TerminalReadResult,
TerminalSendOperation,
TerminalSendRead,
TerminalSendRequest,
TerminalSendResult,
TerminalSessionStatus,
TerminalSignal,
TerminalSignalResult,
TerminalWaitReason,
} from '@deepseek-ai/dsh-terminal'
import type { ResolvedConfig } from './config.ts'
import { CONTROLLED_PROMPT, TerminalSanitizer } from './sanitize.ts'
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(): TerminalSendRead {
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 TerminalSendOperation {
private readonly output: BoundedTextBuffer
private readonly promise: PromiseWithResolvers<TerminalSendResult>
private finished = false
private cancellationRequested = false
private initialForegroundLeftWait: boolean
private initialForegroundPgid: number | undefined
constructor(
maxBytes: number,
readonly startedAt: number,
private readonly onCancel: () => void,
) {
this.output = new BoundedTextBuffer(maxBytes)
this.promise = Promise.withResolvers<TerminalSendResult>()
this.initialForegroundLeftWait = true
}
get done(): Promise<TerminalSendResult> {
return this.promise.promise
}
get settled(): boolean {
return this.finished
}
get cancelRequested(): boolean {
return this.cancellationRequested
}
append(text: string): void {
if (!this.finished) this.output.append(text)
}
settle(waitReason: TerminalWaitReason, sessionStatus: TerminalSessionStatus, 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(): TerminalSendRead {
return this.output.consume()
}
setInitialForeground(foreground: SubprocessTerminalForeground | undefined): void {
this.initialForegroundPgid = foreground?.processGroupId
this.initialForegroundLeftWait = foreground?.inputWaiting !== true
}
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.cancellationRequested = true
this.onCancel()
return true
}
}
/** Backend session wrapping one provider-owned terminal process. */
export class LocalPtySession implements TerminalBackendSession {
motd = ''
readonly pid: number
private readonly decoder = new TextDecoder()
private readonly sanitizer: TerminalSanitizer
private readonly scrollback: BoundedTextBuffer
private readonly outputEnded = Promise.withResolvers<void>()
private readonly completion: Promise<void>
private statusValue: TerminalSessionStatus = { kind: 'running' }
// TODO(pty-send-state-consolidation): Fold the per-send fields below
// (active/activeTimer/activeDeadlineTimer/activeAbort/interrupting/
// activeWrite/pollingReady/polling) into one send-lifecycle owner; the
// cancellation/readiness interplay now has enough pinned tests to carry
// that refactor safely.
private active: LocalSendOperation | undefined
private activeTimer: NodeJS.Timeout | undefined
private activeDeadlineTimer: NodeJS.Timeout | undefined
private activeAbort: (() => void) | undefined
private interrupting: LocalSendOperation | undefined
private activeWrite: Promise<boolean> | undefined
private pollingReady: LocalSendOperation | undefined
private polling = false
private promptSeen = false
private promptTextSeen = false
private promptTail = ''
private shellPgid: number | undefined
private initializing = false
private lastOutputAt = Date.now()
private closing = false
private closePromise: Promise<void> | undefined
private transportFailure: Error | undefined
constructor(
private readonly terminal: SubprocessTerminalHandle,
private readonly config: ResolvedConfig,
) {
this.pid = terminal.pid
this.sanitizer = new TerminalSanitizer(config.maxReadBytes)
this.scrollback = new BoundedTextBuffer(config.scrollbackMaxBytes, config.scrollbackLines)
terminal.output.on('data', this.onTerminalData)
terminal.output.once('end', this.onTerminalEnd)
terminal.output.once('error', this.onTerminalError)
this.completion = terminal.done.then(
outcome => this.onExit(outcome),
(error: unknown) => { this.onTransportFailure(error) },
)
}
/**
* 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<void> {
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: TerminalSendRequest): TerminalSendOperation {
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) {
const draining = this.activeWrite !== undefined
? ' or draining provider write'
: this.interrupting !== undefined
? ' or draining foreground interrupt'
: ''
throw new TerminalError(`PTY session already has an active send${draining}`, 'SEND_ACTIVE')
}
if (request.signal?.aborted === true) throw new Error('PTY send aborted before write')
const operation = new LocalSendOperation(
this.config.maxReadBytes,
Date.now(),
() => { this.interrupt(operation) },
)
this.active = operation
this.resetReadinessEvidence()
if (request.signal !== undefined) {
const onAbort = (): void => { operation.cancel() }
request.signal.addEventListener('abort', onAbort, { once: true })
this.activeAbort = () => request.signal?.removeEventListener('abort', onAbort)
}
this.activeDeadlineTimer = setTimeout(() => {
if (this.active === operation) {
this.settleActive('timeout', this.activeWrite !== undefined || this.interrupting === operation)
}
}, this.config.timeoutMs)
void this.beginSend(operation, request)
return operation
}
private async beginSend(operation: LocalSendOperation, request: TerminalSendRequest): Promise<void> {
let foreground: SubprocessTerminalForeground | undefined
try {
foreground = await this.terminal.inspectForeground()
} catch (error: unknown) {
// A pre-write inspection failure while cancellation owns the slot must not
// release it: interruptOnce's in-flight foreground signal could land on a
// successor's foreground group. The interrupt path's post-signal tail
// resumes polling, whose guarded catch propagates a persistent failure.
// A retained settled operation implies that same in-flight interrupt, so
// this guard admits only an unsettled active send.
if (this.active === operation && !this.closing && this.interrupting !== operation) {
this.failActive(error)
}
return
}
try {
if (this.active !== operation || this.closing || this.interrupting === operation) return
operation.setInitialForeground(foreground)
const input = `${request.text}${request.submit ? '\r' : ''}`
if (input.length > 0 && !operation.cancelRequested) {
this.resetReadinessEvidence()
const write = this.terminal.write(input)
this.activeWrite = write.then(() => true, () => false)
try {
await write
} finally {
this.activeWrite = undefined
}
}
// Cancellation owns post-write signalling and reservation release.
if (operation.cancelRequested) return
if (this.active === operation && operation.settled) {
this.clearActive()
return
}
// Closing can race the awaited provider write even though static analysis sees only local assignments.
// oxlint-disable-next-line typescript/no-unnecessary-condition -- awaited provider writes can close the session.
if (this.active === operation && !this.closing) {
this.pollingReady = operation
this.schedulePoll(operation)
}
} catch (error: unknown) {
if (this.active === operation && !this.closing) {
if (operation.settled) this.clearActive()
else this.failActive(error)
}
}
}
private resetReadinessEvidence(): void {
this.lastOutputAt = Date.now()
this.promptSeen = false
this.promptTextSeen = false
this.promptTail = ''
}
read(request: TerminalReadRequest): TerminalReadResult {
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,
}
}
async signal(signal: TerminalSignal): Promise<TerminalSignalResult> {
if (this.closing) throw new Error('PTY session is closing')
const targetPgid = await this.terminal.signalForeground(signal)
return { delivered: true, targetPgid }
}
status(): TerminalSessionStatus {
return this.statusValue
}
close(reason: string): Promise<void> {
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 readonly onTerminalData = (chunk: Buffer | Uint8Array | string): void => {
const bytes = typeof chunk === 'string' ? Buffer.from(chunk, 'utf8') : chunk
this.onData(this.decoder.decode(bytes, { stream: true }))
}
private readonly onTerminalEnd = (): void => {
this.onData(this.decoder.decode())
this.appendOutput(this.sanitizer.flush())
this.outputEnded.resolve()
}
private readonly onTerminalError = (error: Error): void => {
this.onTransportFailure(error)
this.outputEnded.resolve()
}
private onData(data: string): void {
const sanitized = this.sanitizer.push(data)
this.appendOutput(sanitized.text)
if (sanitized.prompt) {
// TODO(pty-delayed-signal-prompt): With a reproducer, define a marker-generation boundary
// before attributing a signal-delayed prompt to a later send.
// 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.promptTail = ''
this.lastOutputAt = Date.now()
}
if (this.promptSeen && sanitized.promptTail !== undefined) {
const remaining = Math.max(0, CONTROLLED_PROMPT.length + 1 - this.promptTail.length)
this.promptTail += sanitized.promptTail.slice(0, remaining)
if (sanitized.promptTail.length > remaining) this.promptTail = `${CONTROLLED_PROMPT}\0`
this.promptTextSeen = this.promptTail === CONTROLLED_PROMPT
}
}
private async onExit(outcome: SubprocessOutcome): Promise<void> {
await this.outputEnded.promise
if (this.transportFailure !== undefined) return
this.statusValue = { kind: 'exited', exitCode: outcome.exitCode, signal: outcome.signal }
this.settleActive('session_exit')
}
private onTransportFailure(error: unknown): void {
const failure = error instanceof Error ? error : new Error(String(error))
this.transportFailure ??= failure
this.statusValue = { kind: 'exited', exitCode: null, signal: null }
this.failActive(failure)
void this.terminal.terminate().catch(() => {})
}
private appendOutput(text: string): void {
if (text.length === 0) return
this.lastOutputAt = Date.now()
this.scrollback.append(text)
this.active?.append(text)
}
private schedulePoll(operation: LocalSendOperation, delayMs = this.config.pollIntervalMs): void {
if (this.active !== operation || this.interrupting === operation || this.polling) return
if (this.activeTimer !== undefined) clearTimeout(this.activeTimer)
this.activeTimer = setTimeout(() => {
this.activeTimer = undefined
void this.pollReadiness(operation)
}, delayMs)
}
private async pollReadiness(operation: LocalSendOperation): Promise<void> {
if (this.active !== operation || this.polling) return
this.polling = true
try {
if (this.statusValue.kind === 'exited') {
this.settleActive('session_exit')
return
}
const foreground = await this.terminal.inspectForeground()
if (this.active !== operation || this.closing || this.interrupting === operation) return
const idleFor = Date.now() - this.lastOutputAt
if (this.promptSeen && foreground !== undefined && this.shellPgid === undefined) {
this.shellPgid = foreground.processGroupId
}
if (this.promptSeen && this.promptTextSeen && idleFor >= this.config.pollIntervalMs
&& foreground?.processGroupId === this.shellPgid) {
this.settleActive('stdin_read')
return
}
const elapsed = Date.now() - operation.startedAt
const startupHasOutput = !this.initializing || this.scrollback.snapshot().text.length > 0
const acceptsStdinWait = startupHasOutput && foreground !== undefined
&& operation.acceptsStdinWait(foreground.processGroupId, foreground.inputWaiting)
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.
const handoffGrace = this.promptSeen ? this.config.handoffGraceMs : 0
if (startupHasOutput && idleFor >= this.config.idleSilenceMs + handoffGrace) {
this.settleActive('inferred_idle')
}
} catch (error: unknown) {
if (this.active === operation && !this.closing && this.interrupting !== operation) this.failActive(error)
} finally {
this.polling = false
const active = this.active
// Awaited provider inspection can clear or replace the active send despite static analysis.
// oxlint-disable-next-line typescript/no-unnecessary-condition -- awaited inspection can replace the active send.
if (active !== undefined && this.pollingReady === active) this.schedulePoll(active)
}
}
private settleActive(waitReason: TerminalWaitReason, retainOwnership = false): void {
const operation = this.active
if (operation === undefined) return
const scrollbackTruncated = this.scrollback.snapshot().truncated
if (retainOwnership) {
this.stopPolling()
this.activeAbort?.()
this.activeAbort = undefined
} else {
this.clearActive()
}
operation.settle(waitReason, this.statusValue, scrollbackTruncated)
}
private stopPolling(): void {
this.stopReadinessPolling()
if (this.activeDeadlineTimer !== undefined) clearTimeout(this.activeDeadlineTimer)
this.activeDeadlineTimer = undefined
}
private stopReadinessPolling(): void {
if (this.activeTimer !== undefined) clearTimeout(this.activeTimer)
this.activeTimer = undefined
this.pollingReady = undefined
}
private clearActive(): void {
const operation = this.active
this.stopPolling()
this.activeAbort?.()
this.activeAbort = undefined
if (this.interrupting === operation) this.interrupting = undefined
this.pollingReady = 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
this.interrupting = operation
this.stopReadinessPolling()
void this.interruptOnce(operation)
}
private async interruptOnce(operation: LocalSendOperation): Promise<void> {
try {
const activeWrite = this.activeWrite
if (activeWrite !== undefined && !await activeWrite) return
await this.terminal.signalForeground('SIGINT')
} catch (error: unknown) {
if (this.active === operation && !this.closing) this.onTransportFailure(error)
return
} finally {
if (this.interrupting === operation) this.interrupting = undefined
}
if (this.active === operation && operation.settled) {
this.clearActive()
} else if (this.active === operation && !this.closing) {
this.pollingReady = operation
this.schedulePoll(operation, 0)
}
}
private async closeOnce(reason: string): Promise<void> {
// 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()
try {
await this.terminal.terminate()
} catch (error: unknown) {
throw new Error(`PTY cleanup failed (${reason})`, { cause: error })
}
// Quiescence is the active send's terminal outcome.
this.settleActive('session_exit')
await this.completion
this.terminal.output.off('data', this.onTerminalData)
this.terminal.output.off('end', this.onTerminalEnd)
this.terminal.output.off('error', this.onTerminalError)
if (this.transportFailure !== undefined) throw this.transportFailure
}
}