feat: add persistent PTY sessions

This commit is contained in:
NI0317
2026-07-21 16:01:00 +08:00
parent b4750f6333
commit 58cde5103a
71 changed files with 5677 additions and 82 deletions

View File

@@ -0,0 +1,34 @@
# @deepseek-ai/dsh-pty
Owner-scoped persistent PTY seam. `PtyService` registers as `ctx.pty`, mints opaque session ids, routes creation through named backends, fences every operation to the exact live `Agent`, and awaits backend quiescence when that agent or the service disposes.
## Contract
- Backends register one stable `type` and return an unpublished `PtyBackendSession`; failed or cancelled setup must clean partial resources.
- A successful spawn publishes one `PtySessionId`. The optional `name` is owner-local display metadata, never authority.
- One session accepts at most one live send operation. Reads and signals may observe it; another send fails until the operation settles.
- `PtySendResult.waitReason` and `sessionStatus` are independent. `session_exit` describes the top-level PTY process, not an arbitrary foreground command.
- `kill()` and disposal resolve only after the backend's captured process tree is quiescent. A cleanup failure rejects instead of claiming success.
The seam contains no `node-pty`, sandbox, tool-schema, prompt, task, or terminal-rendering policy. Implementations own terminal mechanics; consumers own model presentation and optional background-task registration.
## Model Experience
### Indirect consumer
#### What the model sees
Nothing directly. This package registers no prompt or tool; `@deepseek-ai/dsh-tool-pty` owns visible schemas and result text.
#### Token effect
None directly. Live session state stays process-local until a consumer returns a bounded result.
#### KV Cache effect
No direct invalidation; the named consumer owns request-prefix changes.
## Known Limitations and Deferred Work
- Sessions are process-local and are not restored after a harness restart.
- Cross-agent sharing is intentionally absent; a future shared-session design needs a separate authority contract.

View File

@@ -0,0 +1,35 @@
{
"name": "@deepseek-ai/dsh-pty",
"description": "Persistent PTY session seam for the DeepSeek Harness — owner-scoped ids, backend registry, interactive sends, reads, signals, and awaited cleanup",
"version": "0.0.1",
"private": true,
"type": "module",
"main": "lib/index.js",
"types": "lib/types/index.d.ts",
"exports": {
".": {
"types": "./lib/types/index.d.ts",
"default": "./lib/index.js"
},
"./src/*": "./src/*",
"./package.json": "./package.json"
},
"files": [
"lib/index.js",
"lib/types/**/*.d.ts",
"lib/types/**/*.d.ts.map",
"src"
],
"license": "BSD-3-Clause",
"peerDependencies": {
"@deepseek-ai/dsh-agent": "^0.0.1",
"@deepseek-ai/dsh-brand": "^0.0.1",
"cordis": "^4.0.0-rc.7"
},
"devDependencies": {
"@deepseek-ai/dsh-agent": "workspace:^",
"@deepseek-ai/dsh-brand": "workspace:^",
"@deepseek-ai/dsh-session": "workspace:^",
"cordis": "^4.0.0-rc.7"
}
}

View File

