feat: add persistent PTY sessions
This commit is contained in:
34
packages/pty/pty/README.md
Normal file
34
packages/pty/pty/README.md
Normal 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.
|
||||
35
packages/pty/pty/package.json
Normal file
35
packages/pty/pty/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
356
packages/pty/pty/src/index.ts
Normal file
356
packages/pty/pty/src/index.ts
Normal 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
|
||||
158
packages/pty/pty/src/types.ts
Normal file
158
packages/pty/pty/src/types.ts
Normal 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
|
||||
}
|
||||
346
packages/pty/pty/tests/service.spec.ts
Normal file
346
packages/pty/pty/tests/service.spec.ts
Normal 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' })
|
||||
})
|
||||
})
|
||||
24
packages/pty/pty/tsconfig.json
Normal file
24
packages/pty/pty/tsconfig.json
Normal 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"
|
||||
}
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user