fix(pty): close review lifecycle gaps

This commit is contained in:
Tianyi Cui
2026-07-22 22:37:20 +08:00
parent 1167ea71b7
commit 57a47b1fb3
47 changed files with 940 additions and 192 deletions

View File

@@ -4,9 +4,11 @@ Local `node-pty` backend for `ctx.pty`. It starts an interactive shell under the
## Plugin (`pty-local`)
The plugin injects `pty`, `sandbox`, and `sandboxPolicy`, then registers the configured backend type (`shell`). `danger-full-access` starts the shell directly; confined modes wrap the exact shell argv through `ctx.sandbox`. The current session-level sandbox override is resolved at spawn and remains fixed for the PTY lifetime.
The plugin injects `agents`, `pty`, `sandbox`, and `sandboxPolicy`, then registers the configured backend type (`shell`). `danger-full-access` starts the shell directly; confined modes wrap the exact shell argv through `ctx.sandbox`. The effective session mode is resolved at spawn. A change to a different effective mode is rejected before its `sandbox/mode` event commits while that owner has an open PTY or a spawn in progress; wait for creation to settle and close the sessions before changing modes, so a terminal opened with wider access cannot survive a downgrade.
Linux readiness combines a foreground-verified private bash prompt marker, foreground-process-group syscall inspection, silence fallback, and absolute timeout. macOS uses the verified prompt marker plus silence/timeout because it has no `/proc` syscall surface. Unrecognized or unreadable process state is never a positive exact-idle signal. During unpublished startup, a fallback requires observed output; zero-output silence cannot publish an empty session, and timeout rejects the spawn. Incomplete terminal-control sequences are bounded by `maxReadBytes` and discarded through their terminator after crossing that limit.
Linux readiness combines a foreground-verified private bash prompt marker, foreground-process-group syscall inspection, silence fallback, and absolute timeout. macOS uses the verified prompt marker plus silence/timeout because it has no `/proc` syscall surface. A marker is not ready until printable prompt text arrives, including when the OSC marker and `PS1` are split across data callbacks. Unrecognized or unreadable process state is never a positive exact-idle signal. During unpublished startup, a fallback requires observed output; zero-output silence cannot publish an empty session, and timeout rejects the spawn. Incomplete terminal-control sequences are bounded by `maxReadBytes` and discarded through their terminator after crossing that limit; a trailing carriage return is carried across callbacks so split CRLF becomes one newline.
Send cancellation resolves the current foreground process group and delivers a real `SIGINT`; it never emulates interruption by writing `\x03`, so raw-mode programs remain cancellable. Close sends `SIGTERM` to descendants, waits, rescans and sends `SIGKILL` to the remaining tree, verifies that descendants left the process table while the shell can still reap them, and only then stops the shell. A survivor failure does not cache a permanently rejected close; a later close retries the teardown.
## Model Experience

View File

