Rename the six model-facing tools pty_* -> terminal_* and align every description, guidance section, ACP card title, and rendered result to terminal terminology. Package and service internals keep their technical PTY names (PtyService, "unknown PTY session", node-pty). Harden the local backend teardown: - a failed close is retryable: drop the memoized rejection so a later terminal_close re-runs against the live process table - service disposal clears the backend, reservation, and owner-cleanup registries even when a close fails - stop readiness polling before teardown so an in-flight send settles as session_exit instead of a mis-inferred wait reason - bound the sanitizer's pending buffer against unterminated escape runs Update the tool catalog, package READMEs, the bilingual Agent Note, and the acp/headless pty-tools snapshots to match.
363 lines
12 KiB
TypeScript
363 lines
12 KiB
TypeScript
/**
|
|
* Owner-scoped persistent PTY registry. Backends own terminal mechanics while
|
|
* this service owns ids, publication, authorization, and awaited cleanup.
|
|
* @module @deepseek-ai/dsh-pty
|
|
*/
|
|
|
|
import { Context, Service } from 'cordis'
|
|
import type { Agent } from '@deepseek-ai/dsh-agent'
|
|
import type {
|
|
PtyBackend,
|
|
PtyBackendSession,
|
|
PtyReadRequest,
|
|
PtyReadResult,
|
|
PtySendOperation,
|
|
PtySendRequest,
|
|
PtySessionIdValue,
|
|
PtySessionSnapshot,
|
|
PtySignal,
|
|
PtySignalResult,
|
|
PtySpawnRequest,
|
|
PtySpawnResult,
|
|
} from './types.ts'
|
|
|
|
export type {
|
|
PtyBackend,
|
|
PtyBackendSession,
|
|
PtyBackendSpawnSpec,
|
|
PtyReadRequest,
|
|
PtyReadResult,
|
|
PtySendOperation,
|
|
PtySendRead,
|
|
PtySendRequest,
|
|
PtySendResult,
|
|
PtySessionSnapshot,
|
|
PtySessionStatus,
|
|
PtySignal,
|
|
PtySignalResult,
|
|
PtySpawnRequest,
|
|
PtySpawnResult,
|
|
PtyWaitReason,
|
|
} from './types.ts'
|
|
|
|
/** Opaque identity minted by {@link PtyService} for one live PTY session. */
|
|
export type PtySessionId = PtySessionIdValue
|
|
|
|
declare module 'cordis' {
|
|
interface Context {
|
|
pty: PtyService
|
|
}
|
|
}
|
|
|
|
/** Machine-routable PTY service failures. */
|
|
export type PtyErrorCode =
|
|
| 'DUPLICATE_BACKEND'
|
|
| 'DUPLICATE_NAME'
|
|
| 'FOREIGN_SESSION'
|
|
| 'NO_BACKEND'
|
|
| 'NO_SESSION'
|
|
| 'OWNER_NOT_LIVE'
|
|
| 'SEND_ACTIVE'
|
|
| 'SERVICE_DISPOSING'
|
|
|
|
/** Error carrying a stable {@link PtyErrorCode}. */
|
|
export class PtyError extends Error {
|
|
constructor(message: string, readonly code: PtyErrorCode) {
|
|
super(message)
|
|
this.name = 'PtyError'
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Brand one registry-minted string as a {@link PtySessionId}.
|
|
* @param value - raw registry-issued id.
|
|
* @returns Same string with the PTY session brand.
|
|
*/
|
|
export function PtySessionId(value: string): PtySessionId {
|
|
return value as PtySessionId
|
|
}
|
|
|
|
function isAborted(signal: AbortSignal | undefined): boolean {
|
|
return signal?.aborted === true
|
|
}
|
|
|
|
interface SessionRecord {
|
|
readonly id: PtySessionId
|
|
readonly owner: Agent
|
|
readonly name: string | undefined
|
|
readonly type: string
|
|
readonly session: PtyBackendSession
|
|
active: PtySendOperation | undefined
|
|
closing: Promise<void> | undefined
|
|
}
|
|
|
|
/** In-process registry for replaceable PTY backends and exact-Agent sessions. */
|
|
export class PtyService extends Service {
|
|
private readonly backends = new Map<string, PtyBackend>()
|
|
private readonly sessions = new Map<PtySessionId, SessionRecord>()
|
|
private readonly reservedNames = new Map<Agent, Set<string>>()
|
|
private readonly ownerCleanups = new Map<Agent, () => Promise<void> | void>()
|
|
private readonly disposedOwners = new WeakSet<Agent>()
|
|
private nextId = 0
|
|
private disposing = false
|
|
|
|
constructor(ctx: Context) {
|
|
super(ctx, 'pty')
|
|
ctx.effect(() => () => this.disposeAll(), 'pty teardown')
|
|
}
|
|
|
|
/**
|
|
* Register one backend type for this effect scope.
|
|
* @param backend - provider with a non-empty unique type.
|
|
* @returns disposer that removes exactly this contribution.
|
|
*/
|
|
registerBackend(backend: PtyBackend): () => void {
|
|
if (backend.type.length === 0) throw new Error('pty backend type must be non-empty')
|
|
if (this.backends.has(backend.type)) {
|
|
throw new PtyError(`a PTY backend named "${backend.type}" is already registered`, 'DUPLICATE_BACKEND')
|
|
}
|
|
const dispose = this.ctx.effect(() => {
|
|
this.backends.set(backend.type, backend)
|
|
return () => {
|
|
if (this.backends.get(backend.type) === backend) this.backends.delete(backend.type)
|
|
}
|
|
}, 'pty.registerBackend()')
|
|
return () => void dispose()
|
|
}
|
|
|
|
/**
|
|
* List registered backend types in registration order.
|
|
* @returns fresh backend type names.
|
|
*/
|
|
listBackends(): string[] {
|
|
return [...this.backends.keys()]
|
|
}
|
|
|
|
/**
|
|
* Create and publish one owner-scoped session after backend setup succeeds.
|
|
* @param owner - exact registered Agent that owns access and cleanup.
|
|
* @param request - backend type plus optional owner-local name and cwd.
|
|
* @param signal - cancellation of unpublished setup.
|
|
* @returns published identity, metadata, status, and MOTD.
|
|
*/
|
|
async spawn(owner: Agent, request: PtySpawnRequest, signal?: AbortSignal): Promise<PtySpawnResult> {
|
|
this.assertActive()
|
|
this.ensureOwnerCleanup(owner)
|
|
const backend = this.backends.get(request.type)
|
|
if (backend === undefined) throw new PtyError(`no PTY backend registered for "${request.type}"`, 'NO_BACKEND')
|
|
if (request.name !== undefined && request.name.length === 0) throw new Error('PTY session name must be non-empty')
|
|
if (isAborted(signal)) throw new Error('PTY spawn aborted')
|
|
|
|
const releaseName = this.reserveName(owner, request.name)
|
|
const sessionId = PtySessionId(`pty-${++this.nextId}`)
|
|
let session: PtyBackendSession | undefined
|
|
try {
|
|
session = await backend.spawn({
|
|
sessionId,
|
|
owner,
|
|
type: request.type,
|
|
...request.name !== undefined ? { name: request.name } : {},
|
|
...request.cwd !== undefined ? { cwd: request.cwd } : {},
|
|
...signal !== undefined ? { signal } : {},
|
|
})
|
|
if (this.disposing || isAborted(signal) || !this.isLiveOwner(owner)) {
|
|
throw new PtyError('PTY owner is no longer live', 'OWNER_NOT_LIVE')
|
|
}
|
|
const record: SessionRecord = {
|
|
id: sessionId,
|
|
owner,
|
|
name: request.name,
|
|
type: request.type,
|
|
session,
|
|
active: undefined,
|
|
closing: undefined,
|
|
}
|
|
this.sessions.set(sessionId, record)
|
|
return this.snapshot(record, session.motd)
|
|
} catch (error) {
|
|
if (session !== undefined && !this.sessions.has(sessionId)) {
|
|
try {
|
|
await session.close('PTY spawn rolled back')
|
|
} catch (closeError: unknown) {
|
|
throw new AggregateError([error, closeError], 'PTY spawn and rollback both failed')
|
|
}
|
|
}
|
|
throw error
|
|
} finally {
|
|
releaseName()
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Start one exclusive interactive send.
|
|
* @param owner - exact session owner.
|
|
* @param id - target PTY identity.
|
|
* @param request - explicit text, submit behavior, and cancellation.
|
|
* @returns live operation handle for foreground await or task registration.
|
|
*/
|
|
startSend(owner: Agent, id: PtySessionId, request: PtySendRequest): PtySendOperation {
|
|
const record = this.expectOwned(owner, id)
|
|
if (record.closing !== undefined) throw new Error(`PTY session ${id} is closing`)
|
|
if (record.active !== undefined) throw new PtyError(`PTY session ${id} already has an active send`, 'SEND_ACTIVE')
|
|
const operation = record.session.startSend(request)
|
|
record.active = operation
|
|
void operation.done.then(
|
|
() => { record.active = undefined },
|
|
() => { record.active = undefined },
|
|
)
|
|
return operation
|
|
}
|
|
|
|
/**
|
|
* Read one bounded scrollback page from an owned session.
|
|
* @param owner - exact session owner.
|
|
* @param id - target PTY identity.
|
|
* @param request - optional newest-relative offset and line count.
|
|
* @returns bounded retained text and pagination metadata.
|
|
*/
|
|
read(owner: Agent, id: PtySessionId, request: PtyReadRequest = {}): PtyReadResult {
|
|
return this.expectOwned(owner, id).session.read(request)
|
|
}
|
|
|
|
/**
|
|
* Deliver an allowed signal through an owned backend session.
|
|
* @param owner - exact session owner.
|
|
* @param id - target PTY identity.
|
|
* @param signal - allowed POSIX signal name.
|
|
* @returns delivered foreground process-group identity.
|
|
*/
|
|
signal(owner: Agent, id: PtySessionId, signal: PtySignal): Promise<PtySignalResult> {
|
|
return this.expectOwned(owner, id).session.signal(signal)
|
|
}
|
|
|
|
/**
|
|
* Close one owned session and remove it only after quiescent backend cleanup.
|
|
* @param owner - exact session owner.
|
|
* @param id - target PTY identity.
|
|
* @param reason - diagnostic cleanup reason.
|
|
* @returns true for a newly closed session, false when the same close is already in flight.
|
|
*/
|
|
async kill(owner: Agent, id: PtySessionId, reason = 'model request'): Promise<boolean> {
|
|
const record = this.expectOwned(owner, id)
|
|
if (record.closing !== undefined) {
|
|
await record.closing
|
|
return false
|
|
}
|
|
const closing = record.session.close(reason)
|
|
record.closing = closing
|
|
try {
|
|
await closing
|
|
this.sessions.delete(id)
|
|
return true
|
|
} catch (error) {
|
|
record.closing = undefined
|
|
throw error
|
|
}
|
|
}
|
|
|
|
/**
|
|
* List fresh snapshots for exactly one owner.
|
|
* @param owner - exact owner whose sessions are visible.
|
|
* @returns owner-visible snapshots in publication order.
|
|
*/
|
|
list(owner: Agent): PtySessionSnapshot[] {
|
|
return [...this.sessions.values()]
|
|
.filter(record => record.owner === owner)
|
|
.map(record => this.snapshot(record))
|
|
}
|
|
|
|
private assertActive(): void {
|
|
if (this.disposing) throw new PtyError('PTY service is disposing', 'SERVICE_DISPOSING')
|
|
}
|
|
|
|
private isLiveOwner(owner: Agent): boolean {
|
|
return !this.disposedOwners.has(owner) && this.ctx.get('agents')?.get(owner.id) === owner
|
|
}
|
|
|
|
private ensureOwnerCleanup(owner: Agent): void {
|
|
if (!this.isLiveOwner(owner)) {
|
|
throw new PtyError(`agent "${owner.id}" is not the registered PTY owner`, 'OWNER_NOT_LIVE')
|
|
}
|
|
if (this.ownerCleanups.has(owner)) return
|
|
const detach = owner.ctx.effect(() => async () => {
|
|
this.disposedOwners.add(owner)
|
|
this.ownerCleanups.delete(owner)
|
|
await this.disposeOwned(owner)
|
|
}, 'pty.ownerCleanup()')
|
|
this.ownerCleanups.set(owner, detach)
|
|
}
|
|
|
|
private reserveName(owner: Agent, name: string | undefined): () => void {
|
|
if (name === undefined) return () => {}
|
|
if ([...this.sessions.values()].some(record => record.owner === owner && record.name === name)) {
|
|
throw new PtyError(`PTY session name "${name}" already exists for this owner`, 'DUPLICATE_NAME')
|
|
}
|
|
const reserved = this.reservedNames.get(owner) ?? new Set<string>()
|
|
if (reserved.has(name)) throw new PtyError(`PTY session name "${name}" is already being created`, 'DUPLICATE_NAME')
|
|
reserved.add(name)
|
|
this.reservedNames.set(owner, reserved)
|
|
return () => {
|
|
reserved.delete(name)
|
|
if (reserved.size === 0) this.reservedNames.delete(owner)
|
|
}
|
|
}
|
|
|
|
private expectOwned(owner: Agent, id: PtySessionId): SessionRecord {
|
|
const record = this.sessions.get(id)
|
|
if (record === undefined) throw new PtyError(`unknown PTY session ${id}`, 'NO_SESSION')
|
|
if (record.owner !== owner) throw new PtyError(`PTY session ${id} belongs to another agent`, 'FOREIGN_SESSION')
|
|
return record
|
|
}
|
|
|
|
private snapshot(record: SessionRecord): PtySessionSnapshot
|
|
private snapshot(record: SessionRecord, motd: string): PtySpawnResult
|
|
private snapshot(record: SessionRecord, motd?: string): PtySpawnResult | PtySessionSnapshot {
|
|
return {
|
|
sessionId: record.id,
|
|
...record.name !== undefined ? { name: record.name } : {},
|
|
type: record.type,
|
|
...record.session.pid !== undefined ? { pid: record.session.pid } : {},
|
|
status: record.session.status(),
|
|
...motd !== undefined ? { motd } : {},
|
|
}
|
|
}
|
|
|
|
private async disposeOwned(owner: Agent): Promise<void> {
|
|
const owned = [...this.sessions.values()].filter(record => record.owner === owner)
|
|
await this.closeRecords(owned, 'PTY owner disposed')
|
|
this.reservedNames.delete(owner)
|
|
}
|
|
|
|
private async disposeAll(): Promise<void> {
|
|
this.disposing = true
|
|
const records = [...this.sessions.values()]
|
|
// Teardown is best-effort: a close failure still clears registries and runs
|
|
// owner cleanups before the aggregated error propagates, so one stuck
|
|
// session cannot orphan backends, reservations, or owner detachers.
|
|
try {
|
|
await this.closeRecords(records, 'PTY service disposed')
|
|
} finally {
|
|
this.backends.clear()
|
|
this.reservedNames.clear()
|
|
const cleanups = [...this.ownerCleanups.values()]
|
|
this.ownerCleanups.clear()
|
|
await Promise.all(cleanups.map(cleanup => Promise.resolve(cleanup())))
|
|
}
|
|
}
|
|
|
|
private async closeRecords(records: SessionRecord[], reason: string): Promise<void> {
|
|
const results = await Promise.allSettled(records.map(async (record) => {
|
|
const closing = record.closing ?? record.session.close(reason)
|
|
record.closing = closing
|
|
await closing
|
|
this.sessions.delete(record.id)
|
|
}))
|
|
const failures = results
|
|
.filter((result): result is PromiseRejectedResult => result.status === 'rejected')
|
|
.map<unknown>(result => result.reason as unknown)
|
|
if (failures.length > 0) throw new AggregateError(failures, `failed to close ${failures.length} PTY session(s)`)
|
|
}
|
|
}
|
|
|
|
export default PtyService
|