Merge remote-tracking branch 'origin/master' into codex/tool-json-schema-dsl

This commit is contained in:
Tianyi Cui
2026-07-22 23:03:02 +08:00
75 changed files with 6047 additions and 104 deletions

View File

@@ -12,6 +12,7 @@ Packages live at `packages/<group>/<pkg>/`; groups are containers, while names r
| [`goal/`](goal/README.md) | Persisted same-session goal state and lifecycle | Product — stable surface |
| [`llm/`](llm/README.md) | LLM capability family: the abstract service + provider adapters | Product — stable surface |
| [`bash/`](bash/README.md) | Bash capability family: executor seam, local impl, model-facing tool | Product — stable surface |
| [`pty/`](pty/README.md) | Persistent PTY capability family: owner-scoped sessions, local implementation, and model-facing tools | Product — stable surface |
| [`code-runtime/`](code-runtime/README.md) | Code-execution capability family: the runtime seam for model-written programs + a worker-thread backend | Product — stable surface |
| [`sandbox/`](sandbox/README.md) | Process-confinement seam; bwrap/Landlock/Seatbelt backends | Product — stable surface |
| [`fs/`](fs/README.md) | Filesystem capability family: seam, local impl, model-facing file tools, bash-backed discovery tools | Product — stable surface |

View File