@@ -0,0 +1,356 @@
/**
* 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()]
await this.closeRecords(records, 'PTY service disposed')
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

View File

@@ -0,0 +1,158 @@
/**
* Types shared by PTY backends, the owner-scoped registry, and tool consumers.
* Runtime service code lives in `./index.ts`.
* @module @deepseek-ai/dsh-pty/types
*/
import type { Branded } from '@deepseek-ai/dsh-brand'
import type { Agent } from '@deepseek-ai/dsh-agent'
/** Internal exported basis for the public `PtySessionId` type/value pair. */
export type PtySessionIdValue = Branded<'PtySessionId'>
/** Why one interactive send returned control to its caller. */
export type PtyWaitReason = 'stdin_read' | 'inferred_idle' | 'timeout' | 'session_exit'
/** Signals the model-facing PTY surface permits for foreground process groups. */
export type PtySignal = 'SIGINT' | 'SIGTERM' | 'SIGKILL' | 'SIGTSTP' | 'SIGHUP'
/** Top-level PTY process status, independent of a send's wait reason. */
export type PtySessionStatus =
| { kind: 'running' }
| { kind: 'exited'; exitCode: number | null; signal: NodeJS.Signals | null }
/** Request to create one owner-scoped PTY session. */
export interface PtySpawnRequest {
/** Registered backend type. */
type: string
/** Optional owner-local display name. */
name?: string
/** Optional initial working directory interpreted by the backend. */
cwd?: string
}
/** Fully identified request handed from the registry to a backend. */
export interface PtyBackendSpawnSpec extends PtySpawnRequest {
/** Registry-minted session identity. */
sessionId: PtySessionIdValue
/** Exact live owner for authority-aware backend setup. */
owner: Agent
/** Cancellation of unpublished backend setup. */
signal?: AbortSignal
}
/** Input for one line-oriented terminal interaction. */
export interface PtySendRequest {
/** UTF-8 text to write. */
text: string
/** Whether to write the backend's Enter sequence after {@link text}. */
submit: boolean
/** Cancellation for the wait; backends also interrupt the foreground command. */
signal?: AbortSignal
}
/** Incremental output consumed from one live send operation. */
export interface PtySendRead {
/** Output produced since the previous operation read. */
delta: string
/** Whether unread operation output was dropped by the backend's bound. */
truncated: boolean
}
/** Settled result for one foreground or background send. */
export interface PtySendResult {
/** Bounded rendered terminal delta remaining at settlement. */
viewport: string
/** Why the wait returned; this does not imply arbitrary child-process exit. */
waitReason: PtyWaitReason
/** Top-level session status observed at settlement. */
sessionStatus: PtySessionStatus
/** Whether output was dropped from the operation or retained scrollback. */
truncated: boolean
}
/** Live backend-owned send; exactly one may be active per PTY session. */
export interface PtySendOperation {
/** Resolves after readiness, timeout, cancellation, or top-level process exit. */
done: Promise<PtySendResult>
/** Consume output produced since the prior call. */
readOutput(): PtySendRead
/** Request `SIGINT`; returns false after the operation settled. */
cancel(): boolean
}
/** Request for one backward scrollback page. */
export interface PtyReadRequest {
/** Offset from the newest retained line; defaults are backend-owned. */
offset?: number
/** Requested line count; backend limits still apply. */
count?: number
}
/** Bounded scrollback page. */
export interface PtyReadResult {
/** Retained text in chronological order. */
text: string
/** Number of lines currently retained. */
totalLines: number
/** Inclusive newest-relative offset of the first returned line. */
lineBegin: number
/** Exclusive newest-relative offset after the returned page. */
lineEnd: number
/** Whether older retained output or the requested result exceeded a bound. */
truncated: boolean
}
/** Result of delivering a signal to a verified foreground process group. */
export interface PtySignalResult {
/** True only after the backend delivered the signal. */
delivered: true
/** Process group that received the signal. */
targetPgid: number
}
/** Owner-visible summary of one published PTY session. */
export interface PtySessionSnapshot {
/** Registry-minted identity used by every operation. */
sessionId: PtySessionIdValue
/** Optional owner-local display name. */
name?: string
/** Backend type that created the session. */
type: string
/** Top-level process id when the backend has one. */
pid?: number
/** Current top-level process status. */
status: PtySessionStatus
}
/** Backend-owned live session retained by {@link PtyService}. */
export interface PtyBackendSession {
/** Initial bounded terminal output returned from `pty_spawn`. */
readonly motd: string
/** Top-level process id when one exists. */
readonly pid?: number
/** Start one exclusive send operation. */
startSend(request: PtySendRequest): PtySendOperation
/** Read one bounded page from retained scrollback. */
read(request: PtyReadRequest): PtyReadResult
/** Signal the verified foreground process group. */
signal(signal: PtySignal): Promise<PtySignalResult>
/** Observe top-level process status. */
status(): PtySessionStatus
/** Idempotently close the captured owned process tree and await quiescence. */
close(reason: string): Promise<void>
}
/** Replaceable provider for one PTY session type. */
export interface PtyBackend {
/** Stable type selected by {@link PtySpawnRequest.type}. */
readonly type: string
/** Create an unpublished session or reject after cleaning partial resources. */
spawn(spec: PtyBackendSpawnSpec): Promise<PtyBackendSession>
}
/** Successful publication returned by {@link PtyService.spawn}. */
export interface PtySpawnResult extends PtySessionSnapshot {
/** Initial bounded output captured before publication. */
motd: string
}