@@ -30,10 +30,12 @@
},
"license": "BSD-3-Clause",
"peerDependencies": {
"@deepseek-ai/dsh-agent": "^0.0.1",
"@deepseek-ai/dsh-invariants": "^0.0.1",
"@deepseek-ai/dsh-pty": "^0.0.1",
"@deepseek-ai/dsh-sandbox": "^0.0.1",
"@deepseek-ai/dsh-sandbox-policy": "^0.0.1",
"@deepseek-ai/dsh-session": "^0.0.1",
"cordis": "^4.0.0-rc.7"
},
"dependencies": {

View File

@@ -7,6 +7,7 @@
import { Context } from 'cordis'
import * as nodePty from 'node-pty'
import type { IPtyForkOptions } from 'node-pty'
import type { Session, SessionEvent } from '@deepseek-ai/dsh-session'
import type { PtyBackend, PtyBackendSpawnSpec } from '@deepseek-ai/dsh-pty'
import type { SandboxMode } from '@deepseek-ai/dsh-sandbox'
import { effectiveSandboxMode } from '@deepseek-ai/dsh-sandbox-policy'
@@ -20,8 +21,8 @@ export type { Config as PtyLocalConfig } from './config.ts'
/** Cordis plugin name. */
export const name = 'pty-local'
/** Required services: registry plus the one shared confinement policy. */
export const inject = ['pty', 'sandbox', 'sandboxPolicy']
/** Required services: owner/PTY registries plus the one shared confinement policy. */
export const inject = ['agents', 'pty', 'sandbox', 'sandboxPolicy']
const SENSITIVE_ENV_PATTERN = /KEY|SECRET|TOKEN/i
@@ -73,7 +74,7 @@ export class LocalPtyBackend implements PtyBackend {
}
async spawn(spec: PtyBackendSpawnSpec): Promise<LocalPtySession> {
if (spec.signal?.aborted === true) throw new Error('PTY spawn aborted')
spec.signal?.throwIfAborted()
const argv = spawnArgv(this.ctx, this.config, spec)
const file = argv[0]
if (file === undefined) throw new Error('pty-local: sandbox returned empty argv')
@@ -105,4 +106,17 @@ export function apply(ctx: Context, config: Config): void {
validateConfig(config)
const inspector = createProcessInspector()
ctx.pty.registerBackend(new LocalPtyBackend(ctx, config, inspector))
ctx.on('internal/dispatch', (_mode, eventName, args) => {
if (eventName !== 'session/event') return
const [session, event] = args as [Session, SessionEvent]
if (event.type !== 'sandbox/mode') return
const currentMode = effectiveSandboxMode(session.events) ?? ctx.sandboxPolicy.defaultMode
if (event.data.mode === currentMode) return
const owner = ctx.agents.get(session.id)
if (owner === undefined) return
if (!ctx.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 })
}

View File

@@ -9,6 +9,8 @@ export const PROMPT_MARKER_PREFIX = '133;D;'
export interface SanitizedChunk {
text: string
prompt: boolean
/** Present when printable text followed the latest owned prompt marker. */
promptText?: true
}
/**
@@ -20,6 +22,8 @@ export class TerminalSanitizer {
private pending = ''
private discardMode: 'osc' | 'csi' | undefined
private discardOscEscape = false
private trailingCarriageReturn = false
private awaitingPromptText = false
constructor(private readonly maxPendingBytes: number) {}
@@ -32,15 +36,24 @@ export class TerminalSanitizer {
this.pending += this.discardPrefix(chunk)
let text = ''
let prompt = false
let promptText = false
let index = 0
const appendText = (value: string): boolean => {
text += value
if (this.awaitingPromptText && value.replace(/[\r\n\x07]/g, '').length > 0) {
this.awaitingPromptText = false
return true
}
return false
}
while (index < this.pending.length) {
const escape = this.pending.indexOf('\x1b', index)
if (escape < 0) {
text += this.pending.slice(index)
promptText = appendText(this.pending.slice(index)) || promptText
index = this.pending.length
break
}
text += this.pending.slice(index, escape)
promptText = appendText(this.pending.slice(index, escape)) || promptText
if (escape + 1 >= this.pending.length) {
index = escape
break
@@ -59,7 +72,11 @@ export class TerminalSanitizer {
}
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
if (content.startsWith(PROMPT_MARKER_PREFIX)) {
prompt = true
promptText = false
this.awaitingPromptText = true
}
index = end
continue
}
@@ -82,7 +99,7 @@ export class TerminalSanitizer {
}
this.pending = this.pending.slice(index)
this.enforcePendingBound()
return { text: normalizeTerminalText(text), prompt }
return { text: this.normalizeText(text), prompt, ...promptText ? { promptText: true } : {} }
}
/**
@@ -94,7 +111,21 @@ export class TerminalSanitizer {
this.pending = ''
this.discardMode = undefined
this.discardOscEscape = false
return normalizeTerminalText(text)
this.awaitingPromptText = 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 {

View File

@@ -17,7 +17,7 @@ import type {
PtyWaitReason,
} from '@deepseek-ai/dsh-pty'
import type { ResolvedConfig } from './config.ts'
import type { ProcessInspector } from './process-inspector.ts'
import type { ProcessIdentity, ProcessInspector } from './process-inspector.ts'
import { TerminalSanitizer } from './sanitize.ts'
function delay(ms: number): Promise<void> {
@@ -148,9 +148,11 @@ export class LocalPtySession implements PtyBackendSession {
private activeTimer: NodeJS.Timeout | undefined
private activeAbort: (() => void) | undefined
private promptSeen = false
private promptTextSeen = false
private shellPgid: number | undefined
private initializing = false
private lastOutputAt = Date.now()
private closing = false
private closePromise: Promise<void> | undefined
constructor(
@@ -190,21 +192,20 @@ export class LocalPtySession implements PtyBackendSession {
}
startSend(request: PtySendRequest): PtySendOperation {
if (this.closePromise !== undefined) throw new Error('PTY session is closing')
if (this.closing) throw new Error('PTY session is closing')
if (this.statusValue.kind === 'exited') throw new Error('PTY session has exited')
if (this.active !== undefined) throw new Error('PTY session already has an active send')
if (request.signal?.aborted === true) throw new Error('PTY send aborted before write')
const operation = new LocalSendOperation(this.config.maxReadBytes, Date.now(), () => {
try {
this.terminal.write('\x03')
} catch (error: unknown) {
operation.fail(error)
}
})
const operation = new LocalSendOperation(
this.config.maxReadBytes,
Date.now(),
() => { this.interrupt(operation) },
)
this.active = operation
this.lastOutputAt = Date.now()
this.promptSeen = false
this.promptTextSeen = false
if (request.signal !== undefined) {
const onAbort = (): void => { operation.cancel() }
@@ -267,8 +268,15 @@ export class LocalPtySession implements PtyBackendSession {
}
close(reason: string): Promise<void> {
this.closePromise ??= this.closeOnce(reason)
return this.closePromise
this.closing = true
if (this.closePromise !== undefined) return this.closePromise
const closing = this.closeOnce(reason).catch((error: unknown) => {
this.closePromise = undefined
this.failActive(error)
throw error
})
this.closePromise = closing
return closing
}
private onData(data: string): void {
@@ -279,8 +287,11 @@ export class LocalPtySession implements PtyBackendSession {
if (this.shellPgid === undefined) this.shellPgid = foregroundPgid
if (foregroundPgid !== undefined && foregroundPgid === this.shellPgid) {
this.promptSeen = true
this.promptTextSeen = sanitized.promptText === true
this.lastOutputAt = Date.now()
}
} else if (this.promptSeen && sanitized.promptText === true) {
this.promptTextSeen = true
}
}
@@ -297,7 +308,7 @@ export class LocalPtySession implements PtyBackendSession {
this.settleActive('session_exit')
return
}
if (this.promptSeen && Date.now() - this.lastOutputAt >= this.config.pollIntervalMs) {
if (this.promptSeen && this.promptTextSeen && Date.now() - this.lastOutputAt >= this.config.pollIntervalMs) {
this.settleActive('stdin_read')
return
}
@@ -337,58 +348,98 @@ export class LocalPtySession implements PtyBackendSession {
this.active = undefined
}
private failActive(error: unknown): void {
const operation = this.active
if (operation === undefined) return
this.clearActive()
operation.fail(error)
}
private interrupt(operation: LocalSendOperation): void {
if (this.active !== operation) return
try {
const pgid = this.inspector.foregroundPgid(this.pid)
if (pgid === undefined) throw new Error(`cannot resolve foreground process group for PTY ${this.pid}`)
this.inspector.signalGroup(pgid, 'SIGINT')
} catch (error: unknown) {
this.failActive(error)
}
}
private survivors(members: ProcessIdentity[]): ProcessIdentity[] {
return members.filter(member => this.inspector.isAlive(member))
}
private descendants(): ProcessIdentity[] {
return this.inspector.processTree(this.pid).filter(member => member.pid !== this.pid)
}
private async waitForExit(members: ProcessIdentity[]): Promise<ProcessIdentity[]> {
const deadline = Date.now() + this.config.disposeGraceMs
let survivors = this.survivors(members)
while (survivors.length > 0 && Date.now() < deadline) {
await delay(Math.min(25, Math.max(1, deadline - Date.now())))
survivors = this.survivors(members)
}
return survivors
}
private signalMembers(members: ProcessIdentity[], signal: 'SIGTERM' | 'SIGKILL'): void {
for (const member of members) {
try {
this.inspector.signalProcess(member, signal)
} catch (_alreadyExitedDuringSignal) {
// Identity is rechecked by the inspector; a same-tick exit is success.
}
}
}
private async stopDescendants(): Promise<ProcessIdentity[]> {
let members = this.descendants()
this.signalMembers(members, 'SIGTERM')
await this.waitForExit(members)
// A TERM-handling descendant may have forked while winding down. Rescan
// while the shell can still reap every member, then kill the fresh tree.
members = this.descendants()
this.signalMembers(members, 'SIGKILL')
await this.waitForExit(members)
return this.descendants().filter(member => this.inspector.isAlive(member))
}
private async stopShell(): Promise<void> {
try {
this.terminal.kill('SIGTERM')
} catch (_topLevelAlreadyExitedDuringTerm) {
// The exit notification remains authoritative.
}
if (this.statusValue.kind === 'running') {
await Promise.race([this.exitPromise.promise, delay(this.config.disposeGraceMs)])
}
if (this.statusValue.kind === 'running') {
try {
this.terminal.kill('SIGKILL')
} catch (_topLevelAlreadyExitedDuringKill) {
// The exit notification remains authoritative.
}
await Promise.race([this.exitPromise.promise, delay(this.config.disposeGraceMs)])
}
if (this.statusValue.kind === 'running') {
throw new Error(`PTY cleanup failed; surviving pids: ${this.pid}`)
}
}
private async closeOnce(reason: string): Promise<void> {
this.dataDisposable.dispose()
// Stop readiness polling but retain the active operation: teardown settles
// it as session_exit below, so an in-flight send is never mis-settled as
// stdin_read/inferred_idle/timeout during the grace period.
this.stopPolling()
const members = this.inspector.processTree(this.pid)
for (const member of members) {
try {
this.inspector.signalProcess(member, 'SIGTERM')
} catch (_alreadyExitedDuringTerm) {
// Identity is rechecked by the inspector; a same-tick exit is success.
}
}
try {
this.terminal.kill('SIGTERM')
} catch (_topLevelAlreadyExited) {
// onExit or identity checks below remain authoritative.
}
const deadline = Date.now() + this.config.disposeGraceMs
let survivors = members.filter(member => this.inspector.isAlive(member))
while (survivors.length > 0 && Date.now() < deadline) {
await delay(Math.min(25, this.config.disposeGraceMs))
survivors = members.filter(member => this.inspector.isAlive(member))
}
for (const survivor of survivors) {
try {
this.inspector.signalProcess(survivor, 'SIGKILL')
} catch (_alreadyExitedDuringKill) {
// Final identity check below decides success.
}
}
try {
this.terminal.kill('SIGKILL')
} catch (_topLevelAlreadyKilled) {
// The root may already have delivered onExit.
}
const killDeadline = Date.now() + this.config.disposeGraceMs
survivors = members.filter(member => this.inspector.isAlive(member))
while (survivors.length > 0 && Date.now() < killDeadline) {
await delay(Math.min(25, this.config.disposeGraceMs))
survivors = members.filter(member => this.inspector.isAlive(member))
}
const exitWaitMs = Math.max(0, killDeadline - Date.now())
await Promise.race([this.exitPromise.promise, delay(exitWaitMs)])
survivors = members.filter(member => this.inspector.isAlive(member))
this.settleActive('session_exit')
this.exitDisposable.dispose()
const survivors = await this.stopDescendants()
if (survivors.length > 0) {
throw new Error(`PTY cleanup failed (${reason}); surviving pids: ${survivors.map(member => member.pid).join(', ')}`)
}
await this.stopShell()
this.settleActive('session_exit')
this.exitDisposable.dispose()
}
}

View File

@@ -2,12 +2,14 @@ import { describe, expect, it, vi } from 'vitest'
import type { IPty, IPtyForkOptions } from 'node-pty'
import { Context } from 'cordis'
import Loader from '@cordisjs/plugin-loader'
import { Session, SessionId } from '@deepseek-ai/dsh-session'
import SessionStore, { Session, SessionId } from '@deepseek-ai/dsh-session'
import AgentRegistry from '@deepseek-ai/dsh-agent'
import type { Agent } from '@deepseek-ai/dsh-agent'
import SandboxProvider from '@deepseek-ai/dsh-sandbox'
import type { ConfinedArgv, SandboxPolicy } from '@deepseek-ai/dsh-sandbox'
import SandboxPolicyService from '@deepseek-ai/dsh-sandbox-policy'
import SandboxPolicyService, { setSandboxMode } from '@deepseek-ai/dsh-sandbox-policy'
import PtyService, { PtySessionId } from '@deepseek-ai/dsh-pty'
import type { PtyBackendSession } from '@deepseek-ai/dsh-pty'
import { LocalPtyBackend } from '@deepseek-ai/dsh-pty-local'
import * as ptyLocal from '@deepseek-ai/dsh-pty-local'
import type { ResolvedConfig } from '@deepseek-ai/dsh-pty-local/src/config.ts'
@@ -69,8 +71,9 @@ describe('LocalPtyBackend startup rollback', () => {
await ctx.plugin(SandboxPolicyService, { mode: 'read-only', workspaceRoot: '/tmp' })
const backend = new LocalPtyBackend(ctx, config(), inspector)
const controller = new AbortController()
controller.abort()
await expect(backend.spawn(spec(agent(ctx), controller.signal))).rejects.toThrow('spawn aborted')
const abortReason = new Error('spawn aborted')
controller.abort(abortReason)
await expect(backend.spawn(spec(agent(ctx), controller.signal))).rejects.toBe(abortReason)
await expect(backend.spawn(spec(agent(ctx)))).rejects.toThrow('empty argv')
})
@@ -169,12 +172,13 @@ describe('pty-local plugin shape', () => {
const loader = Object.create(Loader.prototype) as Loader
const unwrapped = loader.unwrapExports(ptyLocal) as Record<string, unknown>
expect(unwrapped.name).toBe('pty-local')
expect(unwrapped.inject).toEqual(['pty', 'sandbox', 'sandboxPolicy'])
expect(unwrapped.inject).toEqual(['agents', 'pty', 'sandbox', 'sandboxPolicy'])
expect(unwrapped.Config).toBeDefined()
})
it('validates config and registers the configured backend', async () => {
const ctx = new Context()
await ctx.plugin(AgentRegistry)
await ctx.plugin(PtyService)
await ctx.plugin(EmptySandbox)
await ctx.plugin(SandboxPolicyService, { mode: 'danger-full-access', workspaceRoot: '/tmp' })
@@ -183,4 +187,91 @@ describe('pty-local plugin shape', () => {
await fiber.dispose()
expect(ctx.pty.listBackends()).toEqual([])
})
it('ignores unrelated session events and mode changes without a live owner', async () => {
const ctx = new Context()
await ctx.plugin(SessionStore)
await ctx.plugin(AgentRegistry)
await ctx.plugin(PtyService)
await ctx.plugin(EmptySandbox)
await ctx.plugin(SandboxPolicyService, { mode: 'danger-full-access', workspaceRoot: '/tmp' })
await ctx.plugin(ptyLocal, config())
const session = ctx.sessions.create(SessionId('unowned-mode'))
expect(() => {
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
}).not.toThrow()
expect(() => { setSandboxMode(session, 'read-only') }).not.toThrow()
})
it('rejects an effective sandbox-mode change until the owner closes live terminals', async () => {
const ctx = new Context()
await ctx.plugin(SessionStore)
await ctx.plugin(AgentRegistry)
await ctx.plugin(PtyService)
await ctx.plugin(EmptySandbox)
await ctx.plugin(SandboxPolicyService, { mode: 'danger-full-access', workspaceRoot: '/tmp' })
await ctx.plugin(ptyLocal, config())
const session = ctx.sessions.create(SessionId('mode-owner'))
const owner: Agent = {
id: session.id, options: {}, session, status: 'idle', ctx,
send() {}, steer() {}, inject() {}, cancel() {}, whenIdle: () => Promise.resolve(),
}
ctx.agents.register(owner)
const backendSession = {
motd: '',
startSend: () => { throw new Error('unused') },
read: () => { throw new Error('unused') },
signal: () => Promise.resolve({ delivered: true, targetPgid: 1 }),
status: () => ({ kind: 'running' as const }),
close: () => Promise.resolve(),
} satisfies PtyBackendSession
ctx.pty.registerBackend({ type: 'stub', spawn: () => Promise.resolve(backendSession) })
const created = await ctx.pty.spawn(owner, { type: 'stub' })
expect(() => { setSandboxMode(session, 'danger-full-access') }).not.toThrow()
expect(() => { setSandboxMode(session, 'read-only') }).toThrow(
'cannot change sandbox mode from "danger-full-access" to "read-only" while persistent terminal sessions are open or being created; wait for creation to settle and close them first',
)
expect(session.events.filter(event => event.type === 'sandbox/mode')).toHaveLength(1)
await ctx.pty.kill(owner, created.sessionId)
expect(() => { setSandboxMode(session, 'read-only') }).not.toThrow()
expect(session.events.filter(event => event.type === 'sandbox/mode')).toHaveLength(2)
})
it('also fences sandbox-mode changes across unpublished PTY creation', async () => {
const ctx = new Context()
await ctx.plugin(SessionStore)
await ctx.plugin(AgentRegistry)
await ctx.plugin(PtyService)
await ctx.plugin(EmptySandbox)
await ctx.plugin(SandboxPolicyService, { mode: 'danger-full-access', workspaceRoot: '/tmp' })
await ctx.plugin(ptyLocal, config())
const session = ctx.sessions.create(SessionId('pending-mode-owner'))
const owner: Agent = {
id: session.id, options: {}, session, status: 'idle', ctx,
send() {}, steer() {}, inject() {}, cancel() {}, whenIdle: () => Promise.resolve(),
}
ctx.agents.register(owner)
const gate = Promise.withResolvers<PtyBackendSession>()
ctx.pty.registerBackend({ type: 'slow', spawn: () => gate.promise })
const spawning = ctx.pty.spawn(owner, { type: 'slow' })
expect(ctx.pty.hasOwnerActivity(owner)).toBe(true)
expect(() => { setSandboxMode(session, 'read-only') }).toThrow('open or being created')
gate.resolve({
motd: '',
startSend: () => { throw new Error('unused') },
read: () => { throw new Error('unused') },
signal: () => Promise.resolve({ delivered: true, targetPgid: 1 }),
status: () => ({ kind: 'running' as const }),
close: () => Promise.resolve(),
})
const created = await spawning
await ctx.pty.kill(owner, created.sessionId)
expect(ctx.pty.hasOwnerActivity(owner)).toBe(false)
})
})

View File

@@ -7,6 +7,7 @@ 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 from '@deepseek-ai/dsh-pty'
import type { PtySendOperation } from '@deepseek-ai/dsh-pty'
import SandboxProvider from '@deepseek-ai/dsh-sandbox'
import type { ConfinedArgv, SandboxPolicy } from '@deepseek-ai/dsh-sandbox'
import SandboxPolicyService from '@deepseek-ai/dsh-sandbox-policy'
@@ -62,6 +63,16 @@ async function harness(mode: 'danger-full-access' | 'workspace-write') {
return { ctx, root, agent, fiber, sandbox: ctx.sandbox as PassthroughSandbox }
}
async function waitForOutput(operation: PtySendOperation, expected: string): Promise<void> {
const deadline = Date.now() + 2_000
let output = ''
while (!output.includes(expected) && Date.now() < deadline) {
output += operation.readOutput().delta
if (!output.includes(expected)) await new Promise(resolve => setTimeout(resolve, 10))
}
expect(output).toContain(expected)
}
describe('pty-local real shell', () => {
it('persists cwd and environment across sends, scrubs secrets, and closes', async () => {
const previous = process.env.DSH_TEST_SECRET
@@ -119,4 +130,26 @@ describe('pty-local real shell', () => {
await ctx.pty.kill(agent, created.sessionId)
expect(() => process.kill(pid, 0)).toThrow()
}, 10_000)
it('cancels a raw-mode foreground process with a real SIGINT', async () => {
const { ctx, agent } = await harness('danger-full-access')
const created = await ctx.pty.spawn(agent, { type: 'shell' })
const controller = new AbortController()
const foreground = ctx.pty.startSend(agent, created.sessionId, {
text: 'python3 -c \'import signal,sys,termios,time; signal.signal(signal.SIGINT, lambda *_: (print("SIGINT_SEEN", flush=True), sys.exit(0))); attrs=termios.tcgetattr(0); attrs[3] &= ~termios.ISIG; termios.tcsetattr(0, termios.TCSANOW, attrs); print("RAW_READY", flush=True); time.sleep(60)\'',
submit: true,
signal: controller.signal,
})
await waitForOutput(foreground, 'RAW_READY')
controller.abort()
const result = await foreground.done
expect(result.waitReason).toBe('stdin_read')
const after = await ctx.pty.startSend(agent, created.sessionId, {
text: 'echo AFTER_SIGINT',
submit: true,
}).done
expect(after.viewport).toContain('AFTER_SIGINT')
expect(after.waitReason).toBe('stdin_read')
await ctx.pty.kill(agent, created.sessionId)
}, 10_000)
})

View File

@@ -7,7 +7,7 @@ describe('TerminalSanitizer', () => {
expect(sanitizer.push('red\x1b[3')).toEqual({ text: 'red', prompt: false })
expect(sanitizer.push('1m text\x1b[0m\r\n')).toEqual({ text: ' text\n', prompt: false })
expect(sanitizer.push('\x1b]133;')).toEqual({ text: '', prompt: false })
expect(sanitizer.push('D;0\x07dsh> ')).toEqual({ text: 'dsh> ', prompt: true })
expect(sanitizer.push('D;0\x07dsh> ')).toEqual({ text: 'dsh> ', prompt: true, promptText: true })
})
it('drops unrelated OSC, short escapes, BEL, and incomplete trailing escape', () => {
@@ -25,6 +25,20 @@ describe('TerminalSanitizer', () => {
expect(normalizeTerminalText('a\r\nb\rc\x07')).toBe('a\nb\nc')
})
it('carries a trailing carriage return across data chunks and flushes standalone CR', () => {
const sanitizer = new TerminalSanitizer(64)
expect(sanitizer.push('a\r')).toEqual({ text: 'a', prompt: false })
expect(sanitizer.push('\nb')).toEqual({ text: '\nb', prompt: false })
expect(sanitizer.push('\r')).toEqual({ text: '', prompt: false })
expect(sanitizer.flush()).toBe('\n')
})
it('reports printable prompt text that follows a marker in a later chunk', () => {
const sanitizer = new TerminalSanitizer(64)
expect(sanitizer.push('\x1b]133;D;0\x07')).toEqual({ text: '', prompt: true })
expect(sanitizer.push('dsh> ')).toEqual({ text: 'dsh> ', prompt: false, promptText: true })
})
it('bounds and discards unterminated control sequences through their terminators', () => {
const oscBel = new TerminalSanitizer(8)
expect(oscBel.push(`\x1b]0;${'x'.repeat(16)}`)).toEqual({ text: '', prompt: false })

View File

@@ -15,6 +15,7 @@ class FakeTerminal {
kills: string[] = []
throwWrite = false
throwKill = false
autoExitOnKill = true
private dataListeners = new Set<(data: string) => void>()
private exitListeners = new Set<(event: { exitCode: number; signal?: number }) => void>()
@@ -44,7 +45,7 @@ class FakeTerminal {
kill(signal?: string): void {
if (this.throwKill) throw new Error('kill failed')
this.kills.push(signal ?? 'SIGHUP')
this.emitExit(0, signal === 'SIGKILL' ? 9 : 15)
if (this.autoExitOnKill) this.emitExit(0, signal === 'SIGKILL' ? 9 : 15)
}
resize() {}
@@ -148,7 +149,7 @@ describe('LocalPtySession readiness and output', () => {
expect(() => session.startSend({ text: '', submit: false })).toThrow('has exited')
})
it('cancels with Ctrl-C, observes AbortSignal, and contains write failures', async () => {
it('cancels with foreground-group SIGINT, observes AbortSignal, and contains write failures', async () => {
vi.useFakeTimers()
const terminal = new FakeTerminal()
const inspector = new FakeInspector()
@@ -159,7 +160,8 @@ describe('LocalPtySession readiness and output', () => {
const operation = session.startSend({ text: 'sleep', submit: true, signal: controller.signal })
expect(() => session.startSend({ text: 'again', submit: true })).toThrow('active send')
controller.abort()
expect(terminal.writes.at(-1)).toBe('\x03')
expect(inspector.groups).toContainEqual([456, 'SIGINT'])
expect(terminal.writes).not.toContain('\x03')
terminal.emitData('\x1b]133;D;130\x07dsh> ')
await vi.advanceTimersByTimeAsync(10)
await operation.done
@@ -196,11 +198,13 @@ describe('LocalPtySession readiness and output', () => {
operationInternal.append('')
const sessionInternal = session as unknown as {
pollReadiness(operation: PtySendOperation): void
interrupt(operation: PtySendOperation): void
statusValue: PtySessionStatus
appendOutput(text: string): void
}
sessionInternal.appendOutput('')
sessionInternal.pollReadiness({} as PtySendOperation)
sessionInternal.interrupt({} as PtySendOperation)
sessionInternal.statusValue = { kind: 'exited', exitCode: 2, signal: null }
sessionInternal.pollReadiness(operation)
await operation.done
@@ -212,12 +216,23 @@ describe('LocalPtySession readiness and output', () => {
expect(unknown.status()).toEqual({ kind: 'exited', exitCode: 1, signal: null })
const cancelTerminal = new FakeTerminal()
const cancel = new LocalPtySession(cancelTerminal.asPty(), new FakeInspector(), config())
const cancelInspector = new FakeInspector()
const cancel = new LocalPtySession(cancelTerminal.asPty(), cancelInspector, config())
await initialize(cancel, cancelTerminal)
const cancellable = cancel.startSend({ text: '', submit: false })
cancelTerminal.throwWrite = true
cancelInspector.throwGroup = true
expect(cancellable.cancel()).toBe(true)
await expect(cancellable.done).rejects.toThrow('write failed')
await expect(cancellable.done).rejects.toThrow('group failed')
expect(cancellable.cancel()).toBe(false)
const missingGroupTerminal = new FakeTerminal()
const missingGroupInspector = new FakeInspector()
const missingGroup = new LocalPtySession(missingGroupTerminal.asPty(), missingGroupInspector, config())
await initialize(missingGroup, missingGroupTerminal)
missingGroupInspector.pgid = undefined
const unresolved = missingGroup.startSend({ text: '', submit: false })
expect(unresolved.cancel()).toBe(true)
await expect(unresolved.done).rejects.toThrow('cannot resolve foreground process group')
})
it('does not treat zero-output startup silence as readiness and fails on startup timeout', async () => {
@@ -239,6 +254,23 @@ describe('LocalPtySession readiness and output', () => {
await timedOut
})
it('waits for printable prompt text when the startup marker is split from PS1', async () => {
vi.useFakeTimers()
const terminal = new FakeTerminal()
const session = new LocalPtySession(terminal.asPty(), new FakeInspector(), config())
let settled = false
const initializing = session.initialize().then(() => { settled = true })
terminal.emitData('\x1b]133;D;0\x07')
await vi.advanceTimersByTimeAsync(20)
expect(settled).toBe(false)
terminal.emitData('dsh> ')
await vi.advanceTimersByTimeAsync(10)
await initializing
expect(session.motd).toBe('dsh> ')
})
it('trusts prompt markers only while the startup shell owns the foreground group', async () => {
vi.useFakeTimers()
const terminal = new FakeTerminal()
@@ -328,14 +360,15 @@ describe('LocalPtySession bounds, signals, and teardown', () => {
// readiness poll would otherwise mis-settle this as stdin_read once close
// begins, so teardown must stop polling before its grace period.
terminal.emitData('\x1b]133;D;0\x07dsh> ')
terminal.throwKill = true
terminal.autoExitOnKill = false
const closing = session.close('mid-send')
await vi.advanceTimersByTimeAsync(60)
await vi.advanceTimersByTimeAsync(20)
terminal.emitExit(0, 15)
expect((await operation.done).waitReason).toBe('session_exit')
await closing
})
it('waits for SIGKILL recipients to leave the process table after the shell exits', async () => {
it('keeps the shell alive until SIGKILL recipients leave the process table', async () => {
vi.useFakeTimers()
const terminal = new FakeTerminal()
const inspector = new FakeInspector()
@@ -348,11 +381,59 @@ describe('LocalPtySession bounds, signals, and teardown', () => {
const closing = session.close('test').then(() => { settled = true })
await vi.advanceTimersByTimeAsync(20)
expect(inspector.processes).toContainEqual([124, 'SIGKILL'])
expect(terminal.kills).toEqual([])
expect(settled).toBe(false)
inspector.alive.delete(124)
await vi.advanceTimersByTimeAsync(20)
await closing
expect(terminal.kills).toEqual(['SIGTERM'])
expect(settled).toBe(true)
})
it('rescans for descendants forked during TERM before stopping the shell', async () => {
const terminal = new FakeTerminal()
const inspector = new FakeInspector()
let reads = 0
inspector.processTree = () => {
reads += 1
if (reads === 1) {
inspector.alive.add(124)
return [{ pid: 124, started: 'first' }]
}
if (reads === 2) {
inspector.alive.add(125)
return [{ pid: 125, started: 'late' }]
}
return []
}
const session = new LocalPtySession(terminal.asPty(), inspector, config())
await session.close('test')
expect(inspector.processes).toEqual([[124, 'SIGTERM'], [125, 'SIGKILL']])
expect(terminal.kills).toEqual(['SIGTERM'])
})
it('allows teardown to retry after a descendant-survivor failure', async () => {
vi.useFakeTimers()
const terminal = new FakeTerminal()
const inspector = new FakeInspector()
inspector.members = [{ pid: 124, started: 'child' }]
inspector.alive.add(124)
inspector.removeOnSignal = false
const session = new LocalPtySession(terminal.asPty(), inspector, config({ disposeGraceMs: 10 }))
const first = session.close('first')
const rejected = expect(first).rejects.toThrow('surviving pids: 124')
await vi.advanceTimersByTimeAsync(25)
await rejected
expect(terminal.kills).toEqual([])
inspector.alive.delete(124)
const second = session.close('retry')
expect(second).not.toBe(first)
await second
expect(terminal.kills).toEqual(['SIGTERM'])
})
})

View File

@@ -17,6 +17,12 @@
{
"path": "../../../vendor/schemastery"
},
{
"path": "../../core/agent"
},
{
"path": "../../core/session"
},
{
"path": "../pty"
},

View File

@@ -5,10 +5,12 @@ Owner-scoped persistent PTY seam. `PtyService` registers as `ctx.pty`, mints opa
## Contract
- Backends register one stable `type` and return an unpublished `PtyBackendSession`; failed or cancelled setup must clean partial resources.
- Spawn cancellation preserves the caller's exact abort reason. Service disposal and owner loss remain distinct machine-routable failures after backend setup.
- `hasOwnerActivity(owner)` spans unpublished setup through final close, so lifecycle policy can fence the exact owner without a publication race.
- 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.
- `kill()` and disposal resolve only after the backend's captured process tree is quiescent. A cleanup failure rejects instead of claiming success and leaves the close retriable.
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.

View File

@@ -77,10 +77,6 @@ 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
@@ -96,6 +92,7 @@ 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 pendingSpawns = new Map<Agent, number>()
private readonly ownerCleanups = new Map<Agent, () => Promise<void> | void>()
private readonly disposedOwners = new WeakSet<Agent>()
private nextId = 0
@@ -142,13 +139,13 @@ export class PtyService extends Service {
*/
async spawn(owner: Agent, request: PtySpawnRequest, signal?: AbortSignal): Promise<PtySpawnResult> {
this.assertActive()
signal?.throwIfAborted()
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 releaseSpawn = this.reserveSpawn(owner)
const sessionId = PtySessionId(`pty-${++this.nextId}`)
let session: PtyBackendSession | undefined
try {
@@ -160,7 +157,11 @@ export class PtyService extends Service {
...request.cwd !== undefined ? { cwd: request.cwd } : {},
...signal !== undefined ? { signal } : {},
})
if (this.disposing || isAborted(signal) || !this.isLiveOwner(owner)) {
signal?.throwIfAborted()
if (this.disposing) {
throw new PtyError('PTY service is disposing', 'SERVICE_DISPOSING')
}
if (!this.isLiveOwner(owner)) {
throw new PtyError('PTY owner is no longer live', 'OWNER_NOT_LIVE')
}
const record: SessionRecord = {
@@ -184,10 +185,21 @@ export class PtyService extends Service {
}
throw error
} finally {
releaseSpawn()
releaseName()
}
}
/**
* Test whether an exact owner has a published session or unpublished spawn.
* @param owner - exact live owner to inspect.
* @returns true across the entire spawn-to-close interval, with no publication gap.
*/
hasOwnerActivity(owner: Agent): boolean {
return (this.pendingSpawns.get(owner) ?? 0) > 0
|| [...this.sessions.values()].some(record => record.owner === owner)
}
/**
* Start one exclusive interactive send.
* @param owner - exact session owner.
@@ -302,6 +314,15 @@ export class PtyService extends Service {
}
}
private reserveSpawn(owner: Agent): () => void {
this.pendingSpawns.set(owner, (this.pendingSpawns.get(owner) ?? 0) + 1)
return () => {
const remaining = (this.pendingSpawns.get(owner) ?? 1) - 1
if (remaining === 0) this.pendingSpawns.delete(owner)
else this.pendingSpawns.set(owner, remaining)
}
}
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')
@@ -339,6 +360,7 @@ export class PtyService extends Service {
} finally {
this.backends.clear()
this.reservedNames.clear()
this.pendingSpawns.clear()
const cleanups = [...this.ownerCleanups.values()]
this.ownerCleanups.clear()
await Promise.all(cleanups.map(cleanup => Promise.resolve(cleanup())))

View File

@@ -178,8 +178,9 @@ describe('PtyService ownership and lifecycle', () => {
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')
const abortReason = new Error('spawn aborted')
aborted.abort(abortReason)
await expect(ctx.pty.spawn(owner, { type: 'stub' }, aborted.signal)).rejects.toBe(abortReason)
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 })
@@ -211,6 +212,41 @@ describe('PtyService ownership and lifecycle', () => {
expect(session.closed).toEqual(['PTY spawn rolled back'])
})
it('preserves caller cancellation when a pending backend spawn completes', 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 controller = new AbortController()
const reason = new Error('cancelled by caller')
const pending = ctx.pty.spawn(owner, { type: 'slow' }, controller.signal)
controller.abort(reason)
gate.resolve(session)
await expect(pending).rejects.toBe(reason)
expect(session.closed).toEqual(['PTY spawn rolled back'])
expect(ctx.agents.get(owner.id)).toBe(owner)
})
it('rolls back an unpublished backend session when service disposal wins', 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' })
await disposePtyService(ctx)
gate.resolve(session)
await expect(pending).rejects.toMatchObject({ code: 'SERVICE_DISPOSING' })
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>()

View File

@@ -2,7 +2,16 @@
Six model-facing tools over `ctx.pty`: `terminal_open`, `terminal_send`, `terminal_read`, `terminal_signal`, `terminal_close`, and `terminal_list`. Every operation requires the exact initiating `Agent`, so a model cannot address another agent's terminal even if it learns the id.
`terminal_send(run_in_background: true)` reuses `ctx.tasks`; task preflight occurs before any terminal write, completion is collected with `task_output`, and `task_kill` requests `Ctrl-C`. Foreground sends use terminal ACP cards; lifecycle, history, signal, and list calls use generic cards.
`terminal_send(run_in_background: true)` reuses `ctx.tasks`; task preflight and the PTY service's exclusive per-session send reservation occur before the task id is returned, completion is collected with `task_output`, and `task_kill` delivers `SIGINT` to the foreground process group. Foreground sends use terminal ACP call/result cards. Background sends use a generic execute card; open, read, signal, close, and list use generic `execute`, `read`, `execute`, `delete`, and `read` cards respectively. None declares source locations.
## Config
| key | default | meaning |
|---|---:|---|
| `enableRunInBackground` | `true` | expose and accept `run_in_background`; false omits the schema field and rejects a forced undeclared argument |
| `maxResultBytes` | `262144` | UTF-8 cap for each complete terminal result or PTY task output after wait, session, pagination, truncation, and task-status metadata |
Both values are validated at load. When a result exceeds `maxResultBytes`, rendering reserves space for control metadata and a truncation marker when they fit; cuts preserve UTF-8 boundaries.
## Model Experience
@@ -44,11 +53,11 @@ Prefix-stable while tool visibility and definitions are unchanged.
#### What the model sees
Spawn returns the id and bounded MOTD. Send/read return bounded terminal text plus readiness/history markers. Background mode returns a generic task id. Results remain in session history until compaction; incremental task reads do not repeat consumed output.
Spawn returns the id and bounded MOTD. Send/read return bounded terminal text plus readiness/history markers. Background mode returns a generic task id. Every complete result is capped by `maxResultBytes`, including generic task status text. Results remain in session history until compaction; incremental task reads do not repeat consumed output.
#### Token effect
Data-dependent and bounded by the backend; each returned result remains in history until compaction.
Data-dependent and bounded by `maxResultBytes`; each returned result remains in history until compaction.
#### KV Cache effect

View File

@@ -26,11 +26,15 @@
"src"
],
"license": "BSD-3-Clause",
"dependencies": {
"schemastery": "^3.18.0"
},
"peerDependencies": {
"@deepseek-ai/dsh-agent": "^0.0.1",
"@deepseek-ai/dsh-invariants": "^0.0.1",
"@deepseek-ai/dsh-llm": "^0.0.1",
"@deepseek-ai/dsh-pty": "^0.0.1",
"@deepseek-ai/dsh-retention": "^0.0.1",
"@deepseek-ai/dsh-system-prompt": "^0.0.1",
"@deepseek-ai/dsh-tasks": "^0.0.1",
"@deepseek-ai/dsh-tools": "^0.0.1",
@@ -44,6 +48,7 @@
"@deepseek-ai/dsh-llm": "workspace:^",
"@deepseek-ai/dsh-pty": "workspace:^",
"@deepseek-ai/dsh-pty-local": "workspace:^",
"@deepseek-ai/dsh-retention": "workspace:^",
"@deepseek-ai/dsh-sandbox": "workspace:^",
"@deepseek-ai/dsh-sandbox-policy": "workspace:^",
"@deepseek-ai/dsh-session": "workspace:^",

View File

@@ -5,6 +5,7 @@
*/
import { Context } from 'cordis'
import z from 'schemastery'
import type { Agent } from '@deepseek-ai/dsh-agent'
import type { ContentBlock } from '@deepseek-ai/dsh-llm'
import { PtySessionId } from '@deepseek-ai/dsh-pty'
@@ -12,7 +13,7 @@ import type { PtySendResult, PtySessionId as PtySessionIdType, PtySignal } from
import type {} from '@deepseek-ai/dsh-tasks'
import { defineTool } from '@deepseek-ai/dsh-tools'
import type { ToolExecutionResult, ToolResult } from '@deepseek-ai/dsh-tools'
import { renderList, renderRead, renderSend, renderSendRead, renderSpawn } from './render.ts'
import { boundTerminalText, renderList, renderRead, renderSend, renderSendRead, renderSpawn } from './render.ts'
declare module '@deepseek-ai/dsh-tasks' {
interface TaskKindMap {
@@ -25,6 +26,23 @@ export const name = 'tool-pty'
/** Required capability, registry, and prompt services. */
export const inject = ['pty', 'tools', 'systemPrompt']
/** Default cap for one complete model-facing terminal result. */
export const DEFAULT_MAX_RESULT_BYTES = 256 * 1024
/** Model-facing terminal tool configuration. */
export interface Config {
/** Expose `run_in_background` and accept background sends (default true). */
enableRunInBackground?: boolean
/** Maximum UTF-8 bytes in one complete terminal or task-output result. */
maxResultBytes?: number
}
/** Schemastery configuration for the terminal tool consumer. */
export const Config: z<Config> = z.object({
enableRunInBackground: z.boolean().default(true),
maxResultBytes: z.number().step(1).min(1).max(Number.MAX_SAFE_INTEGER).default(DEFAULT_MAX_RESULT_BYTES),
})
interface SpawnArgs {
type: string
name?: string
@@ -62,8 +80,8 @@ function sessionId(args: SessionArgs): PtySessionIdType {
return PtySessionId(args.sessionId)
}
function textResult(text: string): ContentBlock[] {
return [{ type: 'text', text }]
function textResult(text: string, maxBytes: number): ContentBlock[] {
return [{ type: 'text', text: boundTerminalText(text, maxBytes) }]
}
function rawResultText(result: ToolResult): string | undefined {
@@ -79,7 +97,12 @@ function sendDetail(result: PtySendResult): string {
}
/** Register all terminal tools and the minimal usage guidance. */
export function apply(ctx: Context): void {
export function apply(ctx: Context, config: Config = {}): void {
const enableRunInBackground = config.enableRunInBackground ?? true
const maxResultBytes = config.maxResultBytes ?? DEFAULT_MAX_RESULT_BYTES
if (!Number.isSafeInteger(maxResultBytes) || maxResultBytes <= 0) {
throw new Error('tool-pty: maxResultBytes must be a positive safe integer')
}
ctx.systemPrompt.section({
name: 'tool:pty',
order: 106,
@@ -101,7 +124,7 @@ export function apply(ctx: Context): void {
...args.name !== undefined ? { name: args.name } : {},
...args.cwd !== undefined ? { cwd: args.cwd } : {},
}, exec.signal)
return textResult(renderSpawn(result))
return textResult(renderSpawn(result, maxResultBytes), maxResultBytes)
},
presentCall: (args) => {
const parsed = args
@@ -111,18 +134,22 @@ export function apply(ctx: Context): void {
ctx.tools.register(defineTool({
name: 'terminal_send',
description: 'Send text to a persistent terminal. By default Enter is submitted and the call waits for a prompt, stdin wait, output silence, timeout, or session exit. Background mode returns a task id for task_output/task_kill.',
description: 'Send text to a persistent terminal. By default Enter is submitted and the call waits for a prompt, stdin wait, output silence, timeout, or session exit.'
+ (enableRunInBackground ? ' Background mode returns a task id for task_output/task_kill.' : ''),
parameters: {
sessionId: { type: 'string', required: true, description: 'Terminal session id returned by terminal_open or terminal_list.' },
text: { type: 'string', required: true, description: 'UTF-8 text to write to the terminal.' },
submit: { type: 'boolean', description: 'Submit Enter after text (default true). Set false for control characters or incomplete REPL input.' },
run_in_background: { type: 'boolean', description: 'Return a task id immediately; collect with task_output or stop with task_kill.' },
...enableRunInBackground
? { run_in_background: { type: 'boolean' as const, description: 'Return a task id immediately; collect with task_output or stop with task_kill.' } }
: {},
},
async execute(args: SendArgs, exec): Promise<ToolExecutionResult> {
const owner = requireAgent(exec.agent)
const id = sessionId(args)
const request = { text: args.text, submit: args.submit ?? true }
if (args.run_in_background === true) {
if (!enableRunInBackground) throw new Error('background terminal sends are disabled by tool-pty configuration')
const tasks = ctx.get('tasks')
if (tasks === undefined) throw new Error('background terminal sends require @deepseek-ai/dsh-tasks and @deepseek-ai/dsh-tool-tasks')
let cancelRequested = false
@@ -130,6 +157,7 @@ export function apply(ctx: Context): void {
kind: 'pty-send',
label: `${id}: ${args.text || '(input)'}`,
owner,
outputLimitBytes: maxResultBytes,
run: () => {
const operation = ctx.pty.startSend(owner, id, request)
return {
@@ -145,12 +173,12 @@ export function apply(ctx: Context): void {
}
},
})
return { content: textResult(`started background task ${taskId}`), isError: false }
return { content: textResult(`started background task ${taskId}`, maxResultBytes), isError: false }
}
const operation = ctx.pty.startSend(owner, id, { ...request, signal: exec.signal })
const result = await operation.done
if (exec.signal.aborted) throw new Error('terminal send aborted')
return { content: textResult(renderSend(result)), isError: false, meta: result }
return { content: textResult(renderSend(result, maxResultBytes), maxResultBytes), isError: false, meta: result }
},
presentCall(args) {
const parsed = args as Partial<SendArgs>
@@ -179,7 +207,7 @@ export function apply(ctx: Context): void {
...args.offset !== undefined ? { offset: args.offset } : {},
...args.count !== undefined ? { count: args.count } : {},
})
return Promise.resolve(textResult(renderRead(result)))
return Promise.resolve(textResult(renderRead(result, maxResultBytes), maxResultBytes))
},
presentCall: args => ({ card: 'generic', title: `Read terminal ${(args).sessionId}`, kind: 'read', rawInput: args }),
}))
@@ -193,7 +221,7 @@ export function apply(ctx: Context): void {
},
async execute(args: SignalArgs, exec) {
const result = await ctx.pty.signal(requireAgent(exec.agent), sessionId(args), args.signal)
return textResult(`delivered ${args.signal} to foreground process group ${result.targetPgid}`)
return textResult(`delivered ${args.signal} to foreground process group ${result.targetPgid}`, maxResultBytes)
},
presentCall: args => ({ card: 'generic', title: `Signal terminal ${(args as SignalArgs).sessionId}`, kind: 'execute', rawInput: args }),
}))
@@ -207,7 +235,7 @@ export function apply(ctx: Context): void {
async execute(args: SessionArgs, exec) {
const id = sessionId(args)
const closed = await ctx.pty.kill(requireAgent(exec.agent), id)
return textResult(closed ? `closed terminal session ${id}` : `terminal session ${id} was already closing`)
return textResult(closed ? `closed terminal session ${id}` : `terminal session ${id} was already closing`, maxResultBytes)
},
presentCall: args => ({ card: 'generic', title: `Close terminal ${(args).sessionId}`, kind: 'delete' }),
}))
@@ -217,7 +245,7 @@ export function apply(ctx: Context): void {
description: 'List persistent terminal sessions owned by the current agent.',
parameters: {},
execute(_args: Record<string, never>, exec) {
return Promise.resolve(textResult(renderList(ctx.pty.list(requireAgent(exec.agent)))))
return Promise.resolve(textResult(renderList(ctx.pty.list(requireAgent(exec.agent)), maxResultBytes), maxResultBytes))
},
presentCall: () => ({ card: 'generic', title: 'List terminal sessions', kind: 'read' }),
}))

View File

@@ -1,57 +1,128 @@
/** Model and ACP rendering for persistent terminal tool results. */
import { TextRetainer } from '@deepseek-ai/dsh-retention'
import type { PtyReadResult, PtySendRead, PtySendResult, PtySessionSnapshot, PtySpawnResult } from '@deepseek-ai/dsh-pty'
const encoder = new TextEncoder()
const TRUNCATED = '\n[output truncated]'
function byteLength(text: string): number {
return encoder.encode(text).byteLength
}
function retain(text: string, maxBytes: number, kind: 'head' | 'tail'): string {
const retainer = new TextRetainer({ kind, maxBytes })
retainer.push(text)
return retainer.finish().text
}
function fitWithSuffix(content: string, suffix: string, maxBytes: number): string {
const fixedBytes = byteLength(suffix)
if (fixedBytes >= maxBytes) return retain(suffix, maxBytes, 'tail')
return `${retain(content, maxBytes - fixedBytes, 'tail')}${suffix}`
}
function fitWithPrefix(prefix: string, content: string, maxBytes: number): string {
const fixed = `${prefix}${TRUNCATED}`
const fixedBytes = byteLength(fixed)
if (fixedBytes >= maxBytes) return retain(fixed, maxBytes, 'head')
return `${prefix}${retain(content, maxBytes - fixedBytes, 'tail')}${TRUNCATED}`
}
function boundBodyWithSuffix(
content: string,
metadata: string,
upstreamTruncated: boolean,
maxBytes: number,
): string {
const suffix = `${metadata}${upstreamTruncated ? TRUNCATED : ''}`
const complete = `${content}${suffix}`
if (byteLength(complete) <= maxBytes) return complete
return fitWithSuffix(content, `${metadata}${TRUNCATED}`, maxBytes)
}
/**
* Bound one complete terminal acknowledgement while preserving UTF-8 cuts.
* @param text - complete acknowledgement text.
* @param maxBytes - positive final result cap.
* @returns bounded text with a truncation marker when it fits.
*/
export function boundTerminalText(text: string, maxBytes: number): string {
if (byteLength(text) <= maxBytes) return text
const markerBytes = byteLength(TRUNCATED)
if (markerBytes >= maxBytes) return retain(TRUNCATED, maxBytes, 'tail')
return `${retain(text, maxBytes - markerBytes, 'head')}${TRUNCATED}`
}
/**
* Render one created session and its bounded MOTD.
* @param result - published spawn result.
* @param maxBytes - complete UTF-8 result cap.
* @returns Model-facing session acknowledgement.
*/
export function renderSpawn(result: PtySpawnResult): string {
export function renderSpawn(result: PtySpawnResult, maxBytes: number): string {
const label = result.name === undefined ? result.sessionId : `${result.sessionId} (${result.name})`
return `started terminal session ${label} [type: ${result.type}]\n${result.motd || '(no startup output)'}`
const prefix = `started terminal session ${label} [type: ${result.type}]\n`
const motd = result.motd || '(no startup output)'
const complete = `${prefix}${motd}`
return byteLength(complete) <= maxBytes ? complete : fitWithPrefix(prefix, motd, maxBytes)
}
/**
* Render one settled interactive send.
* @param result - settled send outcome.
* @param maxBytes - complete UTF-8 result cap.
* @returns Terminal output plus wait/session markers.
*/
export function renderSend(result: PtySendResult): string {
export function renderSend(result: PtySendResult, maxBytes: number): string {
const output = result.viewport || '(no new output)'
const status = result.sessionStatus.kind === 'running'
? 'running'
: `exited code=${result.sessionStatus.exitCode ?? 'null'} signal=${result.sessionStatus.signal ?? 'null'}`
return `${output}\n[wait: ${result.waitReason}]\n[session: ${status}]${result.truncated ? '\n[output truncated]' : ''}`
return boundBodyWithSuffix(
output,
`\n[wait: ${result.waitReason}]\n[session: ${status}]`,
result.truncated,
maxBytes,
)
}
/**
* Render one incremental background operation read.
* @param read - consuming operation delta.
* @returns Delta plus truncation marker when needed.
* @returns Delta plus its upstream truncation marker. The generic task control
* applies the producer's complete-result cap after adding task status.
*/
export function renderSendRead(read: PtySendRead): string {
return `${read.delta}${read.truncated ? `${read.delta.endsWith('\n') || read.delta.length === 0 ? '' : '\n'}[output truncated]` : ''}`
const separator = read.delta.endsWith('\n') || read.delta.length === 0 ? '' : '\n'
return `${read.delta}${read.truncated ? `${separator}[output truncated]` : ''}`
}
/**
* Render one bounded historical page.
* @param result - retained scrollback page.
* @param maxBytes - complete UTF-8 result cap.
* @returns Page text plus pagination and truncation markers.
*/
export function renderRead(result: PtyReadResult): string {
export function renderRead(result: PtyReadResult, maxBytes: number): string {
const output = result.text || '(no retained output)'
return `${output}\n[lines: ${result.lineBegin}-${result.lineEnd} of ${result.totalLines}]${result.truncated ? '\n[output truncated]' : ''}`
return boundBodyWithSuffix(
output,
`\n[lines: ${result.lineBegin}-${result.lineEnd} of ${result.totalLines}]`,
result.truncated,
maxBytes,
)
}
/**
* Render owner-visible live sessions.
* @param sessions - fresh owner-scoped snapshots.
* @param maxBytes - complete UTF-8 result cap.
* @returns One line per session or the empty marker.
*/
export function renderList(sessions: PtySessionSnapshot[]): string {
export function renderList(sessions: PtySessionSnapshot[], maxBytes: number): string {
if (sessions.length === 0) return '(no terminal sessions)'
return sessions.map((session) => {
const text = sessions.map((session) => {
const name = session.name === undefined ? '' : ` (${session.name})`
const pid = session.pid === undefined ? '' : ` pid=${session.pid}`
const status = session.status.kind === 'running'
@@ -59,4 +130,5 @@ export function renderList(sessions: PtySessionSnapshot[]): string {
: `exited code=${session.status.exitCode ?? 'null'} signal=${session.status.signal ?? 'null'}`
return `${session.sessionId}${name} [${session.type}] ${status}${pid}`
}).join('\n')
return boundBodyWithSuffix(text, '', false, maxBytes)
}

View File

@@ -1,23 +1,23 @@
import { describe, expect, it } from 'vitest'
import { PtySessionId } from '@deepseek-ai/dsh-pty'
import { renderList, renderRead, renderSend, renderSendRead, renderSpawn } from '@deepseek-ai/dsh-tool-pty/src/render.ts'
import { boundTerminalText, renderList, renderRead, renderSend, renderSendRead, renderSpawn } from '@deepseek-ai/dsh-tool-pty/src/render.ts'
describe('tool-pty rendering', () => {
it('renders spawn with and without names or MOTD', () => {
expect(renderSpawn({ sessionId: PtySessionId('pty-1'), type: 'shell', status: { kind: 'running' }, motd: '' }))
expect(renderSpawn({ sessionId: PtySessionId('pty-1'), type: 'shell', status: { kind: 'running' }, motd: '' }, 1024))
.toBe('started terminal session pty-1 [type: shell]\n(no startup output)')
expect(renderSpawn({ sessionId: PtySessionId('pty-2'), name: 'main', type: 'shell', pid: 2, status: { kind: 'running' }, motd: 'ready' }))
expect(renderSpawn({ sessionId: PtySessionId('pty-2'), name: 'main', type: 'shell', pid: 2, status: { kind: 'running' }, motd: 'ready' }, 1024))
.toContain('pty-2 (main)')
})
it('renders running, exited, empty, and truncated sends', () => {
expect(renderSend({ viewport: '', waitReason: 'timeout', sessionStatus: { kind: 'running' }, truncated: true }))
expect(renderSend({ viewport: '', waitReason: 'timeout', sessionStatus: { kind: 'running' }, truncated: true }, 1024))
.toBe('(no new output)\n[wait: timeout]\n[session: running]\n[output truncated]')
expect(renderSend({ viewport: 'bye', waitReason: 'session_exit', sessionStatus: { kind: 'exited', exitCode: null, signal: 'SIGTERM' }, truncated: false }))
expect(renderSend({ viewport: 'bye', waitReason: 'session_exit', sessionStatus: { kind: 'exited', exitCode: null, signal: 'SIGTERM' }, truncated: false }, 1024))
.toContain('exited code=null signal=SIGTERM')
expect(renderSend({ viewport: 'bye', waitReason: 'session_exit', sessionStatus: { kind: 'exited', exitCode: 2, signal: null }, truncated: false }))
expect(renderSend({ viewport: 'bye', waitReason: 'session_exit', sessionStatus: { kind: 'exited', exitCode: 2, signal: null }, truncated: false }, 1024))
.toContain('exited code=2 signal=null')
expect(renderSend({ viewport: 'bye', waitReason: 'session_exit', sessionStatus: { kind: 'exited', exitCode: null, signal: null }, truncated: false }))
expect(renderSend({ viewport: 'bye', waitReason: 'session_exit', sessionStatus: { kind: 'exited', exitCode: null, signal: null }, truncated: false }, 1024))
.toContain('exited code=null signal=null')
expect(renderSendRead({ delta: '', truncated: true })).toBe('[output truncated]')
expect(renderSendRead({ delta: 'x', truncated: true })).toBe('x\n[output truncated]')
@@ -26,14 +26,48 @@ describe('tool-pty rendering', () => {
})
it('renders history and every list status shape', () => {
expect(renderRead({ text: '', totalLines: 0, lineBegin: 0, lineEnd: 0, truncated: true }))
expect(renderRead({ text: '', totalLines: 0, lineBegin: 0, lineEnd: 0, truncated: true }, 1024))
.toBe('(no retained output)\n[lines: 0-0 of 0]\n[output truncated]')
expect(renderList([])).toBe('(no terminal sessions)')
expect(renderList([], 1024)).toBe('(no terminal sessions)')
expect(renderList([
{ sessionId: PtySessionId('pty-1'), type: 'shell', status: { kind: 'running' } },
{ sessionId: PtySessionId('pty-2'), name: 'done', type: 'shell', pid: 9, status: { kind: 'exited', exitCode: 2, signal: null } },
{ sessionId: PtySessionId('pty-3'), type: 'shell', status: { kind: 'exited', exitCode: null, signal: 'SIGTERM' } },
{ sessionId: PtySessionId('pty-4'), type: 'shell', status: { kind: 'exited', exitCode: null, signal: null } },
])).toBe('pty-1 [shell] running\npty-2 (done) [shell] exited code=2 signal=null pid=9\npty-3 [shell] exited code=null signal=SIGTERM\npty-4 [shell] exited code=null signal=null')
], 1024)).toBe('pty-1 [shell] running\npty-2 (done) [shell] exited code=2 signal=null pid=9\npty-3 [shell] exited code=null signal=SIGTERM\npty-4 [shell] exited code=null signal=null')
})
it('bounds complete UTF-8 results while retaining terminal metadata when it fits', () => {
const send = renderSend({
viewport: `prefix-${'界'.repeat(40)}`,
waitReason: 'stdin_read',
sessionStatus: { kind: 'running' },
truncated: false,
}, 64)
expect(Buffer.byteLength(send)).toBeLessThanOrEqual(64)
expect(send).toContain('[wait: stdin_read]')
expect(send).toContain('[output truncated]')
const read = renderRead({
text: 'x'.repeat(200), totalLines: 20, lineBegin: 0, lineEnd: 10, truncated: false,
}, 48)
expect(Buffer.byteLength(read)).toBeLessThanOrEqual(48)
expect(read).toContain('[lines: 0-10 of 20]')
expect(Buffer.byteLength(renderSpawn({
sessionId: PtySessionId('pty-1'), type: 'shell', status: { kind: 'running' }, motd: 'x'.repeat(200),
}, 32))).toBeLessThanOrEqual(32)
const boundedSpawn = renderSpawn({
sessionId: PtySessionId('pty-1'), type: 'shell', status: { kind: 'running' }, motd: 'x'.repeat(200),
}, 96)
expect(boundedSpawn).toContain('started terminal session pty-1')
expect(boundedSpawn).toContain('[output truncated]')
expect(Buffer.byteLength(renderSend({
viewport: 'x'.repeat(200), waitReason: 'stdin_read', sessionStatus: { kind: 'running' }, truncated: false,
}, 8))).toBeLessThanOrEqual(8)
expect(boundTerminalText('x'.repeat(200), 8)).toHaveLength(8)
expect(boundTerminalText('x'.repeat(200), 32).endsWith('[output truncated]')).toBe(true)
})
})

View File

@@ -31,20 +31,23 @@ class StubSession implements PtyBackendSession {
autoSettle = true
rejectOperation = false
closeGate: PromiseWithResolvers<undefined> | undefined
viewport = 'command output'
delta = 'live output'
deltaTruncated = false
startSend(_request: PtySendRequest): PtySendOperation {
let settle!: () => void
let reject!: (error: unknown) => void
let cancelled = false
const done = new Promise<void>((resolve, rejectPromise) => { settle = resolve; reject = rejectPromise }).then(() => ({
viewport: cancelled ? '^C' : 'command output',
viewport: cancelled ? '^C' : this.viewport,
waitReason: 'stdin_read' as const,
sessionStatus: this.statusValue,
truncated: false,
}))
const operation: PtySendOperation = {
done,
readOutput: () => ({ delta: 'live output', truncated: false }),
readOutput: () => ({ delta: this.delta, truncated: this.deltaTruncated }),
cancel: () => {
if (cancelled) return false
cancelled = true
@@ -87,7 +90,13 @@ function stubBackend() {
return { backend, sessions }
}
async function setup(tasks: boolean) {
async function setup(tasks: boolean, config: ToolPty.Config = {}) {
const base = await setupBase(tasks)
await base.ctx.plugin(ToolPty, config)
return base
}
async function setupBase(tasks: boolean) {
const ctx = new Context()
await ctx.plugin(SystemPrompt)
await ctx.plugin(ToolRegistry)
@@ -99,7 +108,6 @@ async function setup(tasks: boolean) {
await ctx.plugin(TaskService)
await ctx.plugin(ToolTasks)
}
await ctx.plugin(ToolPty)
return { ctx, stub, agent: fakeAgent(ctx, tasks ? 'with-tasks' : 'foreground') }
}
@@ -172,6 +180,24 @@ describe('tool-pty foreground surface', () => {
expect(ctx.tools.get('terminal_close')?.presentCall?.({ sessionId: 'pty-1' })).toMatchObject({ card: 'generic', title: 'Close terminal pty-1' })
expect(ctx.tools.get('terminal_list')?.presentCall?.({})).toMatchObject({ card: 'generic', title: 'List terminal sessions' })
})
it('configuration-gates background sends and validates the final result bound', async () => {
const disabled = await setup(true, { enableRunInBackground: false })
const definition = disabled.ctx.tools.get('terminal_send')
expect(definition?.parameters).not.toHaveProperty('properties.run_in_background')
expect(definition?.description).not.toContain('Background mode')
await call(disabled.ctx, 'terminal_open', { type: 'stub' }, disabled.agent)
expect((await call(disabled.ctx, 'terminal_send', {
sessionId: 'pty-1', text: 'work', run_in_background: true,
}, disabled.agent)).isError).toBe(true)
const defaults = await setupBase(false)
ToolPty.apply(defaults.ctx)
expect(defaults.ctx.tools.get('terminal_send')?.parameters).toHaveProperty('properties.run_in_background')
const invalid = await setupBase(false)
expect(() => { ToolPty.apply(invalid.ctx, { maxResultBytes: 0 }) }).toThrow('maxResultBytes')
})
})
describe('tool-pty task integration', () => {
@@ -184,6 +210,23 @@ describe('tool-pty task integration', () => {
expect(text(output)).toContain('[status: completed, wait: stdin_read]')
})
it('bounds foreground and background results after terminal and task metadata', async () => {
const { ctx, agent, stub } = await setup(true, { maxResultBytes: 64 })
await call(ctx, 'terminal_open', { type: 'stub' }, agent)
stub.sessions[0]!.viewport = '界'.repeat(100)
const foreground = await call(ctx, 'terminal_send', { sessionId: 'pty-1', text: 'foreground' }, agent)
expect(Buffer.byteLength(text(foreground))).toBeLessThanOrEqual(64)
stub.sessions[0]!.delta = '界'.repeat(100)
stub.sessions[0]!.deltaTruncated = true
await call(ctx, 'terminal_send', { sessionId: 'pty-1', text: 'background', run_in_background: true }, agent)
const background = await call(ctx, 'task_output', { task_id: 'pty-send-1', wait: true }, agent)
expect(Buffer.byteLength(text(background))).toBeLessThanOrEqual(64)
expect(text(background)).toContain('[status: completed')
expect(text(background).match(/\[output truncated\]/g)).toHaveLength(1)
expect(text(background)).toContain('[output truncated]\n[status: completed')
})
it('rejects pre-aborted background calls, maps task cancellation, and contains operation failure', async () => {
const { ctx, agent, stub } = await setup(true)
await call(ctx, 'terminal_open', { type: 'stub' }, agent)

View File

@@ -14,6 +14,9 @@
{
"path": "../../../vendor/cordis"
},
{
"path": "../../util/retention"
},
{
"path": "../pty"
},