@@ -386,6 +386,44 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [
},
],
},
{
key: 'pty',
summary: 'In-process registry for replaceable PTY backends and exact-Agent sessions.',
methods: [
{
signature: 'registerBackend(backend: PtyBackend): () => void',
jsDoc: '/**\n * Register one backend type for this effect scope.\n * @param backend - provider with a non-empty unique type.\n * @returns disposer that removes exactly this contribution.\n */',
},
{
signature: 'listBackends(): string[]',
jsDoc: '/**\n * List registered backend types in registration order.\n * @returns fresh backend type names.\n */',
},
{
signature: 'async spawn(owner: Agent, request: PtySpawnRequest, signal?: AbortSignal): Promise<PtySpawnResult>',
jsDoc: '/**\n * Create and publish one owner-scoped session after backend setup succeeds.\n * @param owner - exact registered Agent that owns access and cleanup.\n * @param request - backend type plus optional owner-local name and cwd.\n * @param signal - cancellation of unpublished setup.\n * @returns published identity, metadata, status, and MOTD.\n */',
},
{
signature: 'startSend(owner: Agent, id: PtySessionId, request: PtySendRequest): PtySendOperation',
jsDoc: '/**\n * Start one exclusive interactive send.\n * @param owner - exact session owner.\n * @param id - target PTY identity.\n * @param request - explicit text, submit behavior, and cancellation.\n * @returns live operation handle for foreground await or task registration.\n */',
},
{
signature: 'read(owner: Agent, id: PtySessionId, request: PtyReadRequest = {}): PtyReadResult',
jsDoc: '/**\n * Read one bounded scrollback page from an owned session.\n * @param owner - exact session owner.\n * @param id - target PTY identity.\n * @param request - optional newest-relative offset and line count.\n * @returns bounded retained text and pagination metadata.\n */',
},
{
signature: 'signal(owner: Agent, id: PtySessionId, signal: PtySignal): Promise<PtySignalResult>',
jsDoc: '/**\n * Deliver an allowed signal through an owned backend session.\n * @param owner - exact session owner.\n * @param id - target PTY identity.\n * @param signal - allowed POSIX signal name.\n * @returns delivered foreground process-group identity.\n */',
},
{
signature: 'async kill(owner: Agent, id: PtySessionId, reason = \'model request\'): Promise<boolean>',
jsDoc: '/**\n * Close one owned session and remove it only after quiescent backend cleanup.\n * @param owner - exact session owner.\n * @param id - target PTY identity.\n * @param reason - diagnostic cleanup reason.\n * @returns true for a newly closed session, false when the same close is already in flight.\n */',
},
{
signature: 'list(owner: Agent): PtySessionSnapshot[]',
jsDoc: '/**\n * List fresh snapshots for exactly one owner.\n * @param owner - exact owner whose sessions are visible.\n * @returns owner-visible snapshots in publication order.\n */',
},
],
},
{
key: 'sandbox',
summary: 'Abstract process-sandbox service.',
@@ -1495,6 +1533,78 @@ export const TYPE_API: readonly TypeApiEntry[] = [
name: 'PruneResult',
declaration: 'export interface PruneResult {\n readonly pruned: readonly PrunedEntry[];\n readonly charsRemoved: number;\n}',
},
{
name: 'PtyBackend',
declaration: 'export interface PtyBackend {\n readonly type: string;\n spawn(spec: PtyBackendSpawnSpec): Promise<PtyBackendSession>;\n}',
},
{
name: 'PtyBackendSession',
declaration: 'export interface PtyBackendSession {\n readonly motd: string;\n readonly pid?: number;\n startSend(request: PtySendRequest): PtySendOperation;\n read(request: PtyReadRequest): PtyReadResult;\n signal(signal: PtySignal): Promise<PtySignalResult>;\n status(): PtySessionStatus;\n close(reason: string): Promise<void>;\n}',
},
{
name: 'PtyBackendSpawnSpec',
declaration: 'export interface PtyBackendSpawnSpec extends PtySpawnRequest {\n sessionId: PtySessionIdValue;\n owner: Agent;\n signal?: AbortSignal;\n}',
},
{
name: 'PtyReadRequest',
declaration: 'export interface PtyReadRequest {\n offset?: number;\n count?: number;\n}',
},
{
name: 'PtyReadResult',
declaration: 'export interface PtyReadResult {\n text: string;\n totalLines: number;\n lineBegin: number;\n lineEnd: number;\n truncated: boolean;\n}',
},
{
name: 'PtySendOperation',
declaration: 'export interface PtySendOperation {\n done: Promise<PtySendResult>;\n readOutput(): PtySendRead;\n cancel(): boolean;\n}',
},
{
name: 'PtySendRead',
declaration: 'export interface PtySendRead {\n delta: string;\n truncated: boolean;\n}',
},
{
name: 'PtySendRequest',
declaration: 'export interface PtySendRequest {\n text: string;\n submit: boolean;\n signal?: AbortSignal;\n}',
},
{
name: 'PtySendResult',
declaration: 'export interface PtySendResult {\n viewport: string;\n waitReason: PtyWaitReason;\n sessionStatus: PtySessionStatus;\n truncated: boolean;\n}',
},
{
name: 'PtySessionId',
declaration: 'export type PtySessionId = PtySessionIdValue;',
},
{
name: 'PtySessionIdValue',
declaration: 'export type PtySessionIdValue = Branded<\'PtySessionId\'>;',
},
{
name: 'PtySessionSnapshot',
declaration: 'export interface PtySessionSnapshot {\n sessionId: PtySessionIdValue;\n name?: string;\n type: string;\n pid?: number;\n status: PtySessionStatus;\n}',
},
{
name: 'PtySessionStatus',
declaration: 'export type PtySessionStatus = {\n kind: \'running\';\n} | {\n kind: \'exited\';\n exitCode: number | null;\n signal: NodeJS.Signals | null;\n};',
},
{
name: 'PtySignal',
declaration: 'export type PtySignal = \'SIGINT\' | \'SIGTERM\' | \'SIGKILL\' | \'SIGTSTP\' | \'SIGHUP\';',
},
{
name: 'PtySignalResult',
declaration: 'export interface PtySignalResult {\n delivered: true;\n targetPgid: number;\n}',
},
{
name: 'PtySpawnRequest',
declaration: 'export interface PtySpawnRequest {\n type: string;\n name?: string;\n cwd?: string;\n}',
},
{
name: 'PtySpawnResult',
declaration: 'export interface PtySpawnResult extends PtySessionSnapshot {\n motd: string;\n}',
},
{
name: 'PtyWaitReason',
declaration: 'export type PtyWaitReason = \'stdin_read\' | \'inferred_idle\' | \'timeout\' | \'session_exit\';',
},
{
name: 'ReasoningBlock',
declaration: 'export interface ReasoningBlock {\n type: \'reasoning\';\n text: string;\n}',

View File

@@ -23,7 +23,7 @@ describe('gen-tool-catalog collectToolCatalog', () => {
it('boots every shipped tool package and harvests its model-facing schemas', async () => {
const catalog = await collectToolCatalog()
const names = catalog.flatMap(entry => entry.schemas.map(s => s.name)).sort()
expect(names).toEqual(['ask_user_question', 'bash', 'cordis_inspect', 'cordis_mount', 'cordis_unmount', 'create_goal', 'edit', 'exit_plan_mode', 'get_goal', 'glob', 'grep', 'lsp', 'ralph', 'read', 'run_code', 'skill', 'subagent', 'task_kill', 'task_list', 'task_output', 'todo_write', 'update_goal', 'web_fetch', 'web_search', 'workflow', 'write'])
expect(names).toEqual(['ask_user_question', 'bash', 'cordis_inspect', 'cordis_mount', 'cordis_unmount', 'create_goal', 'edit', 'exit_plan_mode', 'get_goal', 'glob', 'grep', 'lsp', 'ralph', 'read', 'run_code', 'skill', 'subagent', 'task_kill', 'task_list', 'task_output', 'terminal_close', 'terminal_list', 'terminal_open', 'terminal_read', 'terminal_send', 'terminal_signal', 'todo_write', 'update_goal', 'web_fetch', 'web_search', 'workflow', 'write'])
// Every tool carries a JSON-Schema `parameters` object (what the model sees).
for (const entry of catalog) {
for (const schema of entry.schemas) {

11
packages/pty/README.md Normal file
View File

@@ -0,0 +1,11 @@
# pty/ — persistent PTY capability family
`PTY` stands for **Pseudo-Terminal**(伪终端). This capability provides persistent, owner-scoped terminal sessions for workflows that require state across tool calls or interactive stdin. PTY complements the one-shot bash and filesystem tools; it does not replace their stronger per-operation contracts.
| Package | Role | ctx key |
|---|---|---|
| [`pty`](pty/README.md) (`@deepseek-ai/dsh-pty`) | Backend registry, branded ids, exact-Agent ownership, session operations, and awaited cleanup | `ctx.pty` |
| `pty-local` (`@deepseek-ai/dsh-pty-local`) | Local `node-pty` backend, readiness detection, bounded terminal state, sandboxing, and process-session supervision | registers on `ctx.pty` |
| `tool-pty` (`@deepseek-ai/dsh-tool-pty`) | Six model-facing tools and generic task integration for background sends | registers on `ctx.tools` |
The design and deferred boundaries live in the [persistent PTY Agent Note](../../.agents/notes/implemented/feature/2026-07-16-persistent-pty-sessions.md).

View File

@@ -0,0 +1,32 @@
# @deepseek-ai/dsh-pty-local
Local `node-pty` backend for `ctx.pty`. It starts an interactive shell under the shared `ctx.sandboxPolicy`, strips credential-shaped ambient environment variables, retains bounded line-oriented output, detects readiness, and tears down the captured process tree rooted at the `node-pty` child.
## 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.
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.
## Model Experience
### Indirect consumer
#### What the model sees
Nothing directly. Through `@deepseek-ai/dsh-tool-pty`, the model may receive bounded MOTD, send deltas, scrollback pages, readiness reasons, and cleanup errors.
#### Token effect
None until a consumer returns bounded backend output. Retained PTY scrollback is not placed in model history by this package.
#### KV Cache effect
No direct invalidation; the consumer owns prompts, schemas, and appended results.
## Known Limitations and Deferred Work
- Line-oriented output is normalized; full-screen alternate-buffer interaction is unsupported.
- Linux exact probes support x64 and arm64 UAPI tables; other architectures use prompt-marker and silence/timeout readiness.
- A descendant that daemonizes and reparents before teardown leaves the captured tree; cleanup never broadens to the launcher PID's POSIX session because that can include unrelated processes.
- Sessions do not survive harness process exit.

View File

@@ -0,0 +1,52 @@
{
"name": "@deepseek-ai/dsh-pty-local",
"description": "Local node-pty backend for persistent DeepSeek Harness PTY sessions",
"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"
},
"./invariant": {
"types": "./lib/types/invariant.d.ts",
"default": "./lib/invariant.js"
},
"./src/*": "./src/*",
"./package.json": "./package.json"
},
"files": [
"lib/index.js",
"lib/invariant.js",
"lib/types/**/*.d.ts",
"lib/types/**/*.d.ts.map",
"src"
],
"scripts": {
"postinstall": "node src/ensure-spawn-helper.mjs"
},
"license": "BSD-3-Clause",
"peerDependencies": {
"@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",
"cordis": "^4.0.0-rc.7"
},
"dependencies": {
"node-pty": "^1.1.0",
"schemastery": "^3.18.0"
},
"devDependencies": {
"@deepseek-ai/dsh-agent": "workspace:^",
"@deepseek-ai/dsh-invariants": "workspace:^",
"@deepseek-ai/dsh-pty": "workspace:^",
"@deepseek-ai/dsh-sandbox": "workspace:^",
"@deepseek-ai/dsh-sandbox-policy": "workspace:^",
"@deepseek-ai/dsh-session": "workspace:^",
"cordis": "^4.0.0-rc.7"
}
}

View File

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

View File

@@ -0,0 +1,16 @@
/** Restore the executable bit stripped from node-pty's prebuilt helper. */
import { chmodSync, existsSync } from 'node:fs'
import { dirname, join } from 'node:path'
import { fileURLToPath } from 'node:url'
const entry = fileURLToPath(import.meta.resolve('node-pty'))
const packageRoot = dirname(dirname(entry))
const candidates = [
join(packageRoot, 'prebuilds', `${process.platform}-${process.arch}`, 'spawn-helper'),
join(packageRoot, 'build', 'Release', 'spawn-helper'),
]
for (const helper of candidates) {
if (existsSync(helper)) chmodSync(helper, 0o755)
}

View File

@@ -0,0 +1,108 @@
/**
* Local persistent PTY backend using public `node-pty` APIs, shared sandbox
* policy, bounded output, platform readiness probes, and process-session cleanup.
* @module @deepseek-ai/dsh-pty-local
*/
import { Context } from 'cordis'
import * as nodePty from 'node-pty'
import type { IPtyForkOptions } from 'node-pty'
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'
import { type Config, type ResolvedConfig, validateConfig } from './config.ts'
import { createProcessInspector } from './process-inspector.ts'
import type { ProcessInspector } from './process-inspector.ts'
import { LocalPtySession } from './session.ts'
export { Config } from './config.ts'
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']
const SENSITIVE_ENV_PATTERN = /KEY|SECRET|TOKEN/i
function childEnvironment(spec: PtyBackendSpawnSpec): NodeJS.ProcessEnv {
const env: NodeJS.ProcessEnv = {}
for (const [key, value] of Object.entries(process.env)) {
if (value !== undefined && !SENSITIVE_ENV_PATTERN.test(key) && !key.startsWith('DSH_')) env[key] = value
}
return {
...env,
TERM: 'dumb',
PAGER: 'cat',
GIT_PAGER: 'cat',
PS1: 'dsh> ',
PROMPT_COMMAND: 'printf "\\033]133;D;%s\\007" "$?"',
BASH_SILENCE_DEPRECATION_WARNING: '1',
DSH_SHELL: '1',
DSH_SESSION_ID: spec.owner.id,
DSH_PTY_SESSION_ID: spec.sessionId,
}
}
function spawnArgv(ctx: Context, config: ResolvedConfig, spec: PtyBackendSpawnSpec): string[] {
const argv = [config.shellPath, ...config.shellArgs]
const mode: SandboxMode = effectiveSandboxMode(spec.owner.session.events) ?? ctx.sandboxPolicy.defaultMode
if (mode === 'danger-full-access') return argv
return ctx.sandbox.confine(argv, {
mode: mode,
workspaceRoot: ctx.sandboxPolicy.workspaceRoot,
}).argv
}
/** Local shell backend registered under the configured type. */
export class LocalPtyBackend implements PtyBackend {
readonly type: string
constructor(
private readonly ctx: Context,
private readonly config: ResolvedConfig,
private readonly inspector: ProcessInspector,
private readonly spawnTerminal: typeof nodePty.spawn = nodePty.spawn,
private readonly createSession: (
terminal: ReturnType<typeof nodePty.spawn>,
inspector: ProcessInspector,
config: ResolvedConfig,
) => LocalPtySession = (terminal, inspector, config) => new LocalPtySession(terminal, inspector, config),
) {
this.type = config.backendType
}
async spawn(spec: PtyBackendSpawnSpec): Promise<LocalPtySession> {
if (spec.signal?.aborted === true) throw new Error('PTY spawn aborted')
const argv = spawnArgv(this.ctx, this.config, spec)
const file = argv[0]
if (file === undefined) throw new Error('pty-local: sandbox returned empty argv')
const options: IPtyForkOptions = {
name: 'dumb',
cols: this.config.cols,
rows: this.config.rows,
cwd: spec.cwd ?? this.ctx.sandboxPolicy.workspaceRoot,
env: childEnvironment(spec),
}
const terminal = this.spawnTerminal(file, argv.slice(1), options)
const session = this.createSession(terminal, this.inspector, this.config)
try {
await session.initialize(spec.signal)
return session
} catch (error) {
try {
await session.close('PTY startup failed')
} catch (closeError: unknown) {
throw new AggregateError([error, closeError], 'PTY startup and cleanup both failed')
}
throw error
}
}
}
/** Register the local PTY backend. */
export function apply(ctx: Context, config: Config): void {
validateConfig(config)
const inspector = createProcessInspector()
ctx.pty.registerBackend(new LocalPtyBackend(ctx, config, inspector))
}

View File

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

View File

@@ -0,0 +1,326 @@
/** Platform process-table inspection used for readiness, signals, and teardown. */
import { closeSync, openSync, readFileSync, readdirSync, readSync } from 'node:fs'
import { execFileSync } from 'node:child_process'
import type { PtySignal } from '@deepseek-ai/dsh-pty'
/** PID plus start identity, preventing teardown escalation after PID reuse. */
export interface ProcessIdentity {
pid: number
started: string
}
/** Injectable OS process operations used by one local PTY session. */
export interface ProcessInspector {
foregroundPgid(shellPid: number): number | undefined
isStdinWaiting(pgid: number): boolean
/** Return the root and its current transitive descendants, children first. */
processTree(rootPid: number): ProcessIdentity[]
isAlive(identity: ProcessIdentity): boolean
signalGroup(pgid: number, signal: PtySignal): void
signalProcess(identity: ProcessIdentity, signal: 'SIGTERM' | 'SIGKILL'): void
}
/** Testable boundary around filesystem, process-table, and signal syscalls. */
export interface ProcessInspectorInternals {
readFile(path: string): string
readDir(path: string): string[]
open(path: string): number
read(fd: number, buffer: Buffer, length: number, position: number): number
close(fd: number): void
exec(file: string, args: string[]): string
kill(pid: number, signal: NodeJS.Signals): void
}
/* v8 ignore start -- thin OS bindings; injected logic is unit-tested and real platform composition exercises them. */
const DEFAULT_INTERNALS: ProcessInspectorInternals = {
readFile: path => readFileSync(path, 'utf8'),
readDir: path => readdirSync(path),
open: path => openSync(path, 'r'),
read: (fd, buffer, length, position) => readSync(fd, buffer, 0, length, position),
close: closeSync,
exec: (file, args) => execFileSync(file, args, { encoding: 'utf8' }),
kill: (pid, signal) => process.kill(pid, signal),
}
/* v8 ignore stop */
interface ProcStat {
pid: number
parentPid: number
pgrp: number
session: number
tpgid: number
started: string
}
/**
* Parse fields used from Linux `/proc/<pid>/stat`, including parenthesized comm text.
* @param text - complete stat line.
* @returns Parsed identity/group fields, or undefined for malformed input.
*/
export function parseProcStat(text: string): ProcStat | undefined {
const open = text.indexOf('(')
const close = text.lastIndexOf(')')
if (open <= 0 || close <= open) return undefined
const pid = Number(text.slice(0, open).trim())
const rest = text.slice(close + 2).trim().split(/\s+/)
const parentPid = Number(rest[1])
const pgrp = Number(rest[2])
const session = Number(rest[3])
const tpgid = Number(rest[5])
const started = rest[19]
if (![pid, parentPid, pgrp, session, tpgid].every(Number.isSafeInteger) || started === undefined) return undefined
return { pid, parentPid, pgrp, session, tpgid, started }
}
function readLinuxStat(internals: ProcessInspectorInternals, pid: number): ProcStat | undefined {
try {
return parseProcStat(internals.readFile(`/proc/${pid}/stat`))
} catch (_unreadableProcEntry) {
return undefined
}
}
function numericEntries(internals: ProcessInspectorInternals, path: string): number[] {
try {
return internals.readDir(path).filter(entry => /^\d+$/.test(entry)).map(Number)
} catch (_unreadableProcDirectory) {
return []
}
}
interface SyscallInfo {
number: number
args: number[]
}
function readSyscall(internals: ProcessInspectorInternals, pid: number, tid: number): SyscallInfo | undefined {
try {
const text = internals.readFile(`/proc/${pid}/task/${tid}/syscall`).trim()
if (text === 'running' || text.startsWith('-1 ')) return undefined
const fields = text.split(/\s+/)
const number = Number(fields[0])
const args = fields.slice(1, 7).map(field => Number.parseInt(field, 16))
if (!Number.isSafeInteger(number) || args.some(value => !Number.isSafeInteger(value))) return undefined
return { number, args }
} catch (_unreadableSyscall) {
return undefined
}
}
function readMemory(
internals: ProcessInspectorInternals,
pid: number,
address: number,
length: number,
): Buffer | undefined {
let fd: number | undefined
try {
fd = internals.open(`/proc/${pid}/mem`)
const buffer = Buffer.alloc(length)
const count = internals.read(fd, buffer, length, address)
return buffer.subarray(0, count)
} catch (_unreadableProcessMemory) {
return undefined
} finally {
if (fd !== undefined) internals.close(fd)
}
}
function fdSetHasStdin(internals: ProcessInspectorInternals, pid: number, address: number): boolean {
return address !== 0 && (readMemory(internals, pid, address, 8)?.[0] ?? 0) % 2 === 1
}
function pollHasStdin(
internals: ProcessInspectorInternals,
pid: number,
address: number,
count: number,
): boolean {
if (address === 0 || count <= 0) return false
const memory = readMemory(internals, pid, address, Math.min(count, 1024) * 8)
if (memory === undefined) return false
for (let offset = 0; offset + 8 <= memory.length; offset += 8) {
if (memory.readInt32LE(offset) === 0 && (memory.readInt16LE(offset + 4) & 0x001) !== 0) return true
}
return false
}
function epollHasStdin(internals: ProcessInspectorInternals, pid: number, epfd: number): boolean {
try {
return internals.readFile(`/proc/${pid}/fdinfo/${epfd}`)
.split('\n')
.some(line => /^tfd:\s+0\b/.test(line.trim()))
} catch (_unreadableFdInfo) {
return false
}
}
interface SyscallTable {
read: number
select?: number
pselect: number
poll?: number
ppoll: number
epollWait?: number
epollPwait: number
}
const SYSCALLS: Partial<Record<NodeJS.Architecture, SyscallTable>> = {
x64: { read: 0, select: 23, pselect: 270, poll: 7, ppoll: 271, epollWait: 232, epollPwait: 281 },
arm64: { read: 63, pselect: 72, ppoll: 73, epollPwait: 22 },
}
function syscallWaitsOnStdin(
internals: ProcessInspectorInternals,
pid: number,
syscall: SyscallInfo,
table: SyscallTable,
): boolean {
const [a0 = 0, a1 = 0, a2 = 0] = syscall.args
if (syscall.number === table.read) return a0 === 0
if (syscall.number === table.select || syscall.number === table.pselect) {
return a0 >= 1 && fdSetHasStdin(internals, pid, a1)
}
if (syscall.number === table.poll || syscall.number === table.ppoll) {
return a1 >= 1 && pollHasStdin(internals, pid, a0, a1)
}
if (syscall.number === table.epollWait || syscall.number === table.epollPwait) {
return a2 >= 1 && epollHasStdin(internals, pid, a0)
}
return false
}
abstract class PosixProcessInspector implements ProcessInspector {
constructor(protected readonly internals: ProcessInspectorInternals) {}
abstract foregroundPgid(shellPid: number): number | undefined
abstract isStdinWaiting(pgid: number): boolean
abstract processTree(rootPid: number): ProcessIdentity[]
abstract isAlive(identity: ProcessIdentity): boolean
signalGroup(pgid: number, signal: PtySignal): void {
this.internals.kill(-pgid, signal)
}
signalProcess(identity: ProcessIdentity, signal: 'SIGTERM' | 'SIGKILL'): void {
if (this.isAlive(identity)) this.internals.kill(identity.pid, signal)
}
}
interface ProcessTreeEntry extends ProcessIdentity {
parentPid: number
}
function processTree(entries: ProcessTreeEntry[], rootPid: number): ProcessIdentity[] {
const byPid = new Map(entries.map(entry => [entry.pid, entry]))
const root = byPid.get(rootPid)
if (root === undefined) return []
const byParent = new Map<number, ProcessTreeEntry[]>()
for (const entry of entries) {
const children = byParent.get(entry.parentPid) ?? []
children.push(entry)
byParent.set(entry.parentPid, children)
}
const visited = new Set<number>()
const result: ProcessIdentity[] = []
const visit = (entry: ProcessTreeEntry): void => {
if (visited.has(entry.pid)) return
visited.add(entry.pid)
for (const child of byParent.get(entry.pid) ?? []) visit(child)
result.push({ pid: entry.pid, started: entry.started })
}
visit(root)
return result
}
class LinuxProcessInspector extends PosixProcessInspector {
constructor(
private readonly arch: NodeJS.Architecture,
internals: ProcessInspectorInternals,
) {
super(internals)
}
foregroundPgid(shellPid: number): number | undefined {
const tpgid = readLinuxStat(this.internals, shellPid)?.tpgid
return tpgid !== undefined && tpgid > 0 ? tpgid : undefined
}
isStdinWaiting(pgid: number): boolean {
const table = SYSCALLS[this.arch]
if (table === undefined) return false
for (const pid of numericEntries(this.internals, '/proc')) {
if (readLinuxStat(this.internals, pid)?.pgrp !== pgid) continue
for (const tid of numericEntries(this.internals, `/proc/${pid}/task`)) {
const syscall = readSyscall(this.internals, pid, tid)
if (syscall !== undefined && syscallWaitsOnStdin(this.internals, pid, syscall, table)) return true
}
}
return false
}
processTree(rootPid: number): ProcessIdentity[] {
const entries = numericEntries(this.internals, '/proc').flatMap((pid) => {
const stat = readLinuxStat(this.internals, pid)
return stat === undefined ? [] : [{ pid, parentPid: stat.parentPid, started: stat.started }]
})
return processTree(entries, rootPid)
}
isAlive(identity: ProcessIdentity): boolean {
return readLinuxStat(this.internals, identity.pid)?.started === identity.started
}
}
interface PsEntry extends ProcessTreeEntry {}
function macProcessTable(internals: ProcessInspectorInternals): PsEntry[] {
return internals.exec('/bin/ps', ['-axo', 'pid=,ppid=,lstart=']).split('\n').flatMap((line) => {
const match = /^\s*(\d+)\s+(\d+)\s+(.+?)\s*$/.exec(line)
if (match?.[1] === undefined || match[2] === undefined || match[3] === undefined) return []
return [{ pid: Number(match[1]), parentPid: Number(match[2]), started: match[3] }]
})
}
class MacProcessInspector extends PosixProcessInspector {
foregroundPgid(shellPid: number): number | undefined {
try {
const value = Number(this.internals.exec('/bin/ps', ['-o', 'tpgid=', '-p', String(shellPid)]).trim())
return Number.isSafeInteger(value) && value > 0 ? value : undefined
} catch (_missingProcess) {
return undefined
}
}
isStdinWaiting(_pgid: number): boolean {
return false
}
processTree(rootPid: number): ProcessIdentity[] {
return processTree(macProcessTable(this.internals), rootPid)
}
isAlive(identity: ProcessIdentity): boolean {
return macProcessTable(this.internals).some(entry => entry.pid === identity.pid && entry.started === identity.started)
}
}
/**
* Create the supported platform inspector or fail at plugin load.
* @param platform - target Node platform.
* @param arch - target CPU architecture for Linux syscall numbers.
* @param internals - filesystem/process boundary, injectable for deterministic tests.
* @returns Platform process inspector.
*/
export function createProcessInspector(
platform: NodeJS.Platform = process.platform,
arch: NodeJS.Architecture = process.arch,
internals: ProcessInspectorInternals = DEFAULT_INTERNALS,
): ProcessInspector {
if (platform === 'linux') return new LinuxProcessInspector(arch, internals)
if (platform === 'darwin') return new MacProcessInspector(internals)
throw new Error(`pty-local: unsupported platform ${platform}`)
}

View File

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

View File

@@ -0,0 +1,394 @@
/** Local `node-pty` session: bounded output, readiness, signals, and teardown. */
import { constants } from 'node:os'
import { Buffer } from 'node:buffer'
import type { IDisposable, IPty } from 'node-pty'
import type {
PtyBackendSession,
PtyReadRequest,
PtyReadResult,
PtySendOperation,
PtySendRead,
PtySendRequest,
PtySendResult,
PtySessionStatus,
PtySignal,
PtySignalResult,
PtyWaitReason,
} from '@deepseek-ai/dsh-pty'
import type { ResolvedConfig } from './config.ts'
import type { ProcessInspector } from './process-inspector.ts'
import { TerminalSanitizer } from './sanitize.ts'
function delay(ms: number): Promise<void> {
return new Promise(resolve => setTimeout(resolve, ms))
}
function utf8Tail(text: string, maxBytes: number): { text: string; truncated: boolean } {
if (Buffer.byteLength(text) <= maxBytes) return { text, truncated: false }
const chars = Array.from(text)
let bytes = 0
let start = chars.length
while (start > 0) {
const next = Buffer.byteLength(chars[start - 1] as string)
if (bytes + next > maxBytes) break
bytes += next
start -= 1
}
return { text: chars.slice(start).join(''), truncated: true }
}
class BoundedTextBuffer {
private value = ''
private dropped = false
constructor(
private readonly maxBytes: number,
private readonly maxLines?: number,
) {}
append(text: string): void {
if (text.length === 0) return
this.value += text
if (this.maxLines !== undefined) {
const lines = this.value.split('\n')
if (lines.length > this.maxLines) {
this.value = lines.slice(lines.length - this.maxLines).join('\n')
this.dropped = true
}
}
const tail = utf8Tail(this.value, this.maxBytes)
this.value = tail.text
this.dropped ||= tail.truncated
}
consume(): PtySendRead {
const delta = this.value
const truncated = this.dropped
this.value = ''
this.dropped = false
return { delta, truncated }
}
snapshot(): { text: string; truncated: boolean } {
return { text: this.value, truncated: this.dropped }
}
}
class LocalSendOperation implements PtySendOperation {
private readonly output: BoundedTextBuffer
private readonly promise: PromiseWithResolvers<PtySendResult>
private finished = false
constructor(
maxBytes: number,
readonly startedAt: number,
private readonly onCancel: () => void,
) {
this.output = new BoundedTextBuffer(maxBytes)
this.promise = Promise.withResolvers<PtySendResult>()
}
get done(): Promise<PtySendResult> {
return this.promise.promise
}
append(text: string): void {
if (!this.finished) this.output.append(text)
}
settle(waitReason: PtyWaitReason, sessionStatus: PtySessionStatus, inheritedTruncation: boolean): void {
if (this.finished) return
this.finished = true
const read = this.output.snapshot()
this.promise.resolve({
viewport: read.text,
waitReason,
sessionStatus,
truncated: read.truncated || inheritedTruncation,
})
}
fail(error: unknown): void {
if (this.finished) return
this.finished = true
this.promise.reject(error)
}
readOutput(): PtySendRead {
return this.output.consume()
}
cancel(): boolean {
if (this.finished) return false
this.onCancel()
return true
}
}
function signalName(number: number | undefined): NodeJS.Signals | null {
if (number === undefined || number === 0) return null
for (const [name, value] of Object.entries(constants.signals)) {
if (value === number) return name as NodeJS.Signals
}
return null
}
/** Backend session wrapping one `node-pty` process and its captured process tree. */
export class LocalPtySession implements PtyBackendSession {
motd = ''
readonly pid: number
private readonly sanitizer: TerminalSanitizer
private readonly scrollback: BoundedTextBuffer
private readonly exitPromise: PromiseWithResolvers<void> = Promise.withResolvers<void>()
private readonly dataDisposable: IDisposable
private readonly exitDisposable: IDisposable
private statusValue: PtySessionStatus = { kind: 'running' }
private active: LocalSendOperation | undefined
private activeTimer: NodeJS.Timeout | undefined
private activeAbort: (() => void) | undefined
private promptSeen = false
private shellPgid: number | undefined
private initializing = false
private lastOutputAt = Date.now()
private closePromise: Promise<void> | undefined
constructor(
private readonly terminal: IPty,
private readonly inspector: ProcessInspector,
private readonly config: ResolvedConfig,
) {
this.pid = terminal.pid
this.sanitizer = new TerminalSanitizer(config.maxReadBytes)
this.scrollback = new BoundedTextBuffer(config.scrollbackMaxBytes, config.scrollbackLines)
this.dataDisposable = terminal.onData((data) => { this.onData(data) })
this.exitDisposable = terminal.onExit(({ exitCode, signal }) => {
const tail = this.sanitizer.flush()
this.appendOutput(tail)
this.statusValue = { kind: 'exited', exitCode, signal: signalName(signal) }
this.settleActive('session_exit')
this.exitPromise.resolve()
})
}
/**
* Capture startup output through the same readiness contract as later sends.
* @param signal - optional cancellation while the shell reaches its first prompt.
* @returns Resolves after startup readiness; rejects on exit or readiness timeout.
*/
async initialize(signal?: AbortSignal): Promise<void> {
this.initializing = true
try {
const operation = this.startSend({ text: '', submit: false, ...signal !== undefined ? { signal } : {} })
const result = await operation.done
if (result.waitReason === 'session_exit') throw new Error('PTY shell exited during startup')
if (result.waitReason === 'timeout') throw new Error('PTY shell did not reach readiness before startup timeout')
this.motd = result.viewport
} finally {
this.initializing = false
}
}
startSend(request: PtySendRequest): PtySendOperation {
if (this.closePromise !== undefined) 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)
}
})
this.active = operation
this.lastOutputAt = Date.now()
this.promptSeen = false
if (request.signal !== undefined) {
const onAbort = (): void => { operation.cancel() }
request.signal.addEventListener('abort', onAbort, { once: true })
this.activeAbort = () => request.signal?.removeEventListener('abort', onAbort)
}
try {
if (request.text.length > 0) this.terminal.write(request.text)
if (request.submit) this.terminal.write('\r')
} catch (error: unknown) {
this.clearActive()
operation.fail(error)
return operation
}
this.activeTimer = setInterval(() => { this.pollReadiness(operation) }, this.config.pollIntervalMs)
return operation
}
read(request: PtyReadRequest): PtyReadResult {
const snapshot = this.scrollback.snapshot()
const lines = snapshot.text.split('\n')
const totalLines = snapshot.text.length === 0 ? 0 : lines.length
const offset = request.offset ?? 0
const count = request.count ?? 500
if (!Number.isSafeInteger(offset) || offset < 0) throw new Error('PTY read offset must be a non-negative safe integer')
if (!Number.isSafeInteger(count) || count <= 0) throw new Error('PTY read count must be a positive safe integer')
if (offset >= totalLines) {
return { text: '', totalLines, lineBegin: offset, lineEnd: offset, truncated: snapshot.truncated }
}
const end = totalLines - offset
const start = Math.max(0, end - count)
const requested = lines.slice(start, end).join('\n')
const bounded = utf8Tail(requested, this.config.maxReadBytes)
const returnedLines = bounded.text.length === 0 ? 0 : bounded.text.split('\n').length
return {
text: bounded.text,
totalLines,
lineBegin: offset,
lineEnd: offset + returnedLines,
truncated: snapshot.truncated || bounded.truncated,
}
}
signal(signal: PtySignal): Promise<PtySignalResult> {
return Promise.resolve().then(() => {
const pgid = this.inspector.foregroundPgid(this.pid)
if (pgid === undefined) throw new Error(`cannot resolve foreground process group for PTY ${this.pid}`)
if (signal === 'SIGKILL' && pgid === this.pid) {
throw new Error('refusing to SIGKILL the PTY shell; use terminal_close')
}
this.inspector.signalGroup(pgid, signal)
return { delivered: true, targetPgid: pgid }
})
}
status(): PtySessionStatus {
return this.statusValue
}
close(reason: string): Promise<void> {
this.closePromise ??= this.closeOnce(reason)
return this.closePromise
}
private onData(data: string): void {
const sanitized = this.sanitizer.push(data)
this.appendOutput(sanitized.text)
if (sanitized.prompt) {
const foregroundPgid = this.inspector.foregroundPgid(this.pid)
if (this.shellPgid === undefined) this.shellPgid = foregroundPgid
if (foregroundPgid !== undefined && foregroundPgid === this.shellPgid) {
this.promptSeen = true
this.lastOutputAt = Date.now()
}
}
}
private appendOutput(text: string): void {
if (text.length === 0) return
this.lastOutputAt = Date.now()
this.scrollback.append(text)
this.active?.append(text)
}
private pollReadiness(operation: LocalSendOperation): void {
if (this.active !== operation) return
if (this.statusValue.kind === 'exited') {
this.settleActive('session_exit')
return
}
if (this.promptSeen && Date.now() - this.lastOutputAt >= this.config.pollIntervalMs) {
this.settleActive('stdin_read')
return
}
const elapsed = Date.now() - operation.startedAt
const startupHasOutput = !this.initializing || this.scrollback.snapshot().text.length > 0
if (startupHasOutput && elapsed >= this.config.exactProbeAfterMs) {
const pgid = this.inspector.foregroundPgid(this.pid)
if (pgid !== undefined && this.inspector.isStdinWaiting(pgid)) {
this.settleActive('stdin_read')
return
}
}
if (startupHasOutput && Date.now() - this.lastOutputAt >= this.config.idleSilenceMs) {
this.settleActive('inferred_idle')
return
}
if (elapsed >= this.config.timeoutMs) this.settleActive('timeout')
}
private settleActive(waitReason: PtyWaitReason): void {
const operation = this.active
if (operation === undefined) return
const scrollbackTruncated = this.scrollback.snapshot().truncated
this.clearActive()
operation.settle(waitReason, this.statusValue, scrollbackTruncated)
}
private stopPolling(): void {
if (this.activeTimer !== undefined) clearInterval(this.activeTimer)
this.activeTimer = undefined
}
private clearActive(): void {
this.stopPolling()
this.activeAbort?.()
this.activeAbort = undefined
this.active = undefined
}
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()
if (survivors.length > 0) {
throw new Error(`PTY cleanup failed (${reason}); surviving pids: ${survivors.map(member => member.pid).join(', ')}`)
}
}
}

View File

@@ -0,0 +1,27 @@
import { describe, expect, it } from 'vitest'
import type { Config } from '@deepseek-ai/dsh-pty-local/src/config.ts'
import { validateConfig } from '@deepseek-ai/dsh-pty-local/src/config.ts'
function config(overrides: Partial<Config> = {}): Config {
return {
backendType: 'shell', shellPath: '/bin/bash', shellArgs: [], rows: 40, cols: 160,
scrollbackLines: 100, scrollbackMaxBytes: 1024, maxReadBytes: 512,
pollIntervalMs: 10, exactProbeAfterMs: 20, idleSilenceMs: 100, timeoutMs: 1000,
disposeGraceMs: 100,
...overrides,
}
}
describe('pty-local config', () => {
it('accepts resolved positive bounds', () => {
expect(() => { validateConfig(config()) }).not.toThrow()
})
it('rejects empty names, invalid numbers, and a read cap above retention', () => {
expect(() => { validateConfig(config({ backendType: '' })) }).toThrow('backendType')
expect(() => { validateConfig(config({ shellPath: '' })) }).toThrow('shellPath')
expect(() => { validateConfig(config({ rows: 0 })) }).toThrow('rows')
expect(() => { validateConfig(config({ rows: 1.5 })) }).toThrow('rows')
expect(() => { validateConfig(config({ maxReadBytes: 2048 })) }).toThrow('must not exceed')
})
})

View File

@@ -0,0 +1,186 @@
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 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 PtyService, { PtySessionId } 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'
import type { ProcessInspector } from '@deepseek-ai/dsh-pty-local/src/process-inspector.ts'
import type { LocalPtySession } from '@deepseek-ai/dsh-pty-local/src/session.ts'
class EmptySandbox extends SandboxProvider {
confine(_argv: readonly string[], _policy: SandboxPolicy): ConfinedArgv {
return { argv: [], enforcement: 'full', denialSignatures: [], runnerFailureSignatures: [] }
}
}
class RecordingSandbox extends SandboxProvider {
calls: { argv: readonly string[]; policy: SandboxPolicy }[] = []
confine(argv: readonly string[], policy: SandboxPolicy): ConfinedArgv {
this.calls.push({ argv, policy })
return { argv: ['/sandbox', '--', ...argv], enforcement: 'full', denialSignatures: [], runnerFailureSignatures: [] }
}
}
function config(): ResolvedConfig {
return {
backendType: 'shell', shellPath: '/bin/bash', shellArgs: [], rows: 24, cols: 80,
scrollbackLines: 10, scrollbackMaxBytes: 100, maxReadBytes: 50,
pollIntervalMs: 10, exactProbeAfterMs: 20, idleSilenceMs: 50, timeoutMs: 100,
disposeGraceMs: 10,
}
}
function agent(ctx: Context): Agent {
const id = SessionId('agent')
return {
id, options: {}, session: new Session(id), status: 'idle', ctx,
send() {}, steer() {}, inject() {}, cancel() {}, whenIdle: () => Promise.resolve(),
}
}
const inspector = {
foregroundPgid: () => undefined,
isStdinWaiting: () => false,
processTree: () => [],
isAlive: () => false,
signalGroup() {},
signalProcess() {},
} satisfies ProcessInspector
function spec(owner: Agent, signal?: AbortSignal) {
return {
sessionId: PtySessionId('pty-1'), owner, type: 'shell',
...signal !== undefined ? { signal } : {},
}
}
describe('LocalPtyBackend startup rollback', () => {
it('rejects pre-aborted setup and empty sandbox argv', async () => {
const ctx = new Context()
await ctx.plugin(EmptySandbox)
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')
await expect(backend.spawn(spec(agent(ctx)))).rejects.toThrow('empty argv')
})
it('closes failed startup and aggregates cleanup failure', async () => {
const ctx = new Context()
await ctx.plugin(EmptySandbox)
await ctx.plugin(SandboxPolicyService, { mode: 'danger-full-access', workspaceRoot: '/tmp' })
const spawnTerminal = (() => ({} as IPty)) as never
const closed = vi.fn<() => Promise<void>>().mockResolvedValue(undefined)
const failed = { initialize: () => Promise.reject(new Error('startup failed')), close: closed } as unknown as LocalPtySession
const backend = new LocalPtyBackend(ctx, config(), inspector, spawnTerminal, () => failed)
await expect(backend.spawn(spec(agent(ctx)))).rejects.toThrow('startup failed')
expect(closed).toHaveBeenCalledWith('PTY startup failed')
const doublyFailed = {
initialize: () => Promise.reject(new Error('startup failed')),
close: () => Promise.reject(new Error('cleanup failed')),
} as unknown as LocalPtySession
const aggregate = new LocalPtyBackend(ctx, config(), inspector, spawnTerminal, () => doublyFailed)
await expect(aggregate.spawn(spec(agent(ctx)))).rejects.toThrow('startup and cleanup both failed')
})
it('wraps confined argv, scrubs the environment, and returns initialized sessions', async () => {
const ctx = new Context()
await ctx.plugin(RecordingSandbox)
await ctx.plugin(SandboxPolicyService, { mode: 'workspace-write', workspaceRoot: '/workspace' })
const terminal = {} as IPty
let spawned: { file: string; args: string[]; options: IPtyForkOptions } | undefined
const spawnTerminal = ((file: string, args: string[], options: IPtyForkOptions) => {
spawned = { file, args, options }
return terminal
}) as never
const initialized = vi.fn<() => Promise<void>>().mockResolvedValue(undefined)
const session = { initialize: initialized } as unknown as LocalPtySession
const backend = new LocalPtyBackend(
ctx,
{ ...config(), shellArgs: ['-i'] },
inspector,
spawnTerminal,
() => session,
)
const previous = process.env.PTY_TEST_SECRET
process.env.PTY_TEST_SECRET = 'must-not-leak'
try {
expect(await backend.spawn({ ...spec(agent(ctx)), cwd: '/work' })).toBe(session)
} finally {
if (previous === undefined) delete process.env.PTY_TEST_SECRET
else process.env.PTY_TEST_SECRET = previous
}
expect(spawned).toMatchObject({
file: '/sandbox',
args: ['--', '/bin/bash', '-i'],
options: {
name: 'dumb', cols: 80, rows: 24, cwd: '/work',
env: {
TERM: 'dumb', PAGER: 'cat', GIT_PAGER: 'cat', PS1: 'dsh> ', BASH_SILENCE_DEPRECATION_WARNING: '1',
DSH_SHELL: '1', DSH_SESSION_ID: 'agent', DSH_PTY_SESSION_ID: 'pty-1',
},
},
})
expect(spawned?.options.env?.PTY_TEST_SECRET).toBeUndefined()
expect(initialized).toHaveBeenCalledWith(undefined)
})
it('composes the default local session around a spawned terminal', async () => {
const ctx = new Context()
await ctx.plugin(EmptySandbox)
await ctx.plugin(SandboxPolicyService, { mode: 'danger-full-access', workspaceRoot: '/workspace' })
let exitListener: ((event: { exitCode: number; signal?: number }) => void) | undefined
const terminal = {
pid: 123, cols: 80, rows: 24, process: 'bash', handleFlowControl: false,
onData(listener: (data: string) => void) {
queueMicrotask(() => { listener('\x1b]133;D;0\x07dsh> ') })
return { dispose() {} }
},
onExit(listener: (event: { exitCode: number; signal?: number }) => void) {
exitListener = listener
return { dispose() {} }
},
write() {},
kill() { exitListener?.({ exitCode: 0, signal: 15 }) },
resize() {}, clear() {}, pause() {}, resume() {},
} as IPty
const backend = new LocalPtyBackend(ctx, config(), inspector, () => terminal)
const session = await backend.spawn(spec(agent(ctx)))
expect(session.motd).toBe('dsh> ')
await session.close('test complete')
})
})
describe('pty-local plugin shape', () => {
it('keeps name, inject, and Config through Loader unwrapExports', () => {
expect('default' in ptyLocal).toBe(false)
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.Config).toBeDefined()
})
it('validates config and registers the configured backend', async () => {
const ctx = new Context()
await ctx.plugin(PtyService)
await ctx.plugin(EmptySandbox)
await ctx.plugin(SandboxPolicyService, { mode: 'danger-full-access', workspaceRoot: '/tmp' })
const fiber = await ctx.plugin(ptyLocal, config())
expect(ctx.pty.listBackends()).toEqual(['shell'])
await fiber.dispose()
expect(ctx.pty.listBackends()).toEqual([])
})
})