View File

@@ -0,0 +1,346 @@
import { describe, expect, expectTypeOf, it } from 'vitest'
import { Context } from 'cordis'
import { Session, SessionId } from '@deepseek-ai/dsh-session'
import AgentRegistry from '@deepseek-ai/dsh-agent'
import type { Agent } from '@deepseek-ai/dsh-agent'
import PtyService, { PtyError, PtySessionId } from '@deepseek-ai/dsh-pty'
import type {
PtyBackend,
PtyBackendSession,
PtyReadRequest,
PtySendOperation,
PtySendRequest,
PtySessionId as PtySessionIdType,
PtySessionStatus,
PtySignal,
} from '@deepseek-ai/dsh-pty'
const agentScopeDisposers = new WeakMap<Agent, () => Promise<void>>()
const ptyServiceDisposers = new WeakMap<Context, () => Promise<void>>()
function stubAgent(ctx: Context, rawId: string): Agent {
const id = SessionId(rawId)
const scopeFiber = ctx.plugin(() => {})
const agent: Agent = {
id,
options: {},
session: new Session(id),
status: 'idle',
ctx: scopeFiber.ctx,
send() {},
steer() {},
inject() {},
cancel() {},
whenIdle: () => Promise.resolve(),
}
agentScopeDisposers.set(agent, async () => { await scopeFiber.dispose() })
return agent
}
async function disposeAgentScope(agent: Agent): Promise<void> {
const dispose = agentScopeDisposers.get(agent)
if (dispose === undefined) throw new Error('missing agent scope')
await dispose()
}
class StubSession implements PtyBackendSession {
readonly motd = 'stub ready'
readonly pid = 123
closed: string[] = []
statusValue: PtySessionStatus = { kind: 'running' }
operation: PtySendOperation | undefined
rejectSend = false
rejectClose = false
closeGate: PromiseWithResolvers<undefined> | undefined
startSend(_request: PtySendRequest): PtySendOperation {
if (this.rejectSend) {
return { done: Promise.reject(new Error('send failed')), readOutput: () => ({ delta: '', truncated: false }), cancel: () => false }
}
let settle!: () => void
let settled = false
const done = new Promise<void>((resolve) => { settle = resolve }).then(() => ({
viewport: 'done',
waitReason: 'stdin_read' as const,
sessionStatus: this.statusValue,
truncated: false,
}))
const operation: PtySendOperation = {
done,
readOutput: () => ({ delta: 'delta', truncated: false }),
cancel: () => {
if (settled) return false
settled = true
settle()
return true
},
}
this.operation = operation
return operation
}
read(request: PtyReadRequest) {
return { text: `${request.offset ?? 0}:${request.count ?? 0}`, totalLines: 1, lineBegin: 0, lineEnd: 1, truncated: false }
}
async signal(signal: PtySignal) {
return { delivered: true as const, targetPgid: signal === 'SIGINT' ? 12 : 13 }
}
status(): PtySessionStatus {
return this.statusValue
}
async close(reason: string): Promise<void> {
this.closed.push(reason)
if (this.rejectClose) throw new Error('close failed')
if (this.closeGate !== undefined) await this.closeGate.promise
this.statusValue = { kind: 'exited', exitCode: 0, signal: null }
this.operation?.cancel()
}
}
function backend(type = 'stub') {
const sessions: StubSession[] = []
const provider: PtyBackend = {
type,
async spawn() {
const session = new StubSession()
sessions.push(session)
return session
},
}
return { provider, sessions }
}
async function harness() {
const ctx = new Context()
await ctx.plugin(AgentRegistry)
const fiber = await ctx.plugin(PtyService)
ptyServiceDisposers.set(ctx, async () => { await fiber.dispose() })
return ctx
}
async function disposePtyService(ctx: Context): Promise<void> {
const dispose = ptyServiceDisposers.get(ctx)
if (dispose === undefined) throw new Error('missing PTY service fiber')
await dispose()
}
describe('PtyService backend registry', () => {
it('preserves the id brand and disposes exact backend contributions', async () => {
expectTypeOf(PtySessionId('pty-1')).toEqualTypeOf<PtySessionIdType>()
const ctx = await harness()
const first = backend()
const dispose = ctx.pty.registerBackend(first.provider)
expect(ctx.pty.listBackends()).toEqual(['stub'])
expect(() => ctx.pty.registerBackend(backend().provider)).toThrow(PtyError)
const internal = ctx.pty as unknown as { backends: Map<string, PtyBackend> }
internal.backends.set('stub', backend('replacement').provider)
dispose()
expect(ctx.pty.listBackends()).toEqual(['stub'])
internal.backends.clear()
})
it('rejects empty backend types', async () => {
const ctx = await harness()
expect(() => ctx.pty.registerBackend(backend('').provider)).toThrow('must be non-empty')
})
})
describe('PtyService ownership and lifecycle', () => {
it('publishes only after spawn and fences every operation to the exact owner', async () => {
const ctx = await harness()
const b = backend()
ctx.pty.registerBackend(b.provider)
const owner = stubAgent(ctx, 'owner')
const foreign = stubAgent(ctx, 'foreign')
ctx.agents.register(owner)
ctx.agents.register(foreign)
const created = await ctx.pty.spawn(owner, { type: 'stub', name: 'main', cwd: '/tmp' })
expect(created).toMatchObject({ sessionId: 'pty-1', name: 'main', type: 'stub', pid: 123, motd: 'stub ready', status: { kind: 'running' } })
expect(ctx.pty.list(owner)).toHaveLength(1)
expect(ctx.pty.list(foreign)).toEqual([])
expect(() => ctx.pty.read(foreign, created.sessionId)).toThrow('belongs to another agent')
expect(() => ctx.pty.signal(foreign, created.sessionId, 'SIGINT')).toThrow('belongs to another agent')
await expect(Promise.resolve().then(() => ctx.pty.kill(foreign, created.sessionId))).rejects.toThrow('belongs to another agent')
})
it('rejects unknown backends, non-live owners, duplicate names, and active sends', async () => {
const ctx = await harness()
const owner = stubAgent(ctx, 'owner')
await expect(ctx.pty.spawn(owner, { type: 'missing' })).rejects.toMatchObject({ code: 'OWNER_NOT_LIVE' })
ctx.agents.register(owner)
await expect(ctx.pty.spawn(owner, { type: 'missing' })).rejects.toMatchObject({ code: 'NO_BACKEND' })
const b = backend()
ctx.pty.registerBackend(b.provider)
const created = await ctx.pty.spawn(owner, { type: 'stub', name: 'main' })
await expect(ctx.pty.spawn(owner, { type: 'stub', name: '' })).rejects.toThrow('must be non-empty')
const aborted = new AbortController()
aborted.abort()
await expect(ctx.pty.spawn(owner, { type: 'stub' }, aborted.signal)).rejects.toThrow('spawn aborted')
await expect(ctx.pty.spawn(owner, { type: 'stub', name: 'main' })).rejects.toMatchObject({ code: 'DUPLICATE_NAME' })
const operation = ctx.pty.startSend(owner, created.sessionId, { text: 'echo hi', submit: true })
expect(() => ctx.pty.startSend(owner, created.sessionId, { text: 'pwd', submit: true })).toThrow(PtyError)
expect(operation.readOutput()).toEqual({ delta: 'delta', truncated: false })
expect(operation.cancel()).toBe(true)
await operation.done
const next = ctx.pty.startSend(owner, created.sessionId, { text: 'pwd', submit: true })
next.cancel()
await next.done
b.sessions[0]!.rejectSend = true
await expect(ctx.pty.startSend(owner, created.sessionId, { text: 'bad', submit: true }).done).rejects.toThrow('send failed')
await new Promise(resolve => setTimeout(resolve, 0))
})
it('reserves concurrent names and rolls back a spawn whose owner disappears', async () => {
const ctx = await harness()
const gate = Promise.withResolvers<PtyBackendSession>()
const session = new StubSession()
ctx.pty.registerBackend({ type: 'slow', spawn: () => gate.promise })
const owner = stubAgent(ctx, 'owner')
ctx.agents.register(owner)
const pending = ctx.pty.spawn(owner, { type: 'slow', name: 'main' })
await expect(ctx.pty.spawn(owner, { type: 'slow', name: 'main' })).rejects.toMatchObject({ code: 'DUPLICATE_NAME' })
await disposeAgentScope(owner)
gate.resolve(session)
await expect(pending).rejects.toMatchObject({ code: 'OWNER_NOT_LIVE' })
expect(session.closed).toEqual(['PTY spawn rolled back'])
})
it('keeps independent reservations and handles provider failure before publication', async () => {
const ctx = await harness()
const firstGate = Promise.withResolvers<PtyBackendSession>()
const secondGate = Promise.withResolvers<PtyBackendSession>()
let count = 0
ctx.pty.registerBackend({
type: 'slow',
spawn: () => ++count === 1 ? firstGate.promise : secondGate.promise,
})
const owner = stubAgent(ctx, 'owner')
ctx.agents.register(owner)
const first = ctx.pty.spawn(owner, { type: 'slow', name: 'one' })
const second = ctx.pty.spawn(owner, { type: 'slow', name: 'two' })
firstGate.resolve(new StubSession())
await first
secondGate.resolve(new StubSession())
await second
ctx.pty.registerBackend({ type: 'throwing', spawn: () => Promise.reject(new Error('provider failed')) })
await expect(ctx.pty.spawn(owner, { type: 'throwing' })).rejects.toThrow('provider failed')
const controller = new AbortController()
const b = backend('signaled')
ctx.pty.registerBackend(b.provider)
await ctx.pty.spawn(owner, { type: 'signaled' }, controller.signal)
})
it('omits optional pid metadata when a backend has no process id', async () => {
const ctx = await harness()
const owner = stubAgent(ctx, 'owner')
ctx.agents.register(owner)
const session = new StubSession()
Object.defineProperty(session, 'pid', { value: undefined })
ctx.pty.registerBackend({ type: 'virtual', spawn: () => Promise.resolve(session) })
expect(await ctx.pty.spawn(owner, { type: 'virtual' })).not.toHaveProperty('pid')
})
it('reports rollback and close failures without publishing false success', async () => {
const ctx = await harness()
const owner = stubAgent(ctx, 'owner')
ctx.agents.register(owner)
const failedSpawn = new StubSession()
failedSpawn.rejectClose = true
ctx.pty.registerBackend({
type: 'bad-spawn',
async spawn() {
await disposeAgentScope(owner)
return failedSpawn
},
})
await expect(ctx.pty.spawn(owner, { type: 'bad-spawn' })).rejects.toThrow('spawn and rollback both failed')
const nextOwner = stubAgent(ctx, 'next')
ctx.agents.register(nextOwner)
const b = backend('bad-close')
ctx.pty.registerBackend(b.provider)
const created = await ctx.pty.spawn(nextOwner, { type: 'bad-close' })
b.sessions[0]!.rejectClose = true
await expect(ctx.pty.kill(nextOwner, created.sessionId)).rejects.toThrow('close failed')
expect(ctx.pty.list(nextOwner)).toHaveLength(1)
})
it('joins an already-running close and refuses new sends while closing', async () => {
const ctx = await harness()
const owner = stubAgent(ctx, 'owner')
ctx.agents.register(owner)
const b = backend()
ctx.pty.registerBackend(b.provider)
const created = await ctx.pty.spawn(owner, { type: 'stub' })
b.sessions[0]!.closeGate = Promise.withResolvers<undefined>()
const first = ctx.pty.kill(owner, created.sessionId)
expect(() => ctx.pty.startSend(owner, created.sessionId, { text: '', submit: false })).toThrow('closing')
const second = ctx.pty.kill(owner, created.sessionId)
b.sessions[0]!.closeGate?.resolve(undefined)
expect(await first).toBe(true)
expect(await second).toBe(false)
expect(() => ctx.pty.read(owner, created.sessionId)).toThrow('unknown PTY')
})
it('awaits owner cleanup and removes sessions while backend registration may reload', async () => {
const ctx = await harness()
const b = backend()
const disposeBackend = ctx.pty.registerBackend(b.provider)
const owner = stubAgent(ctx, 'owner')
ctx.agents.register(owner)
const created = await ctx.pty.spawn(owner, { type: 'stub' })
disposeBackend()
expect(ctx.pty.listBackends()).toEqual([])
expect(ctx.pty.read(owner, created.sessionId).text).toBe('0:0')
await disposeAgentScope(owner)
expect(b.sessions[0]?.closed).toEqual(['PTY owner disposed'])
expect(ctx.pty.list(owner)).toEqual([])
})
it('kills idempotently and service disposal closes all owners', async () => {
const ctx = await harness()
const b = backend()
ctx.pty.registerBackend(b.provider)
const first = stubAgent(ctx, 'first')
const second = stubAgent(ctx, 'second')
ctx.agents.register(first)
ctx.agents.register(second)
const a = await ctx.pty.spawn(first, { type: 'stub' })
await ctx.pty.spawn(second, { type: 'stub' })
expect(await ctx.pty.kill(first, a.sessionId)).toBe(true)
expect(b.sessions[0]?.closed).toEqual(['model request'])
const service = ctx.pty
await disposePtyService(ctx)
expect(b.sessions[1]?.closed).toEqual(['PTY service disposed'])
await expect(service.spawn(first, { type: 'stub' })).rejects.toMatchObject({ code: 'SERVICE_DISPOSING' })
})
it('aggregates service-disposal close failures after attempting every record', async () => {
const ctx = await harness()
const service = ctx.pty
const b = backend()
ctx.pty.registerBackend(b.provider)
const owner = stubAgent(ctx, 'owner')
ctx.agents.register(owner)
await ctx.pty.spawn(owner, { type: 'stub' })
b.sessions[0]!.rejectClose = true
const internal = service as unknown as {
sessions: Map<PtySessionIdType, unknown>
closeRecords(records: unknown[], reason: string): Promise<void>
}
await expect(internal.closeRecords([...internal.sessions.values()], 'test failure')).rejects.toThrow('failed to close 1 PTY session')
b.sessions[0]!.rejectClose = false
await disposePtyService(ctx)
await expect(service.spawn(owner, { type: 'stub' })).rejects.toMatchObject({ code: 'SERVICE_DISPOSING' })
})
})

View File

@@ -0,0 +1,24 @@
{
"extends": "../../../tsconfig.base.json",
"compilerOptions": {
"rootDir": "src",
"outDir": "lib/types"
},
"include": [
"src"
],
"references": [
{
"path": "../../../vendor/cosmokit"
},
{
"path": "../../../vendor/cordis"
},
{
"path": "../../core/agent"
},
{
"path": "../../util/brand"
}
]
}