View File

@@ -0,0 +1,122 @@
import { mkdtempSync, realpathSync, rmSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, describe, expect, 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 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'
import * as ptyLocal from '@deepseek-ai/dsh-pty-local'
const roots: string[] = []
const contexts: Context[] = []
afterEach(async () => {
for (const ctx of contexts.splice(0)) await ctx.fiber.dispose()
for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true })
})
class PassthroughSandbox extends SandboxProvider {
calls: { argv: readonly string[]; policy: SandboxPolicy }[] = []
confine(argv: readonly string[], policy: SandboxPolicy): ConfinedArgv {
this.calls.push({ argv, policy })
return { argv: [...argv], enforcement: 'full', denialSignatures: [], runnerFailureSignatures: [] }
}
}
function stubAgent(ctx: Context, rawId: string): Agent {
const id = SessionId(rawId)
const scope = ctx.plugin(() => {})
return {
id, options: {}, session: new Session(id), status: 'idle', ctx: scope.ctx,
send() {}, steer() {}, inject() {}, cancel() {}, whenIdle: () => Promise.resolve(),
}
}
async function harness(mode: 'danger-full-access' | 'workspace-write') {
const root = mkdtempSync(join(tmpdir(), 'dsh-pty-local-'))
roots.push(root)
const ctx = new Context()
contexts.push(ctx)
await ctx.plugin(AgentRegistry)
await ctx.plugin(PtyService)
await ctx.plugin(PassthroughSandbox)
await ctx.plugin(SandboxPolicyService, { mode, workspaceRoot: root })
const fiber = await ctx.plugin(ptyLocal, {
pollIntervalMs: 10,
exactProbeAfterMs: 20,
idleSilenceMs: 250,
timeoutMs: 2000,
disposeGraceMs: 500,
scrollbackLines: 100,
scrollbackMaxBytes: 32_768,
maxReadBytes: 16_384,
})
const agent = stubAgent(ctx, `agent-${mode}`)
ctx.agents.register(agent)
return { ctx, root, agent, fiber, sandbox: ctx.sandbox as PassthroughSandbox }
}
describe('pty-local real shell', () => {
it('persists cwd and environment across sends, scrubs secrets, and closes', async () => {
const previous = process.env.DSH_TEST_SECRET
process.env.DSH_TEST_SECRET = 'must-not-leak'
try {
const { ctx, root, agent } = await harness('danger-full-access')
const created = await ctx.pty.spawn(agent, { type: 'shell', name: 'main', cwd: root })
expect(created.motd).toContain('dsh> ')
const first = ctx.pty.startSend(agent, created.sessionId, { text: 'export KEEP=ok; cd /', submit: true })
expect((await first.done).waitReason).toBe('stdin_read')
const second = ctx.pty.startSend(agent, created.sessionId, { text: 'printf "cwd=%s keep=%s secret=%s\\n" "$PWD" "$KEEP" "${DSH_TEST_SECRET-unset}"', submit: true })
expect((await second.done).viewport).toContain('cwd=/ keep=ok secret=unset')
expect(ctx.pty.read(agent, created.sessionId, { offset: 0, count: 20 }).text).toContain('cwd=/ keep=ok secret=unset')
expect(await ctx.pty.kill(agent, created.sessionId)).toBe(true)
expect(ctx.pty.list(agent)).toEqual([])
} finally {
if (previous === undefined) delete process.env.DSH_TEST_SECRET
else process.env.DSH_TEST_SECRET = previous
}
}, 10_000)
it('wraps the exact shell argv under confined policy and unregisters on reload', async () => {
const { ctx, root, agent, fiber, sandbox } = await harness('workspace-write')
const created = await ctx.pty.spawn(agent, { type: 'shell' })
expect(sandbox.calls).toEqual([{
argv: ['/bin/bash', '--noprofile', '--norc', '-i'],
policy: { mode: 'workspace-write', workspaceRoot: realpathSync.native(root) },
}])
await fiber.dispose()
expect(ctx.pty.listBackends()).toEqual([])
expect(ctx.pty.list(agent)).toHaveLength(1)
await ctx.pty.kill(agent, created.sessionId)
}, 10_000)
it('signals a foreground command and kills a TERM-ignoring background descendant', async () => {
const { ctx, agent } = await harness('danger-full-access')
const created = await ctx.pty.spawn(agent, { type: 'shell' })
const foreground = ctx.pty.startSend(agent, created.sessionId, { text: 'sleep 60', submit: true })
await new Promise(resolve => setTimeout(resolve, 50))
expect((await ctx.pty.signal(agent, created.sessionId, 'SIGINT')).delivered).toBe(true)
expect((await foreground.done).waitReason).toBe('stdin_read')
const background = ctx.pty.startSend(agent, created.sessionId, {
text: 'sh -c \'trap "" TERM; sleep 60\' & echo CHILD=$!',
submit: true,
})
const output = (await background.done).viewport
const child = /CHILD=(\d+)/.exec(output)?.[1]
expect(child).toBeDefined()
const pid = Number(child)
expect(() => process.kill(pid, 0)).not.toThrow()
await ctx.pty.kill(agent, created.sessionId)
expect(() => process.kill(pid, 0)).toThrow()
}, 10_000)
})

View File

@@ -0,0 +1,215 @@
import { describe, expect, it } from 'vitest'
import { createProcessInspector, parseProcStat } from '@deepseek-ai/dsh-pty-local/src/process-inspector.ts'
import type { ProcessInspectorInternals } from '@deepseek-ai/dsh-pty-local/src/process-inspector.ts'
function stat(pid: number, pgrp: number, session: number, tpgid: number, started: string, parentPid = 1): string {
const rest = ['S', String(parentPid), String(pgrp), String(session), '99', String(tpgid)]
while (rest.length < 19) rest.push('0')
rest.push(started)
return `${pid} (command with space) ${rest.join(' ')}`
}
function syscall(number: number, ...args: number[]): string {
const six = [...args]
while (six.length < 6) six.push(0)
return `${number} ${six.slice(0, 6).map(value => `0x${value.toString(16)}`).join(' ')}`
}
function fakeInternals() {
const files = new Map<string, string>()
const dirs = new Map<string, string[]>()
const memories = new Map<string, Buffer>()
const fds = new Map<number, string>()
const kills: Array<[number, NodeJS.Signals]> = []
let nextFd = 10
let ps = ''
let tpgid = '0'
const internals: ProcessInspectorInternals = {
readFile(path) {
const value = files.get(path)
if (value === undefined) throw new Error(`missing ${path}`)
return value
},
readDir(path) {
const value = dirs.get(path)
if (value === undefined) throw new Error(`missing ${path}`)
return value
},
open(path) {
if (!memories.has(path)) throw new Error(`missing ${path}`)
const fd = nextFd++
fds.set(fd, path)
return fd
},
read(fd, buffer, length, position) {
const path = fds.get(fd)
if (path === undefined) throw new Error('bad fd')
const source = memories.get(path)
if (source === undefined) throw new Error('missing memory')
return source.copy(buffer, 0, position, Math.min(source.length, position + length))
},
close(fd) { fds.delete(fd) },
exec(_file, args) {
if (args.includes('tpgid=')) return tpgid
return ps
},
kill(pid, signal) { kills.push([pid, signal]) },
}
return {
internals, files, dirs, memories, kills,
setPs(value: string) { ps = value },
setTpgid(value: string) { tpgid = value },
}
}
describe('Linux process inspector', () => {
it('parses stat safely, captures only the rooted process tree, and signals identities', () => {
expect(parseProcStat('bad')).toBeUndefined()
expect(parseProcStat('1 () S')).toBeUndefined()
expect(parseProcStat(stat(10, 20, 30, 40, '500'))).toEqual({ pid: 10, parentPid: 1, pgrp: 20, session: 30, tpgid: 40, started: '500' })
const fake = fakeInternals()
fake.dirs.set('/proc', ['x', '10', '11', '12', '13', '14'])
fake.files.set('/proc/10/stat', stat(10, 20, 30, 40, '500'))
fake.files.set('/proc/11/stat', stat(11, 21, 30, -1, '501'))
fake.files.set('/proc/12/stat', stat(12, 22, 30, -1, '502', 10))
fake.files.set('/proc/13/stat', stat(13, 23, 30, -1, '503', 12))
const inspector = createProcessInspector('linux', 'x64', fake.internals)
expect(inspector.foregroundPgid(10)).toBe(40)
expect(inspector.foregroundPgid(11)).toBeUndefined()
expect(inspector.foregroundPgid(99)).toBeUndefined()
expect(inspector.processTree(10)).toEqual([
{ pid: 13, started: '503' },
{ pid: 12, started: '502' },
{ pid: 10, started: '500' },
])
expect(inspector.processTree(99)).toEqual([])
expect(inspector.isAlive({ pid: 10, started: '500' })).toBe(true)
expect(inspector.isAlive({ pid: 10, started: 'old' })).toBe(false)
inspector.signalGroup(40, 'SIGINT')
inspector.signalProcess({ pid: 10, started: '500' }, 'SIGTERM')
inspector.signalProcess({ pid: 10, started: 'old' }, 'SIGKILL')
expect(fake.kills).toEqual([[-40, 'SIGINT'], [10, 'SIGTERM']])
})
it('detects read, select, poll, and epoll waits across non-leader threads', () => {
const fake = fakeInternals()
fake.dirs.set('/proc', ['100', '101'])
fake.files.set('/proc/100/stat', stat(100, 77, 100, 77, '1'))
fake.files.set('/proc/101/stat', stat(101, 77, 100, 77, '2'))
fake.dirs.set('/proc/100/task', ['100'])
fake.dirs.set('/proc/101/task', ['101', '102'])
const inspector = createProcessInspector('linux', 'x64', fake.internals)
fake.files.set('/proc/100/task/100/syscall', 'running')
fake.files.set('/proc/101/task/101/syscall', '-1 0x0')
fake.files.set('/proc/101/task/102/syscall', syscall(0, 0))
expect(inspector.isStdinWaiting(77)).toBe(true)
fake.files.set('/proc/101/task/102/syscall', syscall(270, 1, 0x10))
const fdSet = Buffer.alloc(0x11)
fdSet[0x10] = 1
fake.memories.set('/proc/101/mem', fdSet)
expect(inspector.isStdinWaiting(77)).toBe(true)
const poll = Buffer.alloc(8)
poll.writeInt32LE(0, 0)
poll.writeInt16LE(1, 4)
fake.files.set('/proc/101/task/102/syscall', syscall(7, 0x20, 1))
fake.memories.set('/proc/101/mem', Buffer.concat([Buffer.alloc(0x20), poll]))
expect(inspector.isStdinWaiting(77)).toBe(true)
fake.files.set('/proc/101/task/102/syscall', syscall(232, 5, 0, 1))
fake.files.set('/proc/101/fdinfo/5', 'pos: 0\ntfd: 0 events: 19\n')
expect(inspector.isStdinWaiting(77)).toBe(true)
})
it('fails closed on unsupported, malformed, unreadable, or non-stdin waits', () => {
const fake = fakeInternals()
fake.dirs.set('/proc', ['100'])
fake.files.set('/proc/100/stat', stat(100, 77, 100, 77, '1'))
fake.dirs.set('/proc/100/task', ['100'])
fake.files.set('/proc/100/task/100/syscall', syscall(0, 2))
expect(createProcessInspector('linux', 'mips', fake.internals).isStdinWaiting(77)).toBe(false)
expect(createProcessInspector('linux', 'x64', fake.internals).isStdinWaiting(77)).toBe(false)
fake.files.set('/proc/100/task/100/syscall', syscall(270, 1, 0))
expect(createProcessInspector('linux', 'x64', fake.internals).isStdinWaiting(77)).toBe(false)
fake.files.set('/proc/100/task/100/syscall', syscall(7, 0, 0))
expect(createProcessInspector('linux', 'x64', fake.internals).isStdinWaiting(77)).toBe(false)
fake.files.set('/proc/100/task/100/syscall', syscall(7, 0, 1))
expect(createProcessInspector('linux', 'x64', fake.internals).isStdinWaiting(77)).toBe(false)
fake.files.set('/proc/100/task/100/syscall', syscall(7, 0x20, 1))
expect(createProcessInspector('linux', 'x64', fake.internals).isStdinWaiting(77)).toBe(false)
fake.files.set('/proc/100/task/100/syscall', syscall(232, 9, 0, 1))
expect(createProcessInspector('linux', 'x64', fake.internals).isStdinWaiting(77)).toBe(false)
fake.files.set('/proc/100/task/100/syscall', syscall(999))
expect(createProcessInspector('linux', 'x64', fake.internals).isStdinWaiting(77)).toBe(false)
fake.files.set('/proc/100/task/100/syscall', 'not-a-number 0x0')
expect(createProcessInspector('linux', 'x64', fake.internals).isStdinWaiting(77)).toBe(false)
fake.dirs.delete('/proc/100/task')
expect(createProcessInspector('linux', 'x64', fake.internals).isStdinWaiting(77)).toBe(false)
fake.dirs.set('/proc', ['100', '200'])
fake.files.set('/proc/200/stat', stat(200, 88, 200, 88, '2'))
expect(createProcessInspector('linux', 'x64', fake.internals).isStdinWaiting(77)).toBe(false)
})
it('contains unreadable syscall, memory, and fdinfo boundaries', () => {
const fake = fakeInternals()
fake.dirs.set('/proc', ['100'])
fake.files.set('/proc/100/stat', stat(100, 77, 100, 77, '1'))
fake.dirs.set('/proc/100/task', ['100'])
const inspector = createProcessInspector('linux', 'x64', fake.internals)
expect(inspector.isStdinWaiting(77)).toBe(false)
fake.files.set('/proc/100/task/100/syscall', syscall(270, 1, 0x10))
expect(inspector.isStdinWaiting(77)).toBe(false)
fake.files.set('/proc/100/task/100/syscall', syscall(232, 5, 0, 1))
expect(inspector.isStdinWaiting(77)).toBe(false)
const noStdinPoll = Buffer.alloc(0x28)
noStdinPoll.writeInt32LE(2, 0x20)
noStdinPoll.writeInt16LE(1, 0x24)
fake.memories.set('/proc/100/mem', noStdinPoll)
fake.files.set('/proc/100/task/100/syscall', syscall(7, 0x20, 1))
expect(inspector.isStdinWaiting(77)).toBe(false)
})
})
describe('macOS process inspector', () => {
it('reads tpgid and process trees, contains cycles, and identity-fences signals', () => {
const fake = fakeInternals()
fake.setTpgid('55\n')
fake.setPs(' 10 1 Mon Jul 21 10:00:00 2026\n 11 10 Mon Jul 21 10:00:01 2026\n 12 11 Mon Jul 21 10:00:02 2026\n 13 99 Mon Jul 21 10:00:03 2026\nmalformed\n')
const inspector = createProcessInspector('darwin', 'arm64', fake.internals)
expect(inspector.foregroundPgid(10)).toBe(55)
expect(inspector.isStdinWaiting(55)).toBe(false)
expect(inspector.processTree(10)).toEqual([
{ pid: 12, started: 'Mon Jul 21 10:00:02 2026' },
{ pid: 11, started: 'Mon Jul 21 10:00:01 2026' },
{ pid: 10, started: 'Mon Jul 21 10:00:00 2026' },
])
expect(inspector.processTree(99)).toEqual([])
expect(inspector.isAlive({ pid: 11, started: 'Mon Jul 21 10:00:01 2026' })).toBe(true)
inspector.signalGroup(55, 'SIGTSTP')
inspector.signalProcess({ pid: 11, started: 'Mon Jul 21 10:00:01 2026' }, 'SIGKILL')
inspector.signalProcess({ pid: 12, started: 'missing' }, 'SIGTERM')
expect(fake.kills).toEqual([[-55, 'SIGTSTP'], [11, 'SIGKILL']])
fake.setPs(' 10 11 Mon Jul 21 10:00:00 2026\n 11 10 Mon Jul 21 10:00:01 2026\n')
expect(inspector.processTree(10)).toEqual([
{ pid: 11, started: 'Mon Jul 21 10:00:01 2026' },
{ pid: 10, started: 'Mon Jul 21 10:00:00 2026' },
])
})
it('returns undefined for missing or invalid foreground groups and rejects unsupported platforms', () => {
const fake = fakeInternals()
fake.setTpgid('-1')
expect(createProcessInspector('darwin', 'arm64', fake.internals).foregroundPgid(1)).toBeUndefined()
fake.internals.exec = () => { throw new Error('gone') }
expect(createProcessInspector('darwin', 'arm64', fake.internals).foregroundPgid(1)).toBeUndefined()
expect(() => createProcessInspector('win32', 'x64', fake.internals)).toThrow('unsupported platform win32')
})
})

View File

@@ -0,0 +1,62 @@
import { describe, expect, it } from 'vitest'
import { normalizeTerminalText, TerminalSanitizer } from '@deepseek-ai/dsh-pty-local/src/sanitize.ts'
describe('TerminalSanitizer', () => {
it('removes split CSI and owned OSC prompt markers', () => {
const sanitizer = new TerminalSanitizer(64)
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 })
})
it('drops unrelated OSC, short escapes, BEL, and incomplete trailing escape', () => {
const sanitizer = new TerminalSanitizer(64)
expect(sanitizer.push('a\x1b]0;title\x1b\\b\x1b7c\x07')).toEqual({ text: 'abc', prompt: false })
expect(sanitizer.push('tail\x1b')).toEqual({ text: 'tail', prompt: false })
expect(sanitizer.flush()).toBe('')
expect(sanitizer.flush()).toBe('')
expect(sanitizer.push('\x1b]0;one\x07middle\x1b\\')).toEqual({ text: 'middle', prompt: false })
expect(sanitizer.push('\x1b]0;one\x1b\\middle\x07')).toEqual({ text: 'middle', prompt: false })
expect(sanitizer.push('\x1b]0;title\x1b\\')).toEqual({ text: '', prompt: false })
})
it('normalizes CRLF and standalone carriage returns', () => {
expect(normalizeTerminalText('a\r\nb\rc\x07')).toBe('a\nb\nc')
})
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 })
expect(oscBel.push('more\x07tail')).toEqual({ text: 'tail', prompt: false })
const oscSt = new TerminalSanitizer(8)
oscSt.push(`\x1b]0;${'x'.repeat(16)}`)
expect(oscSt.push('more\x1b')).toEqual({ text: '', prompt: false })
expect(oscSt.push('\\tail')).toEqual({ text: 'tail', prompt: false })
const oscDirectSt = new TerminalSanitizer(8)
oscDirectSt.push(`\x1b]0;${'x'.repeat(16)}`)
expect(oscDirectSt.push('more\x1b\\tail')).toEqual({ text: 'tail', prompt: false })
const oscFalseSt = new TerminalSanitizer(8)
oscFalseSt.push(`\x1b]0;${'x'.repeat(16)}`)
oscFalseSt.push('\x1b')
expect(oscFalseSt.push('more')).toEqual({ text: '', prompt: false })
expect(oscFalseSt.push('\x07tail')).toEqual({ text: 'tail', prompt: false })
const oscNonTerminatingEscape = new TerminalSanitizer(8)
oscNonTerminatingEscape.push(`\x1b]0;${'x'.repeat(16)}`)
expect(oscNonTerminatingEscape.push('more\x1bxmore\x07tail')).toEqual({ text: 'tail', prompt: false })
const csi = new TerminalSanitizer(8)
expect(csi.push(`\x1b[${'1'.repeat(16)}`)).toEqual({ text: '', prompt: false })
expect(csi.push('123')).toEqual({ text: '', prompt: false })
expect(csi.push('mtext')).toEqual({ text: 'text', prompt: false })
const flushed = new TerminalSanitizer(8)
flushed.push(`\x1b]0;${'x'.repeat(16)}`)
expect(flushed.flush()).toBe('')
expect(flushed.push('text')).toEqual({ text: 'text', prompt: false })
})
})

View File

@@ -0,0 +1,358 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import type { IDisposable, IPty } from 'node-pty'
import { LocalPtySession } from '@deepseek-ai/dsh-pty-local/src/session.ts'
import type { ResolvedConfig } from '@deepseek-ai/dsh-pty-local/src/config.ts'
import type { ProcessIdentity, ProcessInspector } from '@deepseek-ai/dsh-pty-local/src/process-inspector.ts'
import type { PtySendOperation, PtySessionStatus, PtySignal } from '@deepseek-ai/dsh-pty'
class FakeTerminal {
pid = 123
cols = 80
rows = 24
process = 'bash'
handleFlowControl = false
writes: string[] = []
kills: string[] = []
throwWrite = false
throwKill = false
private dataListeners = new Set<(data: string) => void>()
private exitListeners = new Set<(event: { exitCode: number; signal?: number }) => void>()
readonly onData = (listener: (data: string) => void): IDisposable => {
this.dataListeners.add(listener)
return { dispose: () => this.dataListeners.delete(listener) }
}
readonly onExit = (listener: (event: { exitCode: number; signal?: number }) => void): IDisposable => {
this.exitListeners.add(listener)
return { dispose: () => this.exitListeners.delete(listener) }
}
emitData(data: string): void {
for (const listener of this.dataListeners) listener(data)
}
emitExit(exitCode = 0, signal?: number): void {
for (const listener of this.exitListeners) listener({ exitCode, ...signal === undefined ? {} : { signal } })
}
write(data: string): void {
if (this.throwWrite) throw new Error('write failed')
this.writes.push(data)
}
kill(signal?: string): void {
if (this.throwKill) throw new Error('kill failed')
this.kills.push(signal ?? 'SIGHUP')
this.emitExit(0, signal === 'SIGKILL' ? 9 : 15)
}
resize() {}
clear() {}
pause() {}
resume() {}
asPty(): IPty {
return this
}
}
class FakeInspector implements ProcessInspector {
pgid: number | undefined = 456
waiting = false
members: ProcessIdentity[] = []
alive = new Set<number>()
groups: Array<[number, PtySignal]> = []
processes: Array<[number, 'SIGTERM' | 'SIGKILL']> = []
throwGroup = false
throwProcess = false
removeOnSignal = true
foregroundPgid() { return this.pgid }
isStdinWaiting() { return this.waiting }
processTree() { return this.members }
isAlive(identity: ProcessIdentity) { return this.alive.has(identity.pid) }
signalGroup(pgid: number, signal: PtySignal) {
if (this.throwGroup) throw new Error('group failed')
this.groups.push([pgid, signal])
}
signalProcess(identity: ProcessIdentity, signal: 'SIGTERM' | 'SIGKILL') {
if (this.throwProcess) throw new Error('process raced')
this.processes.push([identity.pid, signal])
if (this.removeOnSignal) this.alive.delete(identity.pid)
}
}
function config(overrides: Partial<ResolvedConfig> = {}): ResolvedConfig {
return {
backendType: 'shell', shellPath: '/bin/bash', shellArgs: [], rows: 24, cols: 80,
scrollbackLines: 10, scrollbackMaxBytes: 128, maxReadBytes: 64,
pollIntervalMs: 10, exactProbeAfterMs: 20, idleSilenceMs: 50, timeoutMs: 100,
disposeGraceMs: 20,
...overrides,
}
}
afterEach(() => { vi.useRealTimers() })
async function initialize(session: LocalPtySession, terminal: FakeTerminal): Promise<void> {
const pending = session.initialize()
terminal.emitData('\x1b]133;D;0\x07dsh> ')
await vi.advanceTimersByTimeAsync(10)
await pending
}
describe('LocalPtySession readiness and output', () => {
it('captures prompt MOTD, writes submit explicitly, and settles exact stdin waits', async () => {
vi.useFakeTimers()
const terminal = new FakeTerminal()
const inspector = new FakeInspector()
const session = new LocalPtySession(terminal.asPty(), inspector, config())
await initialize(session, terminal)
expect(session.motd).toBe('dsh> ')
inspector.waiting = true
const operation = session.startSend({ text: 'python3', submit: true })
expect(terminal.writes).toEqual(['python3', '\r'])
terminal.emitData('Python\r\n>>> ')
await vi.advanceTimersByTimeAsync(20)
expect(await operation.done).toMatchObject({ waitReason: 'stdin_read', viewport: 'Python\n>>> ', sessionStatus: { kind: 'running' } })
expect(operation.cancel()).toBe(false)
})
it('distinguishes inferred idle, timeout, exit signal, and operation reads', async () => {
vi.useFakeTimers()
const terminal = new FakeTerminal()
const inspector = new FakeInspector()
const session = new LocalPtySession(terminal.asPty(), inspector, config())
await initialize(session, terminal)
inspector.pgid = undefined
const inferred = session.startSend({ text: 'sleep', submit: false })
terminal.emitData('working')
expect(inferred.readOutput()).toEqual({ delta: 'working', truncated: false })
await vi.advanceTimersByTimeAsync(60)
expect((await inferred.done).waitReason).toBe('inferred_idle')
const timeout = session.startSend({ text: 'blocked', submit: false })
await vi.advanceTimersByTimeAsync(40)
terminal.emitData('.')
await vi.advanceTimersByTimeAsync(40)
terminal.emitData('.')
await vi.advanceTimersByTimeAsync(30)
expect((await timeout.done).waitReason).toBe('timeout')
const exiting = session.startSend({ text: 'exit', submit: true })
terminal.emitExit(7, 9)
expect(await exiting.done).toMatchObject({ waitReason: 'session_exit', sessionStatus: { kind: 'exited', exitCode: 7, signal: 'SIGKILL' } })
expect(() => session.startSend({ text: '', submit: false })).toThrow('has exited')
})
it('cancels with Ctrl-C, observes AbortSignal, and contains write failures', async () => {
vi.useFakeTimers()
const terminal = new FakeTerminal()
const inspector = new FakeInspector()
const session = new LocalPtySession(terminal.asPty(), inspector, config())
await initialize(session, terminal)
const controller = new AbortController()
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')
terminal.emitData('\x1b]133;D;130\x07dsh> ')
await vi.advanceTimersByTimeAsync(10)
await operation.done
const aborted = new AbortController()
aborted.abort()
expect(() => session.startSend({ text: '', submit: false, signal: aborted.signal })).toThrow('aborted before write')
terminal.throwWrite = true
const failed = session.startSend({ text: 'x', submit: false })
await expect(failed.done).rejects.toThrow('write failed')
const failedInternal = failed as unknown as { append(text: string): void; fail(error: unknown): void }
failedInternal.append('ignored')
failedInternal.fail(new Error('ignored'))
})
it('handles startup exit, unknown exit signals, cancel-write failure, and stale polls', async () => {
vi.useFakeTimers()
const startupTerminal = new FakeTerminal()
const startup = new LocalPtySession(startupTerminal.asPty(), new FakeInspector(), config())
const initializing = startup.initialize(new AbortController().signal)
startupTerminal.emitExit(1)
await expect(initializing).rejects.toThrow('exited during startup')
expect(startup.status()).toEqual({ kind: 'exited', exitCode: 1, signal: null })
const terminal = new FakeTerminal()
const session = new LocalPtySession(terminal.asPty(), new FakeInspector(), config())
await initialize(session, terminal)
const operation = session.startSend({ text: '', submit: false })
const operationInternal = operation as unknown as {
append(text: string): void
settle(reason: 'timeout', status: PtySessionStatus, inherited: boolean): void
}
operationInternal.append('')
const sessionInternal = session as unknown as {
pollReadiness(operation: PtySendOperation): void
statusValue: PtySessionStatus
appendOutput(text: string): void
}
sessionInternal.appendOutput('')
sessionInternal.pollReadiness({} as PtySendOperation)
sessionInternal.statusValue = { kind: 'exited', exitCode: 2, signal: null }
sessionInternal.pollReadiness(operation)
await operation.done
operationInternal.settle('timeout', { kind: 'running' }, false)
const unknownTerminal = new FakeTerminal()
const unknown = new LocalPtySession(unknownTerminal.asPty(), new FakeInspector(), config())
unknownTerminal.emitExit(1, 999)
expect(unknown.status()).toEqual({ kind: 'exited', exitCode: 1, signal: null })
const cancelTerminal = new FakeTerminal()
const cancel = new LocalPtySession(cancelTerminal.asPty(), new FakeInspector(), config())
await initialize(cancel, cancelTerminal)
const cancellable = cancel.startSend({ text: '', submit: false })
cancelTerminal.throwWrite = true
expect(cancellable.cancel()).toBe(true)
await expect(cancellable.done).rejects.toThrow('write failed')
})
it('does not treat zero-output startup silence as readiness and fails on startup timeout', 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 })
await vi.advanceTimersByTimeAsync(60)
expect(settled).toBe(false)
terminal.emitData('\x1b]133;D;0\x07dsh> ')
await vi.advanceTimersByTimeAsync(10)
await initializing
const timeoutTerminal = new FakeTerminal()
const timeout = new LocalPtySession(timeoutTerminal.asPty(), new FakeInspector(), config())
const timedOut = expect(timeout.initialize()).rejects.toThrow('startup timeout')
await vi.advanceTimersByTimeAsync(100)
await timedOut
})
it('trusts prompt markers only while the startup shell owns the foreground group', async () => {
vi.useFakeTimers()
const terminal = new FakeTerminal()
const inspector = new FakeInspector()
const session = new LocalPtySession(terminal.asPty(), inspector, config())
await initialize(session, terminal)
const operation = session.startSend({ text: 'run', submit: true })
let settled = false
void operation.done.then(() => { settled = true })
inspector.pgid = 789
terminal.emitData('\x1b]133;D;0\x07spoofed')
await vi.advanceTimersByTimeAsync(10)
expect(settled).toBe(false)
inspector.pgid = 456
terminal.emitData('\x1b]133;D;0\x07dsh> ')
await vi.advanceTimersByTimeAsync(10)
expect((await operation.done).waitReason).toBe('stdin_read')
})
})
describe('LocalPtySession bounds, signals, and teardown', () => {
it('validates pagination and enforces line/UTF-8 bounds', async () => {
vi.useFakeTimers()
const terminal = new FakeTerminal()
const session = new LocalPtySession(
terminal.asPty(),
new FakeInspector(),
config({ scrollbackLines: 3, scrollbackMaxBytes: 12, maxReadBytes: 6 }),
)
expect(session.read({})).toMatchObject({ text: '' })
await initialize(session, terminal)
const operation = session.startSend({ text: '', submit: false })
terminal.emitData('一\n二\n三\n四')
await vi.advanceTimersByTimeAsync(60)
expect((await operation.done).truncated).toBe(true)
const page = session.read({ offset: 0, count: 3 })
expect(Buffer.byteLength(page.text)).toBeLessThanOrEqual(6)
expect(page.truncated).toBe(true)
expect(session.read({ offset: 999 })).toMatchObject({ text: '', lineBegin: 999, lineEnd: 999 })
expect(() => session.read({ offset: -1 })).toThrow('offset')
expect(() => session.read({ count: 0 })).toThrow('count')
const tinyTerminal = new FakeTerminal()
const tiny = new LocalPtySession(tinyTerminal.asPty(), new FakeInspector(), config({ maxReadBytes: 1 }))
await initialize(tiny, tinyTerminal)
const tinyOperation = tiny.startSend({ text: '', submit: false })
tinyTerminal.emitData('一')
await vi.advanceTimersByTimeAsync(60)
await tinyOperation.done
expect(tiny.read({ offset: 0, count: 1 }).text).toBe('')
})
it('signals verified groups and refuses unresolved or shell-targeted hard kills', async () => {
const terminal = new FakeTerminal()
const inspector = new FakeInspector()
const session = new LocalPtySession(terminal.asPty(), inspector, config())
expect(await session.signal('SIGINT')).toEqual({ delivered: true, targetPgid: 456 })
inspector.pgid = terminal.pid
await expect(session.signal('SIGKILL')).rejects.toThrow('use terminal_close')
inspector.pgid = undefined
await expect(session.signal('SIGTERM')).rejects.toThrow('cannot resolve')
})
it('closes idempotently, contains signal races, and reports survivors', async () => {
const terminal = new FakeTerminal()
const inspector = new FakeInspector()
inspector.members = [{ pid: 123, started: 'a' }]
inspector.alive.add(123)
inspector.throwProcess = true
terminal.throwKill = true
const session = new LocalPtySession(terminal.asPty(), inspector, config({ disposeGraceMs: 1 }))
const closing = session.close('test')
expect(session.close('other')).toBe(closing)
await expect(closing).rejects.toThrow('surviving pids: 123')
expect(() => session.startSend({ text: '', submit: false })).toThrow('closing')
})
it('settles an active send as session_exit when closed mid-operation', async () => {
vi.useFakeTimers()
const terminal = new FakeTerminal()
const session = new LocalPtySession(terminal.asPty(), new FakeInspector(), config({ disposeGraceMs: 50 }))
await initialize(session, terminal)
const operation = session.startSend({ text: 'run', submit: true })
// The shell returns to its prompt while the send is active; a running
// 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
const closing = session.close('mid-send')
await vi.advanceTimersByTimeAsync(60)
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 () => {
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: 20 }))
let settled = false
const closing = session.close('test').then(() => { settled = true })
await vi.advanceTimersByTimeAsync(20)
expect(inspector.processes).toContainEqual([124, 'SIGKILL'])
expect(settled).toBe(false)
inspector.alive.delete(124)
await vi.advanceTimersByTimeAsync(20)
await closing
expect(settled).toBe(true)
})
})

View File

@@ -0,0 +1,33 @@
{
"extends": "../../../tsconfig.base.json",
"compilerOptions": {
"rootDir": "src",
"outDir": "lib/types"
},
"include": [
"src"
],
"references": [
{
"path": "../../../vendor/cosmokit"
},
{
"path": "../../../vendor/cordis"
},
{
"path": "../../../vendor/schemastery"
},
{
"path": "../pty"
},
{
"path": "../../sandbox/sandbox"
},
{
"path": "../../sandbox/sandbox-policy"
},
{
"path": "../../support/invariants"
}
]
}

View File

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

View File

@@ -0,0 +1,42 @@
{
"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"
},
"./invariant": {
"types": "./lib/types/invariant.d.ts",
"default": "./lib/invariant.js"
},
"./src/*": "./src/*",
"./package.json": "./package.json"
},
"files": [
"lib/index.js",
"lib/invariant.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",
"@deepseek-ai/dsh-invariants": "^0.0.1",
"cordis": "^4.0.0-rc.7"
},
"devDependencies": {
"@deepseek-ai/dsh-agent": "workspace:^",
"@deepseek-ai/dsh-brand": "workspace:^",
"@deepseek-ai/dsh-invariants": "workspace:^",
"@deepseek-ai/dsh-session": "workspace:^",
"cordis": "^4.0.0-rc.7"
}
}

View File

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

View File

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

View File

@@ -0,0 +1,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 `terminal_open`. */
readonly motd: string
/** Top-level process id when one exists. */
readonly pid?: number
/** Start one exclusive send operation. */
startSend(request: PtySendRequest): PtySendOperation
/** Read one bounded page from retained scrollback. */
read(request: PtyReadRequest): PtyReadResult
/** Signal the verified foreground process group. */
signal(signal: PtySignal): Promise<PtySignalResult>
/** Observe top-level process status. */
status(): PtySessionStatus
/** Idempotently close the captured owned process tree and await quiescence. */
close(reason: string): Promise<void>
}
/** Replaceable provider for one PTY session type. */
export interface PtyBackend {
/** Stable type selected by {@link PtySpawnRequest.type}. */
readonly type: string
/** Create an unpublished session or reject after cleaning partial resources. */
spawn(spec: PtyBackendSpawnSpec): Promise<PtyBackendSession>
}
/** Successful publication returned by {@link PtyService.spawn}. */
export interface PtySpawnResult extends PtySessionSnapshot {
/** Initial bounded output captured before publication. */
motd: string
}

View File

@@ -0,0 +1,367 @@
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' })
})
it('clears registries and runs owner cleanups even when a session close fails', async () => {
const ctx = await harness()
const service = ctx.pty
const b = backend()
service.registerBackend(b.provider)
const owner = stubAgent(ctx, 'owner')
ctx.agents.register(owner)
await service.spawn(owner, { type: 'stub' })
b.sessions[0]!.rejectClose = true
const internal = service as unknown as {
disposeAll(): Promise<void>
backends: Map<string, unknown>
ownerCleanups: Map<Agent, unknown>
}
// Teardown surfaces the close failure, but its finally still clears the
// backend and owner-cleanup registries instead of orphaning them.
await expect(internal.disposeAll()).rejects.toThrow('failed to close 1 PTY session')
expect(internal.backends.size).toBe(0)
expect(internal.ownerCleanups.size).toBe(0)
})
})

View File

@@ -0,0 +1,27 @@
{
"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"
},
{
"path": "../../support/invariants"
}
]
}

View File

@@ -0,0 +1,60 @@
# @deepseek-ai/dsh-tool-pty
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.
## Model Experience
### System prompt
#### What the model sees
The plugin contributes this fixed guidance section:
##### Terminal guidance
```markdown
Use a terminal session only when work needs persistent terminal state or interactive stdin; prefer bash/read/write/edit for bounded one-shot operations. Track every terminal session id and close sessions that no longer matter. An inferred_idle or timeout result does not prove the foreground command exited.
```
#### Token effect
Small fixed input cost on every request while the plugin is active.
#### KV Cache effect
Prefix-stable while the registration scope and guidance text are unchanged.
### Tool schemas
#### What the model sees
The six generated schemas are listed in the [`dsh-tool-pty` catalog section](../../../docs/tool-catalog.md#deepseek-aidsh-tool-pty). Their fixed schema tokens are present whenever this plugin is active; agent-scoped tool filtering may hide them.
#### Token effect
Fixed schema cost on requests where the tools are visible.
#### KV Cache effect
Prefix-stable while tool visibility and definitions are unchanged.
### Tool results and task context
#### 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.
#### Token effect
Data-dependent and bounded by the backend; each returned result remains in history until compaction.
#### KV Cache effect
Append-only; new results follow the reusable request prefix.
## Known Limitations and Deferred Work
- No named key sequence, TUI, BEL, resize, auto-start, or cross-agent sharing schema is exposed.
- Background mode requires both `@deepseek-ai/dsh-tasks` and its model-facing control surface.

View File

@@ -0,0 +1,56 @@
{
"name": "@deepseek-ai/dsh-tool-pty",
"description": "Six model-facing persistent PTY tools with owner isolation and generic background-task integration",
"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"
},
"./invariant": {
"types": "./lib/types/invariant.d.ts",
"default": "./lib/invariant.js"
},
"./src/*": "./src/*",
"./package.json": "./package.json"
},
"files": [
"lib/index.js",
"lib/invariant.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-invariants": "^0.0.1",
"@deepseek-ai/dsh-llm": "^0.0.1",
"@deepseek-ai/dsh-pty": "^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",
"cordis": "^4.0.0-rc.7"
},
"devDependencies": {
"@cordisjs/plugin-include": "workspace:^",
"@cordisjs/plugin-loader": "workspace:^",
"@deepseek-ai/dsh-agent": "workspace:^",
"@deepseek-ai/dsh-invariants": "workspace:^",
"@deepseek-ai/dsh-llm": "workspace:^",
"@deepseek-ai/dsh-pty": "workspace:^",
"@deepseek-ai/dsh-pty-local": "workspace:^",
"@deepseek-ai/dsh-sandbox": "workspace:^",
"@deepseek-ai/dsh-sandbox-policy": "workspace:^",
"@deepseek-ai/dsh-session": "workspace:^",
"@deepseek-ai/dsh-system-prompt": "workspace:^",
"@deepseek-ai/dsh-tasks": "workspace:^",
"@deepseek-ai/dsh-tool-tasks": "workspace:^",
"@deepseek-ai/dsh-tools": "workspace:^",
"cordis": "^4.0.0-rc.7"
}
}

View File

@@ -0,0 +1,224 @@
/**
* Six model-facing persistent terminal tools. Owner identity comes from the exact
* tool execution Agent; generic `ctx.tasks` owns background ids and collection.
* @module @deepseek-ai/dsh-tool-pty
*/
import { Context } from 'cordis'
import type { Agent } from '@deepseek-ai/dsh-agent'
import type { ContentBlock } from '@deepseek-ai/dsh-llm'
import { PtySessionId } from '@deepseek-ai/dsh-pty'
import type { PtySendResult, PtySessionId as PtySessionIdType, PtySignal } from '@deepseek-ai/dsh-pty'
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'
declare module '@deepseek-ai/dsh-tasks' {
interface TaskKindMap {
'pty-send': 'pty-send'
}
}
/** Cordis plugin name. */
export const name = 'tool-pty'
/** Required capability, registry, and prompt services. */
export const inject = ['pty', 'tools', 'systemPrompt']
interface SpawnArgs {
type: string
name?: string
cwd?: string
}
interface SessionArgs {
sessionId: string
}
interface SendArgs extends SessionArgs {
text: string
submit?: boolean
run_in_background?: boolean
}
interface ReadArgs extends SessionArgs {
offset?: number
count?: number
}
interface SignalArgs extends SessionArgs {
signal: PtySignal
}
function requireAgent(agent: Agent | undefined): Agent {
if (agent === undefined) throw new Error('terminal tools require an initiating agent')
return agent
}
function sessionId(args: SessionArgs): PtySessionIdType {
if (args.sessionId.length === 0) {
throw new Error('sessionId must be a non-empty string')
}
return PtySessionId(args.sessionId)
}
function textResult(text: string): ContentBlock[] {
return [{ type: 'text', text }]
}
function rawResultText(result: ToolResult): string | undefined {
if (result.content.length !== 1) return undefined
const block = result.content[0]
return block?.type === 'text' ? block.text : undefined
}
function sendDetail(result: PtySendResult): string {
return result.sessionStatus.kind === 'running'
? `wait: ${result.waitReason}`
: `session exited: ${result.sessionStatus.exitCode ?? result.sessionStatus.signal ?? 'unknown'}`
}
/** Register all terminal tools and the minimal usage guidance. */
export function apply(ctx: Context): void {
ctx.systemPrompt.section({
name: 'tool:pty',
order: 106,
text: 'Use a terminal session only when work needs persistent terminal state or interactive stdin; prefer bash/read/write/edit for bounded one-shot operations. Track every terminal session id and close sessions that no longer matter. An inferred_idle or timeout result does not prove the foreground command exited.',
})
ctx.tools.register(defineTool({
name: 'terminal_open',
description: 'Create a persistent, owner-isolated terminal session from a registered backend type. Use this for shell or REPL state that must survive across tool calls.',
parameters: {
type: { type: 'string', required: true, description: 'Registered terminal backend type, usually "shell".' },
name: { type: 'string', description: 'Optional owner-local display name such as "main" or "gdb".' },
cwd: { type: 'string', description: 'Initial working directory. Defaults to the deployment workspace root.' },
},
async execute(args: SpawnArgs, exec) {
if (args.type.length === 0) throw new Error('type must be a non-empty string')
const result = await ctx.pty.spawn(requireAgent(exec.agent), {
type: args.type,
...args.name !== undefined ? { name: args.name } : {},
...args.cwd !== undefined ? { cwd: args.cwd } : {},
}, exec.signal)
return textResult(renderSpawn(result))
},
presentCall: (args) => {
const parsed = args
return { card: 'generic', title: `Open terminal ${parsed.name ?? parsed.type}`, kind: 'execute' }
},
}))
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.',
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.' },
},
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) {
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
const taskId = tasks.start({
kind: 'pty-send',
label: `${id}: ${args.text || '(input)'}`,
owner,
run: () => {
const operation = ctx.pty.startSend(owner, id, request)
return {
cancel: () => {
cancelRequested = true
operation.cancel()
},
done: operation.done.then(
result => ({ status: cancelRequested ? 'killed' as const : 'completed' as const, detail: sendDetail(result) }),
(error: unknown) => ({ status: 'failed' as const, detail: String(error) }),
),
readOutput: () => renderSendRead(operation.readOutput()),
}
},
})
return { content: textResult(`started background task ${taskId}`), 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 }
},
presentCall(args) {
const parsed = args as Partial<SendArgs>
if (parsed.run_in_background === true) {
return { card: 'generic', title: `Send to terminal ${parsed.sessionId as string} in background`, kind: 'execute', rawInput: parsed.text }
}
return { card: 'terminal', title: parsed.text || '(send input)', description: `Terminal ${parsed.sessionId as string}` }
},
presentResult(args, result) {
if ((args as Partial<SendArgs>).run_in_background === true || result.isError) return undefined
const raw = rawResultText(result)
return raw === undefined ? undefined : { card: 'terminal', output: raw }
},
}))
ctx.tools.register(defineTool({
name: 'terminal_read',
description: 'Read a bounded page of retained output from a persistent terminal without sending input.',
parameters: {
sessionId: { type: 'string', required: true, description: 'Terminal session id.' },
offset: { type: 'number', description: 'Newest-relative line offset (default 0).' },
count: { type: 'number', description: 'Requested line count (default 500; backend caps apply).' },
},
execute(args: ReadArgs, exec) {
const result = ctx.pty.read(requireAgent(exec.agent), sessionId(args), {
...args.offset !== undefined ? { offset: args.offset } : {},
...args.count !== undefined ? { count: args.count } : {},
})
return Promise.resolve(textResult(renderRead(result)))
},
presentCall: args => ({ card: 'generic', title: `Read terminal ${(args).sessionId}`, kind: 'read', rawInput: args }),
}))
ctx.tools.register(defineTool({
name: 'terminal_signal',
description: 'Send an allowed signal to the current foreground process group of a persistent terminal.',
parameters: {
sessionId: { type: 'string', required: true, description: 'Terminal session id.' },
signal: { type: 'string', required: true, enum: ['SIGINT', 'SIGTERM', 'SIGKILL', 'SIGTSTP', 'SIGHUP'], description: 'Signal to deliver. Shell-targeted SIGKILL is rejected; use terminal_close.' },
},
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}`)
},
presentCall: args => ({ card: 'generic', title: `Signal terminal ${(args as SignalArgs).sessionId}`, kind: 'execute', rawInput: args }),
}))
ctx.tools.register(defineTool({
name: 'terminal_close',
description: 'Close one persistent terminal and wait until its captured owned process tree is gone.',
parameters: {
sessionId: { type: 'string', required: true, description: 'Terminal session id.' },
},
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`)
},
presentCall: args => ({ card: 'generic', title: `Close terminal ${(args).sessionId}`, kind: 'delete' }),
}))
ctx.tools.register(defineTool({
name: 'terminal_list',
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)))))
},
presentCall: () => ({ card: 'generic', title: 'List terminal sessions', kind: 'read' }),
}))
}

View File

@@ -0,0 +1,30 @@
/**
* Package-owned invariant companion for `@deepseek-ai/dsh-tool-pty`.
* @module @deepseek-ai/dsh-tool-pty/invariant
*/
/* jscpd:ignore-start */
import type { Context } from 'cordis'
import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants'
const PACKAGE_NAME = '@deepseek-ai/dsh-tool-pty'
/** Cordis companion plugin name. */
export const name = 'tool-pty-invariant'
/** Service required before the companion can reserve package ownership. */
export const inject = ['invariants']
/**
* No runtime invariant: this stateless adapter contributes tools and prompt guidance, while PTY
* lifecycle and background-task relationships remain owned by the services it composes.
*/
const install: InvariantInstaller = () => {}
/**
* Register this package's invariant companion.
* @param ctx - Cordis context carrying the invariant service.
* @returns the installed registration's disposer after setup succeeds.
*/
export const apply = (ctx: Context): Promise<() => void> =>
Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install))
/* jscpd:ignore-end */

View File

@@ -0,0 +1,62 @@
/** Model and ACP rendering for persistent terminal tool results. */
import type { PtyReadResult, PtySendRead, PtySendResult, PtySessionSnapshot, PtySpawnResult } from '@deepseek-ai/dsh-pty'
/**
* Render one created session and its bounded MOTD.
* @param result - published spawn result.
* @returns Model-facing session acknowledgement.
*/
export function renderSpawn(result: PtySpawnResult): 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)'}`
}
/**
* Render one settled interactive send.
* @param result - settled send outcome.
* @returns Terminal output plus wait/session markers.
*/
export function renderSend(result: PtySendResult): 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]' : ''}`
}
/**
* Render one incremental background operation read.
* @param read - consuming operation delta.
* @returns Delta plus truncation marker when needed.
*/
export function renderSendRead(read: PtySendRead): string {
return `${read.delta}${read.truncated ? `${read.delta.endsWith('\n') || read.delta.length === 0 ? '' : '\n'}[output truncated]` : ''}`
}
/**
* Render one bounded historical page.
* @param result - retained scrollback page.
* @returns Page text plus pagination and truncation markers.
*/
export function renderRead(result: PtyReadResult): string {
const output = result.text || '(no retained output)'
return `${output}\n[lines: ${result.lineBegin}-${result.lineEnd} of ${result.totalLines}]${result.truncated ? '\n[output truncated]' : ''}`
}
/**
* Render owner-visible live sessions.
* @param sessions - fresh owner-scoped snapshots.
* @returns One line per session or the empty marker.
*/
export function renderList(sessions: PtySessionSnapshot[]): string {
if (sessions.length === 0) return '(no terminal sessions)'
return sessions.map((session) => {
const name = session.name === undefined ? '' : ` (${session.name})`
const pid = session.pid === undefined ? '' : ` pid=${session.pid}`
const status = session.status.kind === 'running'
? 'running'
: `exited code=${session.status.exitCode ?? 'null'} signal=${session.status.signal ?? 'null'}`
return `${session.sessionId}${name} [${session.type}] ${status}${pid}`
}).join('\n')
}

View File

@@ -0,0 +1,120 @@
import { mkdtemp, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { pathToFileURL } from 'node:url'
import { afterEach, describe, expect, it } from 'vitest'
import { Context } from 'cordis'
import Loader from '@cordisjs/plugin-loader'
import Include from '@cordisjs/plugin-include'
import { CallId } from '@deepseek-ai/dsh-llm'
import { Session, SessionId } from '@deepseek-ai/dsh-session'
import AgentRegistry from '@deepseek-ai/dsh-agent'
import type { Agent } from '@deepseek-ai/dsh-agent'
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
import ToolRegistry from '@deepseek-ai/dsh-tools'
import PtyService 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'
import * as PtyLocal from '@deepseek-ai/dsh-pty-local'
import * as ToolPty from '@deepseek-ai/dsh-tool-pty'
let root: string | undefined
let context: Context | undefined
afterEach(async () => {
await context?.fiber.dispose()
context = undefined
if (root !== undefined) await rm(root, { recursive: true, force: true })
root = undefined
})
class PassthroughSandbox extends SandboxProvider {
confine(argv: readonly string[], _policy: SandboxPolicy): ConfinedArgv {
return { argv: [...argv], enforcement: 'full', denialSignatures: [], runnerFailureSignatures: [] }
}
}
function agent(ctx: Context): Agent {
const scope = ctx.plugin(() => {})
const id = SessionId('pty-loader-agent')
const value: Agent = {
id, options: {}, session: new Session(id), status: 'idle', ctx: scope.ctx,
send() {}, steer() {}, inject() {}, cancel() {}, whenIdle: () => Promise.resolve(),
}
ctx.agents.register(value)
return value
}
function resultText(result: { content: { type: string; text?: string }[] }): string {
return result.content.filter(block => block.type === 'text').map(block => block.text).join('')
}
const suite = process.platform === 'linux' || process.platform === 'darwin' ? describe : describe.skip
suite('terminal real Loader composition through cordis.yml', () => {
it('boots cordis.yml and preserves shell state across real tool calls', async () => {
root = await mkdtemp(join(tmpdir(), 'dsh-pty-loader-'))
const configPath = join(root, 'cordis.yml')
await writeFile(configPath, [
"- name: '@deepseek-ai/dsh-agent'",
"- name: '@deepseek-ai/dsh-system-prompt'",
"- name: '@deepseek-ai/dsh-tools'",
"- name: '@deepseek-ai/dsh-pty'",
"- name: '@deepseek-ai/dsh-test-sandbox'",
"- name: '@deepseek-ai/dsh-sandbox-policy'",
' config:',
' mode: danger-full-access',
` workspaceRoot: ${JSON.stringify(root)}`,
"- name: '@deepseek-ai/dsh-pty-local'",
' config:',
' pollIntervalMs: 10',
' exactProbeAfterMs: 20',
' idleSilenceMs: 250',
' timeoutMs: 2000',
' disposeGraceMs: 500',
"- name: '@deepseek-ai/dsh-tool-pty'",
'',
].join('\n'))
context = new Context()
context.baseUrl = pathToFileURL(root).href + '/'
await context.plugin(Loader)
context.loader.builtins.include = Include
const modules = new Map<string, unknown>([
['@deepseek-ai/dsh-agent', AgentRegistry],
['@deepseek-ai/dsh-system-prompt', SystemPrompt],
['@deepseek-ai/dsh-tools', ToolRegistry],
['@deepseek-ai/dsh-pty', PtyService],
['@deepseek-ai/dsh-test-sandbox', PassthroughSandbox],
['@deepseek-ai/dsh-sandbox-policy', SandboxPolicyService],
['@deepseek-ai/dsh-pty-local', PtyLocal],
['@deepseek-ai/dsh-tool-pty', ToolPty],
])
context.loader.internal = {
version: 'v2',
async import(specifier: string) {
if (!modules.has(specifier)) throw new Error(`unexpected Loader import: ${specifier}`)
return modules.get(specifier)
},
} as unknown as NonNullable<typeof context.loader.internal>
await context.loader.create({ name: 'cordis:include', config: { path: pathToFileURL(configPath).href } })
await context.loader.await()
const owner = agent(context)
const signal = new AbortController().signal
const spawn = await context.tools.execute({
signal, callId: CallId('spawn'), name: 'terminal_open', arguments: { type: 'shell', name: 'main', cwd: root }, agent: owner,
})
expect(resultText(spawn)).toContain('started terminal session pty-1 (main)')
await context.tools.execute({
signal, callId: CallId('state'), name: 'terminal_send', arguments: { sessionId: 'pty-1', text: 'export KEEP=loader; cd /' }, agent: owner,
})
const read = await context.tools.execute({
signal, callId: CallId('read'), name: 'terminal_send', arguments: { sessionId: 'pty-1', text: 'printf "cwd=%s keep=%s\\n" "$PWD" "$KEEP"' }, agent: owner,
})
expect(resultText(read)).toContain('cwd=/ keep=loader')
expect(context.pty.list(owner)).toHaveLength(1)
}, 15_000)
})

View File

@@ -0,0 +1,39 @@
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'
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: '' }))
.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' }))
.toContain('pty-2 (main)')
})
it('renders running, exited, empty, and truncated sends', () => {
expect(renderSend({ viewport: '', waitReason: 'timeout', sessionStatus: { kind: 'running' }, truncated: true }))
.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 }))
.toContain('exited code=null signal=SIGTERM')
expect(renderSend({ viewport: 'bye', waitReason: 'session_exit', sessionStatus: { kind: 'exited', exitCode: 2, signal: null }, truncated: false }))
.toContain('exited code=2 signal=null')
expect(renderSend({ viewport: 'bye', waitReason: 'session_exit', sessionStatus: { kind: 'exited', exitCode: null, signal: null }, truncated: false }))
.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]')
expect(renderSendRead({ delta: 'x\n', truncated: true })).toBe('x\n[output truncated]')
expect(renderSendRead({ delta: 'x', truncated: false })).toBe('x')
})
it('renders history and every list status shape', () => {
expect(renderRead({ text: '', totalLines: 0, lineBegin: 0, lineEnd: 0, truncated: true }))
.toBe('(no retained output)\n[lines: 0-0 of 0]\n[output truncated]')
expect(renderList([])).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')
})
})

View File

@@ -0,0 +1,246 @@
import { describe, expect, it } from 'vitest'
import { Context } from 'cordis'
import { CallId } from '@deepseek-ai/dsh-llm'
import { Session, SessionId } from '@deepseek-ai/dsh-session'
import AgentRegistry from '@deepseek-ai/dsh-agent'
import type { Agent } from '@deepseek-ai/dsh-agent'
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
import ToolRegistry from '@deepseek-ai/dsh-tools'
import PtyService, { PtySessionId } from '@deepseek-ai/dsh-pty'
import type { PtyBackend, PtyBackendSession, PtySendOperation, PtySendRequest, PtySessionStatus, PtySignal } from '@deepseek-ai/dsh-pty'
import TaskService from '@deepseek-ai/dsh-tasks'
import * as ToolTasks from '@deepseek-ai/dsh-tool-tasks'
import * as ToolPty from '@deepseek-ai/dsh-tool-pty'
function fakeAgent(ctx: Context, rawId: string): Agent {
const scope = ctx.plugin(() => {})
const id = SessionId(rawId)
const agent: Agent = {
id, options: {}, session: new Session(id), status: 'idle', ctx: scope.ctx,
send() {}, steer() {}, inject() {}, cancel() {}, whenIdle: () => Promise.resolve(),
}
ctx.agents.register(agent)
return agent
}
class StubSession implements PtyBackendSession {
readonly motd = 'stub prompt'
readonly pid = 42
statusValue: PtySessionStatus = { kind: 'running' }
operation: PtySendOperation | undefined
autoSettle = true
rejectOperation = false
closeGate: PromiseWithResolvers<undefined> | undefined
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',
waitReason: 'stdin_read' as const,
sessionStatus: this.statusValue,
truncated: false,
}))
const operation: PtySendOperation = {
done,
readOutput: () => ({ delta: 'live output', truncated: false }),
cancel: () => {
if (cancelled) return false
cancelled = true
settle()
return true
},
}
this.operation = operation
if (this.rejectOperation) queueMicrotask(() => { reject(new Error('operation failed')) })
else if (this.autoSettle) queueMicrotask(settle)
return operation
}
read() {
return { text: 'history', totalLines: 1, lineBegin: 0, lineEnd: 1, truncated: false }
}
async signal(signal: PtySignal) {
return { delivered: true as const, targetPgid: signal === 'SIGINT' ? 10 : 11 }
}
status() { return this.statusValue }
async close() {
if (this.closeGate !== undefined) await this.closeGate.promise
this.statusValue = { kind: 'exited', exitCode: 0, signal: null }
}
}
function stubBackend() {
const sessions: StubSession[] = []
const backend: PtyBackend = {
type: 'stub',
async spawn() {
const session = new StubSession()
sessions.push(session)
return session
},
}
return { backend, sessions }
}
async function setup(tasks: boolean) {
const ctx = new Context()
await ctx.plugin(SystemPrompt)
await ctx.plugin(ToolRegistry)
await ctx.plugin(AgentRegistry)
await ctx.plugin(PtyService)
const stub = stubBackend()
ctx.pty.registerBackend(stub.backend)
if (tasks) {
await ctx.plugin(TaskService)
await ctx.plugin(ToolTasks)
}
await ctx.plugin(ToolPty)
return { ctx, stub, agent: fakeAgent(ctx, tasks ? 'with-tasks' : 'foreground') }
}
let callNumber = 0
const testToolSignal = new AbortController().signal
function call(ctx: Context, name: string, args: unknown, agent?: Agent) {
return ctx.tools.execute({ signal: testToolSignal, callId: CallId(`pty-call-${++callNumber}`), name, arguments: args, ...agent ? { agent } : {} })
}
function callWithSignal(ctx: Context, name: string, args: unknown, agent: Agent, signal: AbortSignal) {
return ctx.tools.execute({ callId: CallId(`pty-call-${++callNumber}`), name, arguments: args, agent, signal })
}
function text(result: { content: { type: string; text?: string }[] }): string {
return result.content.filter(block => block.type === 'text').map(block => block.text).join('')
}
describe('tool-pty foreground surface', () => {
it('registers exactly six schemas and drives the full owner-scoped lifecycle', async () => {
const { ctx, agent } = await setup(false)
expect(['terminal_open', 'terminal_send', 'terminal_read', 'terminal_signal', 'terminal_close', 'terminal_list'].every(name => ctx.tools.get(name) !== undefined)).toBe(true)
const spawned = await call(ctx, 'terminal_open', { type: 'stub', name: 'main' }, agent)
expect(text(spawned)).toContain('started terminal session pty-1 (main)')
expect(text(await call(ctx, 'terminal_list', {}, agent))).toContain('pty-1 (main) [stub] running pid=42')
expect(text(await call(ctx, 'terminal_read', { sessionId: 'pty-1' }, agent))).toContain('history\n[lines: 0-1 of 1]')
expect(text(await call(ctx, 'terminal_signal', { sessionId: 'pty-1', signal: 'SIGINT' }, agent))).toBe('delivered SIGINT to foreground process group 10')
const sent = await call(ctx, 'terminal_send', { sessionId: 'pty-1', text: 'echo hi' }, agent)
expect(text(sent)).toContain('command output\n[wait: stdin_read]\n[session: running]')
expect(text(await call(ctx, 'terminal_close', { sessionId: 'pty-1' }, agent))).toBe('closed terminal session pty-1')
expect(text(await call(ctx, 'terminal_list', {}, agent))).toBe('(no terminal sessions)')
})
it('fails without an initiating agent and rejects background before writing', async () => {
const { ctx, agent, stub } = await setup(false)
expect((await call(ctx, 'terminal_open', { type: 'stub' })).isError).toBe(true)
await call(ctx, 'terminal_open', { type: 'stub' }, agent)
const result = await call(ctx, 'terminal_send', { sessionId: 'pty-1', text: 'sleep 1', run_in_background: true }, agent)
expect(result.isError).toBe(true)
expect(stub.sessions[0]?.operation).toBeUndefined()
})
it('validates required values and forwards optional spawn/read arguments', async () => {
const { ctx, agent } = await setup(false)
expect((await call(ctx, 'terminal_open', { type: '' }, agent)).isError).toBe(true)
expect((await call(ctx, 'terminal_send', { sessionId: '', text: 'x' }, agent)).isError).toBe(true)
expect((await call(ctx, 'terminal_send', { sessionId: 1, text: 'x' }, agent)).isError).toBe(true)
expect((await call(ctx, 'terminal_send', { sessionId: 'pty-1', text: 1 }, agent)).isError).toBe(true)
await call(ctx, 'terminal_open', { type: 'stub', name: 'named', cwd: '/tmp' }, agent)
expect(text(await call(ctx, 'terminal_read', { sessionId: 'pty-1', offset: 2, count: 3 }, agent))).toContain('history')
})
it('declares terminal presentation only for foreground sends', async () => {
const { ctx } = await setup(false)
const definition = ctx.tools.get('terminal_send')
expect(definition?.presentCall?.({ sessionId: 'pty-1', text: 'python3' })).toMatchObject({ card: 'terminal', title: 'python3' })
expect(definition?.presentCall?.({ sessionId: 'pty-1', text: 'make', run_in_background: true })).toMatchObject({ card: 'generic' })
expect(definition?.presentCall?.({ sessionId: 'pty-1', text: '' })).toMatchObject({ card: 'terminal', title: '(send input)' })
expect(definition?.presentResult?.({ sessionId: 'pty-1', text: 'x', run_in_background: true }, { content: [], isError: false })).toBeUndefined()
expect(definition?.presentResult?.({ sessionId: 'pty-1', text: 'x' }, { content: [], isError: true })).toBeUndefined()
expect(definition?.presentResult?.({ sessionId: 'pty-1', text: 'x' }, { content: [], isError: false })).toBeUndefined()
expect(definition?.presentResult?.({ sessionId: 'pty-1', text: 'x' }, { content: [{ type: 'text', text: 'a' }, { type: 'text', text: 'b' }], isError: false })).toBeUndefined()
expect(definition?.presentResult?.({ sessionId: 'pty-1', text: 'x' }, { content: [undefined as never], isError: false })).toBeUndefined()
expect(definition?.presentResult?.({ sessionId: 'pty-1', text: 'x' }, { content: [{ type: 'text', text: 'ok' }], isError: false })).toEqual({ card: 'terminal', output: 'ok' })
expect(ctx.tools.get('terminal_open')?.presentCall?.({ type: 'stub' })).toMatchObject({ card: 'generic', title: 'Open terminal stub' })
expect(ctx.tools.get('terminal_open')?.presentCall?.({ type: 'stub', name: 'main' })).toMatchObject({ card: 'generic', title: 'Open terminal main' })
expect(ctx.tools.get('terminal_read')?.presentCall?.({ sessionId: 'pty-1' })).toMatchObject({ card: 'generic', title: 'Read terminal pty-1' })
expect(ctx.tools.get('terminal_signal')?.presentCall?.({ sessionId: 'pty-1', signal: 'SIGINT' })).toMatchObject({ card: 'generic', title: 'Signal terminal pty-1' })
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' })
})
})
describe('tool-pty task integration', () => {
it('registers a generic task and exposes incremental output', async () => {
const { ctx, agent } = await setup(true)
await call(ctx, 'terminal_open', { type: 'stub' }, agent)
expect(text(await call(ctx, 'terminal_send', { sessionId: 'pty-1', text: 'build', run_in_background: true }, agent))).toBe('started background task pty-send-1')
const output = await call(ctx, 'task_output', { task_id: 'pty-send-1', wait: true }, agent)
expect(text(output)).toContain('live output')
expect(text(output)).toContain('[status: completed, wait: stdin_read]')
})
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)
const controller = new AbortController()
controller.abort()
expect((await callWithSignal(ctx, 'terminal_send', { sessionId: 'pty-1', text: 'x', run_in_background: true }, agent, controller.signal)).isError).toBe(true)
stub.sessions[0]!.autoSettle = false
expect(text(await call(ctx, 'terminal_send', { sessionId: 'pty-1', text: '', run_in_background: true }, agent))).toContain('pty-send-1')
expect(text(await call(ctx, 'task_kill', { task_id: 'pty-send-1' }, agent))).toContain('requested cancellation')
await new Promise(resolve => setTimeout(resolve, 0))
expect(text(await call(ctx, 'task_output', { task_id: 'pty-send-1' }, agent))).toContain('[status: killed')
stub.sessions[0]!.rejectOperation = true
stub.sessions[0]!.autoSettle = false
expect(text(await call(ctx, 'terminal_send', { sessionId: 'pty-1', text: 'bad', run_in_background: true }, agent))).toContain('pty-send-2')
await new Promise(resolve => setTimeout(resolve, 0))
expect(text(await call(ctx, 'task_output', { task_id: 'pty-send-2' }, agent))).toContain('[status: failed')
})
it('reports foreground cancellation after the terminal operation settles', async () => {
const { ctx, agent, stub } = await setup(false)
await call(ctx, 'terminal_open', { type: 'stub' }, agent)
stub.sessions[0]!.autoSettle = false
const controller = new AbortController()
const pending = callWithSignal(ctx, 'terminal_send', { sessionId: 'pty-1', text: 'sleep' }, agent, controller.signal)
await Promise.resolve()
controller.abort()
stub.sessions[0]!.operation?.cancel()
expect((await pending).isError).toBe(true)
})
it('renders the already-closing kill result', async () => {
const { ctx, agent, stub } = await setup(false)
await call(ctx, 'terminal_open', { type: 'stub' }, agent)
stub.sessions[0]!.closeGate = Promise.withResolvers<undefined>()
const first = ctx.pty.kill(agent, PtySessionId('pty-1'))
const second = call(ctx, 'terminal_close', { sessionId: 'pty-1' }, agent)
stub.sessions[0]!.closeGate?.resolve(undefined)
await first
expect(text(await second)).toBe('terminal session pty-1 was already closing')
})
it('renders an exited session detail for background completion', async () => {
const { ctx, agent, stub } = await setup(true)
await call(ctx, 'terminal_open', { type: 'stub' }, agent)
stub.sessions[0]!.statusValue = { kind: 'exited', exitCode: null, signal: null }
await call(ctx, 'terminal_send', { sessionId: 'pty-1', text: 'exit', run_in_background: true }, agent)
const output = await call(ctx, 'task_output', { task_id: 'pty-send-1', wait: true }, agent)
expect(text(output)).toContain('session exited: unknown')
})
})
describe('tool-pty plugin shape', () => {
it('is a named function plugin with no default export', () => {
expect('default' in ToolPty).toBe(false)
expect(ToolPty.name).toBe('tool-pty')
expect(ToolPty.inject).toEqual(['pty', 'tools', 'systemPrompt'])
})
})

View File

@@ -0,0 +1,39 @@
{
"extends": "../../../tsconfig.base.json",
"compilerOptions": {
"rootDir": "src",
"outDir": "lib/types"
},
"include": [
"src"
],
"references": [
{
"path": "../../../vendor/cosmokit"
},
{
"path": "../../../vendor/cordis"
},
{
"path": "../pty"
},
{
"path": "../../core/agent"
},
{
"path": "../../llm/llm"
},
{
"path": "../../core/system-prompt"
},
{
"path": "../../core/tools"
},
{
"path": "../../tasks/tasks"
},
{
"path": "../../support/invariants"
}
]
}