refactor: apply repository naming contract

Apply the accepted pre-release package, service, type, directory, and role renames as one repository-wide change.
This commit is contained in:
Tianyi Cui
2026-08-13 00:54:38 +08:00
parent 101df7cf58
commit a2d0f7f411
3281 changed files with 21730 additions and 21592 deletions
+6
View File
@@ -0,0 +1,6 @@
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/terminal/README.md
README.md: 6204eb4de27bc2361f2a49849865f65729dce12c
README.zh.md: 8b7d704440cb8f9082228fc1265500ebe41e1b58
+15
View File
@@ -0,0 +1,15 @@
# terminal/ — persistent PTY capability family
English | [中文](README.zh.md)
`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`](terminal/README.md) (`@deepseek-ai/dsh-terminal`) | Backend registry, branded ids, exact-Agent ownership, session operations, and awaited cleanup | `ctx.terminals` |
| `terminal-bash` (`@deepseek-ai/dsh-terminal-bash`) | Shell backend over `ctx.subprocess.spawnTerminal`: readiness detection, bounded terminal state, sandbox policy, and session operations | registers on `ctx.terminals` |
| `tool-terminal` (`@deepseek-ai/dsh-tool-terminal`) | 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).
The subsystem reference — ids, backend/session contracts, send readiness, bounded reads — is [docs/subsystems/terminal.md](../../docs/subsystems/terminal.md); design and deferred boundaries in the [persistent PTY Agent Note](../../.agents/notes/implemented/feature/2026-07-16-persistent-pty-sessions.md).
+15
View File
@@ -0,0 +1,15 @@
# terminal/:持久 PTY 能力家族
[English](README.md) | 中文
`PTY` 的全称是 **Pseudo-Terminal(伪终端)**。这项能力提供持久且限定所有者范围的终端会话,适用于需要跨工具调用保留状态或使用交互式 stdin 的工作流。PTY 是单次 bash 与文件系统工具的补充,不会取代后两者更严格的逐操作约定。
| 包 | 职责 | ctx 键 |
|---|---|---|
| [`pty`](terminal/README.md)`@deepseek-ai/dsh-terminal`) | 后端注册表、品牌化 id、精确的 Agent 所有权、会话操作与等待完成的清理 | `ctx.terminals` |
| `terminal-bash``@deepseek-ai/dsh-terminal-bash` | `ctx.subprocess.spawnTerminal` 之上的 shell 后端:就绪检测、有界终端状态、沙箱策略与会话操作 | 注册到 `ctx.terminals` |
| `tool-terminal``@deepseek-ai/dsh-tool-terminal`) | 6 个面向模型的工具,并为后台发送集成通用任务 | 注册到 `ctx.tools` |
设计与暂缓边界记录在[持久 PTY Agent Note](../../.agents/notes/implemented/feature/2026-07-16-persistent-pty-sessions.md) 中。
子系统参考——id、后端/会话约定、发送就绪、有界读取——见 [docs/subsystems/terminal.md](../../docs/subsystems/terminal.md);设计与暂缓边界见[持久 PTY Agent Note](../../.agents/notes/implemented/feature/2026-07-16-persistent-pty-sessions.md)。
@@ -0,0 +1,6 @@
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/terminal/terminal-bash/README.md
README.md: 36e725fd4ce86be09755768a9e21ccb66d025251
README.zh.md: 080f45eb03dfdeece91269a3253aeb3fb280864d
+36
View File
@@ -0,0 +1,36 @@
# @deepseek-ai/dsh-terminal-bash
English | [中文](README.zh.md)
Persistent shell backend for `ctx.terminals` over `ctx.subprocess.spawnTerminal`. It starts an interactive shell under the shared `ctx.sandboxPolicy`, retains bounded line-oriented output, and detects readiness while the subprocess provider owns PTY allocation, environment scrubbing, foreground process groups, signalling, and complete terminal-session cleanup. The same PTY backend therefore composes with local or remote execution-world providers.
## Plugin (`terminal-bash`)
The plugin injects `pty`, `sandboxPolicy`, and `subprocess`, then registers the configured backend type (`shell`). `danger-full-access` starts the shell directly without requiring a sandbox provider; confined modes require a same-world `ctx.sandbox` and wrap the exact shell argv through it, failing before spawn when none is mounted. At spawn, one `ctx.sandboxPolicy.resolve({ session })` call supplies both the effective mode and the session workspace root; the same root is the default shell cwd when the caller omits one. A change to a different effective mode is rejected before its `sandbox/mode` event commits while that owner has an open PTY or a spawn in progress; the fence is attached to the exact owner and therefore outlives a provider reload that retains existing sessions. Wait for creation to settle and close the sessions before changing modes, so a terminal opened with wider access cannot survive a downgrade.
Readiness combines a foreground-verified private bash prompt marker, provider-reported foreground stdin-wait facts, silence fallback, and absolute timeout. A marker is not ready until the printable tail after the latest owned marker exactly equals the controlled `PS1`, including when the OSC marker and prompt are split across data callbacks; echoed input or output following an earlier prompt therefore cannot settle the current send. Prompt and silence evidence collected before the provider write, including while pre-write foreground inspection is pending, is discarded at the write boundary. When bash prints the marker before the terminal provider publishes its return to the foreground process group, polling retains the candidate for `handoffGraceMs` past the ordinary silence bound so a coincident handoff can win. An interactive child that inherits `PROMPT_COMMAND` therefore cannot suppress inferred-idle readiness until the absolute timeout. Unknown foreground state is never a positive exact-idle signal. A foreground group's stdin wait that existed before a send is likewise not post-write readiness: the same group must be observed outside that wait before a later wait can settle the send, while a changed foreground group is new evidence. During unpublished startup, a fallback requires observed output; zero-output silence cannot publish an empty session, and timeout rejects the spawn. Cancellation closes the unpublished shell and rejects with the caller's exact abort reason; `TerminalBackendCleanupError` separately preserves a cleanup failure. The caller's signal is forwarded for terminal allocation and readiness initialization; after publication the handle owns its lifetime. Incomplete terminal-control sequences are bounded by `maxReadBytes` and discarded through their terminator after crossing that limit; malformed UTF-8 terminal output uses replacement characters, and a trailing carriage return is carried across callbacks so split CRLF becomes one newline.
Send cancellation marks queued input as canceled before asking the terminal handle to signal the current foreground process group with a real `SIGINT`; if asynchronous pre-write inspection later settles, it cannot execute that input. If a provider write is already in flight, signalling waits for it to settle; a rejected write sends no signal. The canceled send retains its slot until the write and foreground signalling settle, so a successor cannot receive either late bytes or that signal. A provider write or signal that never settles therefore retains the slot indefinitely; closing the session (`terminal_close`) is the recovery. The absolute deadline remains armed while cancellation waits. A signal failure is a terminal transport failure and rejects the active send. Cancellation never emulates interruption by writing `\x03`, so raw-mode programs remain cancellable. Close rejects new public signals, stops readiness polling, and awaits the handle's provider-owned complete-session termination before settling the active send as `session_exit`.
## Model Experience
### Current file policy and indirect consumer
#### What the model sees
The policy owner contributes capability-neutral `sandbox:policy` context. Through `@deepseek-ai/dsh-tool-terminal` or another PTY consumer, the model may also receive bounded MOTD, send deltas, scrollback pages, readiness reasons, and cleanup errors.
#### Token effect
The current-policy clause is present while this backend is mounted. Retained PTY scrollback is not placed in model history until a consumer returns bounded output.
#### KV Cache effect
A standing-policy change appends an owner-rendered superseding runtime-context snapshot after retained history; consumer results remain append-only.
## Known Limitations and Deferred Work
- Line-oriented output is normalized; full-screen alternate-buffer interaction is unsupported.
- Exact stdin-wait detection depends on the mounted subprocess provider; providers that cannot prove it use prompt-marker and silence/timeout readiness.
- Cleanup guarantees are those of `SubprocessTerminalHandle`; provider-specific gaps belong to that implementation's contract rather than this PTY consumer.
- Sessions do not survive harness process exit.
@@ -0,0 +1,36 @@
# @deepseek-ai/dsh-terminal-bash
[English](README.md) | 中文
这是一个基于 `ctx.subprocess.spawnTerminal`、为 `ctx.terminals` 提供的持久 shell 后端。它在共享 `ctx.sandboxPolicy` 下启动交互式 shell,保留有界的逐行输出并检测就绪状态;进程管理提供方则负责 PTY 分配、环境清理、前台进程组、信号发送和完整终端会话清理。因此,同一个 PTY 后端可以与本地或远程执行世界提供方组合。
## 插件(`terminal-bash`
该插件注入 `pty``sandboxPolicy``subprocess`,然后注册所配置的后端类型(`shell`)。`danger-full-access` 无需沙箱提供方即可直接启动 shell;受限模式要求同一执行世界中存在 `ctx.sandbox`,并通过它包装确切的 shell argv,未挂载时会在 spawn 前失败。spawn 时,一次 `ctx.sandboxPolicy.resolve({ session })` 调用会同时给出实际模式与会话工作区根目录;调用方省略 cwd 时,同一根目录也是 shell 的默认 cwd。当某个所有者存在开放的 PTY 或正在进行 spawn 时,如果配置变更会得到不同的实际模式,系统会在对应 `sandbox/mode` 事件提交前拒绝该变更。该限制绑定到确切所有者,因此即使提供方重新加载并保留现有会话,它仍然有效。更改模式前,请等待创建完成并关闭会话,避免以更宽权限打开的终端在权限降级后继续存在。
就绪检测结合以下机制:由前台状态验证的私有 bash 提示符标记、提供方报告的前台 stdin 等待事实、静默回退和绝对超时。只有最新自有标记之后的可打印尾部与受控 `PS1` 完全相等,标记才算就绪;即使 OSC 标记和提示符被拆到多个数据回调中也一样。因此,较早提示符之后的回显输入或输出无法使当前 send 完成。提供方写入前收集的提示符与静默证据,包括写入前前台检查仍在等待时收集的证据,都会在写入边界丢弃。如果 bash 在终端提供方发布其重新取得前台进程组的状态前打印标记,轮询会在普通静默上限之后再保留该候选状态 `handoffGraceMs`,使恰好同时发生的前台交接有机会胜出。因此,继承 `PROMPT_COMMAND` 的交互式子进程无法一直抑制推断空闲就绪直至绝对超时。未知的前台状态绝不会作为精确空闲的正向信号。同样,一次 send 之前就已存在的前台进程组 stdin 等待并不代表写入后就绪:必须先观察到同一进程组脱离该等待,之后再次进入等待才能使该次 send 完成;前台进程组发生变化则构成新的证据。尚未发布的启动过程中,回退路径要求已经观察到输出;零输出静默不能发布空会话,超时则拒绝 spawn。取消操作会关闭尚未发布的 shell,并以调用方提供的确切中止原因拒绝;`TerminalBackendCleanupError` 会单独保留清理失败。调用方的 signal 会转发给终端分配与就绪初始化;发布后,句柄负责其生命周期。未完成的终端控制序列受 `maxReadBytes` 限制;超过上限后,系统会丢弃内容直到其终止符。格式错误的 UTF-8 终端输出使用替换字符;末尾的回车会跨回调保留,使拆分的 CRLF 合并为一个换行。
取消发送时,系统会先把排队输入标记为已取消,再要求终端句柄向当前前台进程组发送真正的 `SIGINT`;异步写入前检查即使随后结算,也无法执行该输入。如果提供方写入已在途,信号发送会等待其结算;写入被拒绝时不会发送信号。已取消的 send 会保留其位置,直到写入与前台信号发送都结算,因此后继 send 不会收到延迟字节或该信号。因此,永不结算的提供方写入或信号会无限期保留该位置;恢复手段是关闭会话(`terminal_close`)。取消等待期间,绝对 deadline 仍保持启用。信号发送失败是终端传输失败,会拒绝活跃 send。取消绝不会通过写入 `\x03` 模拟中断,因此,即使程序运行在 raw 模式下,也仍可取消。关闭操作会拒绝新的公开信号、停止就绪轮询,并等待由句柄提供方负责的完整会话终止,然后才把活跃 send 结算为 `session_exit`
## 模型体验
### 当前文件策略与间接消费方
#### 模型看到的内容
策略归属方会贡献与具体能力无关的 `sandbox:policy` 上下文。模型通过 `@deepseek-ai/dsh-tool-terminal` 或其他 PTY 消费方还可能收到有界的 MOTD、发送增量、scrollback 页、就绪原因和清理错误。
#### Token 影响
装载该后端期间,当前策略子句会一直存在。消费方返回有界输出前,保留的 PTY scrollback 不会进入模型历史。
#### KV Cache 影响
常驻策略发生变化时,会在保留的历史之后追加一份由归属方渲染、取代先前状态的运行时上下文快照;消费方结果保持仅追加。
## 已知限制与暂缓事项
- 输出按行规范化;不支持全屏备用缓冲区交互。
- 精确 stdin 等待检测取决于已挂载的进程管理提供方;无法证明该状态的提供方使用提示符标记和静默/超时就绪机制。
- 清理保证以 `SubprocessTerminalHandle` 的保证为准;提供方特定的缺口属于该实现的约定,而非这个 PTY 消费方。
- harness 进程退出后,会话无法继续存在。
@@ -0,0 +1,58 @@
{
"name": "@deepseek-ai/dsh-terminal-bash",
"description": "Persistent shell PTY backend over the DeepSeek Harness subprocess terminal primitive",
"version": "0.0.1-rc.2",
"publishConfig": {
"access": "restricted"
},
"repository": {
"type": "git",
"url": "git+https://github.com/deepseek-ai/deepseek-harness.git",
"directory": "packages/terminal/terminal-bash"
},
"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"
],
"license": "BSD-3-Clause",
"peerDependencies": {
"@deepseek-ai/dsh-agent": "workspace:^",
"@deepseek-ai/dsh-invariants": "workspace:^",
"@deepseek-ai/dsh-terminal": "workspace:^",
"@deepseek-ai/dsh-sandbox": "workspace:^",
"@deepseek-ai/dsh-sandbox-policy": "workspace:^",
"@deepseek-ai/dsh-session": "workspace:^",
"@deepseek-ai/dsh-subprocess": "workspace:^",
"@deepseek-ai/cordis": "workspace:^"
},
"dependencies": {
"@deepseek-ai/schemastery": "workspace:^"
},
"devDependencies": {
"@deepseek-ai/dsh-agent": "workspace:^",
"@deepseek-ai/dsh-invariants": "workspace:^",
"@deepseek-ai/dsh-terminal": "workspace:^",
"@deepseek-ai/dsh-sandbox": "workspace:^",
"@deepseek-ai/dsh-sandbox-policy": "workspace:^",
"@deepseek-ai/dsh-session": "workspace:^",
"@deepseek-ai/dsh-subprocess": "workspace:^",
"@deepseek-ai/dsh-subprocess-local": "workspace:^",
"@deepseek-ai/cordis": "workspace:^"
}
}
@@ -0,0 +1,81 @@
/** Validated configuration for the local PTY backend. */
import z from '@deepseek-ai/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
/**
* Extra wait beyond `idleSilenceMs`, once a prompt marker was seen, for the shell to
* regain the foreground before `inferred_idle` settles; at least one `pollIntervalMs`.
*/
handoffGraceMs?: 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),
handoffGraceMs: z.number().default(500),
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('terminal-bash: backendType must be non-empty')
if (resolved.shellPath.length === 0) throw new Error('terminal-bash: 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(`terminal-bash: ${name} must be a positive safe integer`)
}
}
if (resolved.maxReadBytes > resolved.scrollbackMaxBytes) {
throw new Error('terminal-bash: maxReadBytes must not exceed scrollbackMaxBytes')
}
if (resolved.handoffGraceMs < resolved.pollIntervalMs) {
throw new Error('terminal-bash: handoffGraceMs must be at least pollIntervalMs so one readiness poll runs inside the grace window')
}
}
@@ -0,0 +1,153 @@
/**
* Persistent shell PTY backend over the subprocess terminal primitive, shared
* sandbox policy, bounded output, and provider-owned session cleanup.
* @module @deepseek-ai/dsh-terminal-bash
*/
import { Context } from '@deepseek-ai/cordis'
import type { Agent } from '@deepseek-ai/dsh-agent'
import type { Session, SessionEvent } from '@deepseek-ai/dsh-session'
import { TerminalBackendCleanupError } from '@deepseek-ai/dsh-terminal'
import type { TerminalBackend, TerminalBackendSpawnSpec } from '@deepseek-ai/dsh-terminal'
import type { SubprocessTerminalHandle, SubprocessTerminalSpawnSpec } from '@deepseek-ai/dsh-subprocess'
import type { SandboxExecutionPolicy } from '@deepseek-ai/dsh-sandbox'
import { effectiveSandboxMode } from '@deepseek-ai/dsh-sandbox-policy'
import { type Config, type ResolvedConfig, validateConfig } from './config.ts'
import { LocalPtySession } from './session.ts'
import { CONTROLLED_PROMPT } from './sanitize.ts'
export { Config } from './config.ts'
export type { Config as TerminalLocalConfig } from './config.ts'
/** Cordis plugin name. */
export const name = 'terminal-bash'
/** Required services: PTY registry, shared confinement policy, and process substrate. */
export const inject = ['terminals', 'sandboxPolicy', 'subprocess']
interface SandboxModeFenceState {
pty: Context['terminals']
sandboxPolicy: Context['sandboxPolicy']
}
const sandboxModeFences = new WeakMap<Agent, SandboxModeFenceState>()
function ensureSandboxModeFence(ctx: Context, owner: Agent): void {
const existing = sandboxModeFences.get(owner)
if (existing !== undefined) {
existing.pty = ctx.terminals
existing.sandboxPolicy = ctx.sandboxPolicy
return
}
const state: SandboxModeFenceState = { pty: ctx.terminals, sandboxPolicy: ctx.sandboxPolicy }
sandboxModeFences.set(owner, state)
owner.ctx.on('internal/dispatch', (_mode, eventName, args) => {
if (eventName !== 'session/event') return
const [session, event] = args as [Session, SessionEvent]
if (session !== owner.session || event.type !== 'sandbox/mode') return
const currentMode = effectiveSandboxMode(session.events) ?? state.sandboxPolicy.defaultMode
if (event.data.mode === currentMode || !state.pty.hasOwnerActivity(owner)) return
throw new Error(
`cannot change sandbox mode from "${currentMode}" to "${event.data.mode}" while persistent terminal sessions are open or being created; wait for creation to settle and close them first`,
)
}, { global: true })
}
function childEnvironment(spec: TerminalBackendSpawnSpec): Record<string, string> {
// The subprocess provider supplies its own scrubbed ambient base; these are
// deliberate terminal-specific overrides layered after it.
return {
TERM: 'dumb',
PAGER: 'cat',
GIT_PAGER: 'cat',
PS1: CONTROLLED_PROMPT,
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, policy: SandboxExecutionPolicy): string[] {
const argv = [config.shellPath, ...config.shellArgs]
if (policy.mode === 'danger-full-access') return argv
const sandbox = ctx.get('sandbox')
if (sandbox === undefined) {
throw new Error(`terminal-bash: sandbox mode "${policy.mode}" requires a ctx.sandbox provider in the execution world`)
}
// Re-state the discriminant because object spread does not preserve its narrowed type.
return sandbox.confine(argv, { ...policy, mode: policy.mode }).argv
}
// TODO(pty-initialize-race-home): Fold this outer abort race into
// LocalPtySession.initialize when the send-state consolidation lands; the
// session already owns the send lifecycle the race protects.
async function initializeSession(session: LocalPtySession, signal?: AbortSignal): Promise<void> {
if (signal === undefined) {
await session.initialize(signal)
return
}
const aborted = Promise.withResolvers<never>()
const onAbort = (): void => { aborted.reject(signal.reason) }
signal.addEventListener('abort', onAbort, { once: true })
try {
signal.throwIfAborted()
await Promise.race([session.initialize(signal), aborted.promise])
} finally {
signal.removeEventListener('abort', onAbort)
}
}
/** Local shell backend registered under the configured type. */
export class BashTerminalBackend implements TerminalBackend {
readonly type: string
constructor(
private readonly ctx: Context,
private readonly config: ResolvedConfig,
private readonly spawnTerminal: (
spec: SubprocessTerminalSpawnSpec,
) => Promise<SubprocessTerminalHandle> = spec => ctx.subprocess.spawnTerminal(spec),
private readonly createSession: (
terminal: SubprocessTerminalHandle,
config: ResolvedConfig,
) => LocalPtySession = (terminal, config) => new LocalPtySession(terminal, config),
) {
this.type = config.backendType
}
async spawn(spec: TerminalBackendSpawnSpec): Promise<LocalPtySession> {
spec.signal?.throwIfAborted()
ensureSandboxModeFence(this.ctx, spec.owner)
const policy = this.ctx.sandboxPolicy.resolve({ session: spec.owner.session })
const argv = spawnArgv(this.ctx, this.config, policy)
if (argv[0] === undefined) throw new Error('terminal-bash: sandbox returned empty argv')
const terminal = await this.spawnTerminal({
argv,
cwd: spec.cwd ?? policy.workspaceRoot,
env: childEnvironment(spec),
rows: this.config.rows,
cols: this.config.cols,
graceMs: this.config.disposeGraceMs,
signal: spec.signal,
})
const session = this.createSession(terminal, this.config)
try {
await initializeSession(session, spec.signal)
return session
} catch (error) {
try {
await session.close('PTY startup failed')
} catch (closeError: unknown) {
throw new TerminalBackendCleanupError(error, closeError)
}
throw error
}
}
}
/** Register the local PTY backend. */
export function apply(ctx: Context, config: Config): void {
validateConfig(config)
ctx.terminals.registerBackend(new BashTerminalBackend(ctx, config))
}
@@ -0,0 +1,30 @@
/**
* Package-owned invariant companion for `@deepseek-ai/dsh-terminal-bash`.
* @module @deepseek-ai/dsh-terminal-bash/invariant
*/
/* jscpd:ignore-start */
import type { Context } from '@deepseek-ai/cordis'
import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants'
const PACKAGE_NAME = '@deepseek-ai/dsh-terminal-bash'
/** Cordis companion plugin name. */
export const name = 'terminal-bash-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 */
@@ -0,0 +1,188 @@
/** 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;'
/** Exact printable prompt emitted after the private marker. */
export const CONTROLLED_PROMPT = 'dsh> '
/** One sanitized chunk plus whether it contained the owned prompt marker. */
export interface SanitizedChunk {
text: string
prompt: boolean
/** Printable text after the latest owned marker in this chunk. */
promptTail?: string
}
/**
* 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
private trailingCarriageReturn = false
private trackingPromptTail = 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 includePromptTail = this.trackingPromptTail
let promptTail = ''
let index = 0
const appendText = (value: string): void => {
text += value
if (this.trackingPromptTail) promptTail += value
}
while (index < this.pending.length) {
const escape = this.pending.indexOf('\x1b', index)
if (escape < 0) {
appendText(this.pending.slice(index))
index = this.pending.length
break
}
appendText(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
this.trackingPromptTail = true
includePromptTail = true
promptTail = ''
}
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: this.normalizeText(text),
prompt,
...includePromptTail ? { promptTail } : {},
}
}
/**
* 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
this.trackingPromptTail = false
const normalized = this.normalizeText(text)
if (!this.trailingCarriageReturn) return normalized
this.trailingCarriageReturn = false
return `${normalized}\n`
}
private normalizeText(text: string): string {
let complete = this.trailingCarriageReturn ? `\r${text}` : text
this.trailingCarriageReturn = false
if (complete.endsWith('\r')) {
complete = complete.slice(0, -1)
this.trailingCarriageReturn = true
}
return normalizeTerminalText(complete)
}
private enforcePendingBound(): void {
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', '')
}
@@ -0,0 +1,565 @@
/** Persistent PTY session over the subprocess seam's terminal primitive. */
import { Buffer } from 'node:buffer'
import type {
SubprocessOutcome,
SubprocessTerminalForeground,
SubprocessTerminalHandle,
} from '@deepseek-ai/dsh-subprocess'
import { TerminalError } from '@deepseek-ai/dsh-terminal'
import type {
TerminalBackendSession,
TerminalReadRequest,
TerminalReadResult,
TerminalSendOperation,
TerminalSendRead,
TerminalSendRequest,
TerminalSendResult,
TerminalSessionStatus,
TerminalSignal,
TerminalSignalResult,
TerminalWaitReason,
} from '@deepseek-ai/dsh-terminal'
import type { ResolvedConfig } from './config.ts'
import { CONTROLLED_PROMPT, TerminalSanitizer } from './sanitize.ts'
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(): TerminalSendRead {
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 TerminalSendOperation {
private readonly output: BoundedTextBuffer
private readonly promise: PromiseWithResolvers<TerminalSendResult>
private finished = false
private cancellationRequested = false
private initialForegroundLeftWait: boolean
private initialForegroundPgid: number | undefined
constructor(
maxBytes: number,
readonly startedAt: number,
private readonly onCancel: () => void,
) {
this.output = new BoundedTextBuffer(maxBytes)
this.promise = Promise.withResolvers<TerminalSendResult>()
this.initialForegroundLeftWait = true
}
get done(): Promise<TerminalSendResult> {
return this.promise.promise
}
get settled(): boolean {
return this.finished
}
get cancelRequested(): boolean {
return this.cancellationRequested
}
append(text: string): void {
if (!this.finished) this.output.append(text)
}
settle(waitReason: TerminalWaitReason, sessionStatus: TerminalSessionStatus, 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(): TerminalSendRead {
return this.output.consume()
}
setInitialForeground(foreground: SubprocessTerminalForeground | undefined): void {
this.initialForegroundPgid = foreground?.processGroupId
this.initialForegroundLeftWait = foreground?.inputWaiting !== true
}
acceptsStdinWait(pgid: number, waiting: boolean): boolean {
// The same group may still expose the wait that existed before terminal.write.
// Observe every poll so a departure before the exact-settlement threshold
// still makes a later return to that wait post-write evidence.
if (pgid !== this.initialForegroundPgid) return waiting
if (!waiting) this.initialForegroundLeftWait = true
return waiting && this.initialForegroundLeftWait
}
cancel(): boolean {
if (this.finished) return false
this.cancellationRequested = true
this.onCancel()
return true
}
}
/** Backend session wrapping one provider-owned terminal process. */
export class LocalPtySession implements TerminalBackendSession {
motd = ''
readonly pid: number
private readonly decoder = new TextDecoder()
private readonly sanitizer: TerminalSanitizer
private readonly scrollback: BoundedTextBuffer
private readonly outputEnded = Promise.withResolvers<void>()
private readonly completion: Promise<void>
private statusValue: TerminalSessionStatus = { kind: 'running' }
// TODO(pty-send-state-consolidation): Fold the per-send fields below
// (active/activeTimer/activeDeadlineTimer/activeAbort/interrupting/
// activeWrite/pollingReady/polling) into one send-lifecycle owner; the
// cancellation/readiness interplay now has enough pinned tests to carry
// that refactor safely.
private active: LocalSendOperation | undefined
private activeTimer: NodeJS.Timeout | undefined
private activeDeadlineTimer: NodeJS.Timeout | undefined
private activeAbort: (() => void) | undefined
private interrupting: LocalSendOperation | undefined
private activeWrite: Promise<boolean> | undefined
private pollingReady: LocalSendOperation | undefined
private polling = false
private promptSeen = false
private promptTextSeen = false
private promptTail = ''
private shellPgid: number | undefined
private initializing = false
private lastOutputAt = Date.now()
private closing = false
private closePromise: Promise<void> | undefined
private transportFailure: Error | undefined
constructor(
private readonly terminal: SubprocessTerminalHandle,
private readonly config: ResolvedConfig,
) {
this.pid = terminal.pid
this.sanitizer = new TerminalSanitizer(config.maxReadBytes)
this.scrollback = new BoundedTextBuffer(config.scrollbackMaxBytes, config.scrollbackLines)
terminal.output.on('data', this.onTerminalData)
terminal.output.once('end', this.onTerminalEnd)
terminal.output.once('error', this.onTerminalError)
this.completion = terminal.done.then(
outcome => this.onExit(outcome),
(error: unknown) => { this.onTransportFailure(error) },
)
}
/**
* 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
} catch (error: unknown) {
signal?.throwIfAborted()
throw error
} finally {
this.initializing = false
}
}
startSend(request: TerminalSendRequest): TerminalSendOperation {
if (this.closing) throw new Error('PTY session is closing')
if (this.statusValue.kind === 'exited') throw new Error('PTY session has exited')
if (this.active !== undefined) {
const draining = this.activeWrite !== undefined
? ' or draining provider write'
: this.interrupting !== undefined
? ' or draining foreground interrupt'
: ''
throw new TerminalError(`PTY session already has an active send${draining}`, 'SEND_ACTIVE')
}
if (request.signal?.aborted === true) throw new Error('PTY send aborted before write')
const operation = new LocalSendOperation(
this.config.maxReadBytes,
Date.now(),
() => { this.interrupt(operation) },
)
this.active = operation
this.resetReadinessEvidence()
if (request.signal !== undefined) {
const onAbort = (): void => { operation.cancel() }
request.signal.addEventListener('abort', onAbort, { once: true })
this.activeAbort = () => request.signal?.removeEventListener('abort', onAbort)
}
this.activeDeadlineTimer = setTimeout(() => {
if (this.active === operation) {
this.settleActive('timeout', this.activeWrite !== undefined || this.interrupting === operation)
}
}, this.config.timeoutMs)
void this.beginSend(operation, request)
return operation
}
private async beginSend(operation: LocalSendOperation, request: TerminalSendRequest): Promise<void> {
let foreground: SubprocessTerminalForeground | undefined
try {
foreground = await this.terminal.inspectForeground()
} catch (error: unknown) {
// A pre-write inspection failure while cancellation owns the slot must not
// release it: interruptOnce's in-flight foreground signal could land on a
// successor's foreground group. The interrupt path's post-signal tail
// resumes polling, whose guarded catch propagates a persistent failure.
// A retained settled operation implies that same in-flight interrupt, so
// this guard admits only an unsettled active send.
if (this.active === operation && !this.closing && this.interrupting !== operation) {
this.failActive(error)
}
return
}
try {
if (this.active !== operation || this.closing || this.interrupting === operation) return
operation.setInitialForeground(foreground)
const input = `${request.text}${request.submit ? '\r' : ''}`
if (input.length > 0 && !operation.cancelRequested) {
this.resetReadinessEvidence()
const write = this.terminal.write(input)
this.activeWrite = write.then(() => true, () => false)
try {
await write
} finally {
this.activeWrite = undefined
}
}
// Cancellation owns post-write signalling and reservation release.
if (operation.cancelRequested) return
if (this.active === operation && operation.settled) {
this.clearActive()
return
}
// Closing can race the awaited provider write even though static analysis sees only local assignments.
// oxlint-disable-next-line typescript/no-unnecessary-condition -- awaited provider writes can close the session.
if (this.active === operation && !this.closing) {
this.pollingReady = operation
this.schedulePoll(operation)
}
} catch (error: unknown) {
if (this.active === operation && !this.closing) {
if (operation.settled) this.clearActive()
else this.failActive(error)
}
}
}
private resetReadinessEvidence(): void {
this.lastOutputAt = Date.now()
this.promptSeen = false
this.promptTextSeen = false
this.promptTail = ''
}
read(request: TerminalReadRequest): TerminalReadResult {
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,
}
}
async signal(signal: TerminalSignal): Promise<TerminalSignalResult> {
if (this.closing) throw new Error('PTY session is closing')
const targetPgid = await this.terminal.signalForeground(signal)
return { delivered: true, targetPgid }
}
status(): TerminalSessionStatus {
return this.statusValue
}
close(reason: string): Promise<void> {
this.closing = true
if (this.closePromise !== undefined) return this.closePromise
const closing = this.closeOnce(reason).catch((error: unknown) => {
this.closePromise = undefined
this.failActive(error)
throw error
})
this.closePromise = closing
return closing
}
private readonly onTerminalData = (chunk: Buffer | Uint8Array | string): void => {
const bytes = typeof chunk === 'string' ? Buffer.from(chunk, 'utf8') : chunk
this.onData(this.decoder.decode(bytes, { stream: true }))
}
private readonly onTerminalEnd = (): void => {
this.onData(this.decoder.decode())
this.appendOutput(this.sanitizer.flush())
this.outputEnded.resolve()
}
private readonly onTerminalError = (error: Error): void => {
this.onTransportFailure(error)
this.outputEnded.resolve()
}
private onData(data: string): void {
const sanitized = this.sanitizer.push(data)
this.appendOutput(sanitized.text)
if (sanitized.prompt) {
// TODO(pty-delayed-signal-prompt): With a reproducer, define a marker-generation boundary
// before attributing a signal-delayed prompt to a later send.
// Bash can print PROMPT_COMMAND before the kernel publishes its return
// to the foreground process group. Retain the marker; polling below is
// the authority that accepts it only after bash owns the foreground.
this.promptSeen = true
this.promptTail = ''
this.lastOutputAt = Date.now()
}
if (this.promptSeen && sanitized.promptTail !== undefined) {
const remaining = Math.max(0, CONTROLLED_PROMPT.length + 1 - this.promptTail.length)
this.promptTail += sanitized.promptTail.slice(0, remaining)
if (sanitized.promptTail.length > remaining) this.promptTail = `${CONTROLLED_PROMPT}\0`
this.promptTextSeen = this.promptTail === CONTROLLED_PROMPT
}
}
private async onExit(outcome: SubprocessOutcome): Promise<void> {
await this.outputEnded.promise
if (this.transportFailure !== undefined) return
this.statusValue = { kind: 'exited', exitCode: outcome.exitCode, signal: outcome.signal }
this.settleActive('session_exit')
}
private onTransportFailure(error: unknown): void {
const failure = error instanceof Error ? error : new Error(String(error))
this.transportFailure ??= failure
this.statusValue = { kind: 'exited', exitCode: null, signal: null }
this.failActive(failure)
void this.terminal.terminate().catch(() => {})
}
private appendOutput(text: string): void {
if (text.length === 0) return
this.lastOutputAt = Date.now()
this.scrollback.append(text)
this.active?.append(text)
}
private schedulePoll(operation: LocalSendOperation, delayMs = this.config.pollIntervalMs): void {
if (this.active !== operation || this.interrupting === operation || this.polling) return
if (this.activeTimer !== undefined) clearTimeout(this.activeTimer)
this.activeTimer = setTimeout(() => {
this.activeTimer = undefined
void this.pollReadiness(operation)
}, delayMs)
}
private async pollReadiness(operation: LocalSendOperation): Promise<void> {
if (this.active !== operation || this.polling) return
this.polling = true
try {
if (this.statusValue.kind === 'exited') {
this.settleActive('session_exit')
return
}
const foreground = await this.terminal.inspectForeground()
if (this.active !== operation || this.closing || this.interrupting === operation) return
const idleFor = Date.now() - this.lastOutputAt
if (this.promptSeen && foreground !== undefined && this.shellPgid === undefined) {
this.shellPgid = foreground.processGroupId
}
if (this.promptSeen && this.promptTextSeen && idleFor >= this.config.pollIntervalMs
&& foreground?.processGroupId === this.shellPgid) {
this.settleActive('stdin_read')
return
}
const elapsed = Date.now() - operation.startedAt
const startupHasOutput = !this.initializing || this.scrollback.snapshot().text.length > 0
const acceptsStdinWait = startupHasOutput && foreground !== undefined
&& operation.acceptsStdinWait(foreground.processGroupId, foreground.inputWaiting)
if (elapsed >= this.config.exactProbeAfterMs && acceptsStdinWait) {
this.settleActive('stdin_read')
return
}
// A prompt candidate can race bash's foreground handoff, but an interactive
// child also inherits PROMPT_COMMAND. Silence therefore remains the bound
// on waiting for shell ownership instead of letting a child marker suppress
// readiness until the absolute timeout.
const handoffGrace = this.promptSeen ? this.config.handoffGraceMs : 0
if (startupHasOutput && idleFor >= this.config.idleSilenceMs + handoffGrace) {
this.settleActive('inferred_idle')
}
} catch (error: unknown) {
if (this.active === operation && !this.closing && this.interrupting !== operation) this.failActive(error)
} finally {
this.polling = false
const active = this.active
// Awaited provider inspection can clear or replace the active send despite static analysis.
// oxlint-disable-next-line typescript/no-unnecessary-condition -- awaited inspection can replace the active send.
if (active !== undefined && this.pollingReady === active) this.schedulePoll(active)
}
}
private settleActive(waitReason: TerminalWaitReason, retainOwnership = false): void {
const operation = this.active
if (operation === undefined) return
const scrollbackTruncated = this.scrollback.snapshot().truncated
if (retainOwnership) {
this.stopPolling()
this.activeAbort?.()
this.activeAbort = undefined
} else {
this.clearActive()
}
operation.settle(waitReason, this.statusValue, scrollbackTruncated)
}
private stopPolling(): void {
this.stopReadinessPolling()
if (this.activeDeadlineTimer !== undefined) clearTimeout(this.activeDeadlineTimer)
this.activeDeadlineTimer = undefined
}
private stopReadinessPolling(): void {
if (this.activeTimer !== undefined) clearTimeout(this.activeTimer)
this.activeTimer = undefined
this.pollingReady = undefined
}
private clearActive(): void {
const operation = this.active
this.stopPolling()
this.activeAbort?.()
this.activeAbort = undefined
if (this.interrupting === operation) this.interrupting = undefined
this.pollingReady = undefined
this.active = undefined
}
private failActive(error: unknown): void {
const operation = this.active
if (operation === undefined) return
this.clearActive()
operation.fail(error)
}
private interrupt(operation: LocalSendOperation): void {
if (this.active !== operation) return
this.interrupting = operation
this.stopReadinessPolling()
void this.interruptOnce(operation)
}
private async interruptOnce(operation: LocalSendOperation): Promise<void> {
try {
const activeWrite = this.activeWrite
if (activeWrite !== undefined && !await activeWrite) return
await this.terminal.signalForeground('SIGINT')
} catch (error: unknown) {
if (this.active === operation && !this.closing) this.onTransportFailure(error)
return
} finally {
if (this.interrupting === operation) this.interrupting = undefined
}
if (this.active === operation && operation.settled) {
this.clearActive()
} else if (this.active === operation && !this.closing) {
this.pollingReady = operation
this.schedulePoll(operation, 0)
}
}
private async closeOnce(reason: string): Promise<void> {
// 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()
try {
await this.terminal.terminate()
} catch (error: unknown) {
throw new Error(`PTY cleanup failed (${reason})`, { cause: error })
}
// Quiescence is the active send's terminal outcome.
this.settleActive('session_exit')
await this.completion
this.terminal.output.off('data', this.onTerminalData)
this.terminal.output.off('end', this.onTerminalEnd)
this.terminal.output.off('error', this.onTerminalError)
if (this.transportFailure !== undefined) throw this.transportFailure
}
}
@@ -0,0 +1,32 @@
import { describe, expect, it } from 'vitest'
import type { Config } from '@deepseek-ai/dsh-terminal-bash/src/config.ts'
import { validateConfig } from '@deepseek-ai/dsh-terminal-bash/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, handoffGraceMs: 50, timeoutMs: 1000,
disposeGraceMs: 100,
...overrides,
}
}
describe('terminal-bash 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')
})
it('rejects a handoff grace shorter than one readiness poll', () => {
expect(() => { validateConfig(config({ handoffGraceMs: 9, pollIntervalMs: 10 })) }).toThrow('handoffGraceMs must be at least pollIntervalMs')
expect(() => { validateConfig(config({ handoffGraceMs: 10, pollIntervalMs: 10 })) }).not.toThrow()
})
})
@@ -0,0 +1,462 @@
import { describe, expect, it, vi } from 'vitest'
import { PassThrough } from 'node:stream'
import { Context } from '@deepseek-ai/cordis'
import Loader from '@deepseek-ai/cordis-plugin-loader'
import SessionStore, { Session, SessionId } from '@deepseek-ai/dsh-session'
import AgentRegistry, { Inbox, 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, { setSandboxMode } from '@deepseek-ai/dsh-sandbox-policy'
import TerminalSessionService, { TerminalBackendCleanupError, TerminalSessionId } from '@deepseek-ai/dsh-terminal'
import { BashTerminalBackend } from '@deepseek-ai/dsh-terminal-bash'
import * as ptyLocal from '@deepseek-ai/dsh-terminal-bash'
import type { ResolvedConfig } from '@deepseek-ai/dsh-terminal-bash/src/config.ts'
import type { LocalPtySession } from '@deepseek-ai/dsh-terminal-bash/src/session.ts'
import { SubprocessRuntime } from '@deepseek-ai/dsh-subprocess'
import type {
SubprocessHandle,
SubprocessSpawnSpec,
SubprocessTerminalHandle,
SubprocessTerminalSpawnSpec,
} from '@deepseek-ai/dsh-subprocess'
class EmptySandbox extends SandboxProvider {
confine(_argv: readonly string[], _policy: SandboxPolicy): ConfinedArgv {
return { argv: [], enforcement: 'full', denialSignatures: [], runnerFailureRules: [] }
}
}
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: [], runnerFailureRules: [] }
}
}
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, handoffGraceMs: 10, timeoutMs: 100,
disposeGraceMs: 10,
}
}
function agent(ctx: Context, cwd?: string): Agent {
const id = SessionId('agent')
const session = Session.create(id, undefined, { version: 0, id, createdAt: 0, ...cwd === undefined ? {} : { cwd } })
return {
id, options: {}, session, inbox: new Inbox(session, { inserted: () => {}, discarded: () => {}, claimed: () => {} }),
status: 'idle',
ctx,
send: () => {},
followup: () => {}, steer: () => {}, inject: () => {}, cancel() {},
runMaintenance: task => task(new AbortController().signal),
whenIdle: () => Promise.resolve(),
}
}
function terminalHandle(): SubprocessTerminalHandle {
const output = new PassThrough()
return {
pid: 123,
output,
done: Promise.resolve({ exitCode: 0, signal: null }),
write: async () => {},
inspectForeground: async () => ({ processGroupId: 123, inputWaiting: true }),
signalForeground: async () => 123,
terminate: async () => { output.end() },
}
}
class StubSubprocessRuntime extends SubprocessRuntime {
async resolveExecutable(command: string): Promise<string> { return command }
spawn(_spec: SubprocessSpawnSpec): SubprocessHandle { throw new Error('unused') }
async spawnTerminal(_spec: SubprocessTerminalSpawnSpec): Promise<SubprocessTerminalHandle> {
return terminalHandle()
}
}
function spec(owner: Agent, signal?: AbortSignal) {
return {
sessionId: TerminalSessionId('pty-1'), owner, type: 'shell',
...signal !== undefined ? { signal } : {},
}
}
function stubLocalSession(initialize: () => Promise<void> = () => Promise.resolve()): LocalPtySession {
return {
motd: '',
initialize,
startSend: () => { throw new Error('unused') },
read: () => { throw new Error('unused') },
signal: () => Promise.resolve({ delivered: true, targetPgid: 1 }),
status: () => ({ kind: 'running' as const }),
close: () => Promise.resolve(),
} as unknown as LocalPtySession
}
function registerStubLocalBackend(ctx: Context, createSession: () => LocalPtySession) {
return ctx.inject(['terminals', 'sandbox', 'sandboxPolicy', 'subprocess'], (providerCtx) => {
providerCtx.terminals.registerBackend(new BashTerminalBackend(
providerCtx,
{ ...config(), backendType: 'stub' },
async () => terminalHandle(),
createSession,
))
})
}
describe('BashTerminalBackend 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 BashTerminalBackend(ctx, config(), async () => terminalHandle())
const controller = new AbortController()
const abortReason = new Error('spawn aborted')
controller.abort(abortReason)
await expect(backend.spawn(spec(agent(ctx), controller.signal))).rejects.toBe(abortReason)
await expect(backend.spawn(spec(agent(ctx)))).rejects.toThrow('empty argv')
})
it('closes failed startup and aggregates cleanup failure', async () => {
const ctx = new Context()
await ctx.plugin(SandboxPolicyService, { mode: 'danger-full-access', workspaceRoot: '/tmp' })
const spawnTerminal = async (): Promise<SubprocessTerminalHandle> => terminalHandle()
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 BashTerminalBackend(ctx, config(), spawnTerminal, () => failed)
await expect(backend.spawn(spec(agent(ctx)))).rejects.toThrow('startup failed')
expect(closed).toHaveBeenCalledWith('PTY startup failed')
const startupFailure = new Error('startup failed')
const cleanupFailure = new Error('cleanup failed')
const doublyFailed = {
initialize: () => Promise.reject(startupFailure),
close: () => Promise.reject(cleanupFailure),
} as unknown as LocalPtySession
const aggregate = new BashTerminalBackend(ctx, config(), spawnTerminal, () => doublyFailed)
await expect(aggregate.spawn(spec(agent(ctx)))).rejects.toEqual(expect.objectContaining({
name: 'TerminalBackendCleanupError',
spawnError: startupFailure,
cleanupError: cleanupFailure,
} satisfies Partial<TerminalBackendCleanupError>))
})
it('starts startup rollback when cancellation wins a stalled initialization', async () => {
const ctx = new Context()
await ctx.plugin(SandboxPolicyService, { mode: 'danger-full-access', workspaceRoot: '/tmp' })
const initialization = Promise.withResolvers<undefined>()
const initializationStarted = Promise.withResolvers<undefined>()
const close = vi.fn<() => Promise<void>>().mockResolvedValue(undefined)
const session = {
initialize: () => {
initializationStarted.resolve(undefined)
return initialization.promise
},
close,
} as unknown as LocalPtySession
const backend = new BashTerminalBackend(ctx, config(), async () => terminalHandle(), () => session)
const controller = new AbortController()
const reason = new Error('cancel stalled startup')
const spawning = backend.spawn(spec(agent(ctx), controller.signal))
await initializationStarted.promise
controller.abort(reason)
await expect(spawning).rejects.toBe(reason)
expect(close).toHaveBeenCalledWith('PTY startup failed')
initialization.resolve(undefined)
})
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 = terminalHandle()
let spawned: SubprocessTerminalSpawnSpec | undefined
const spawnTerminal = async (spec: SubprocessTerminalSpawnSpec): Promise<SubprocessTerminalHandle> => {
spawned = spec
return terminal
}
const initialized = vi.fn<() => Promise<void>>().mockResolvedValue(undefined)
const session = { initialize: initialized } as unknown as LocalPtySession
const backend = new BashTerminalBackend(
ctx,
{ ...config(), shellArgs: ['-i'] },
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({
argv: ['/sandbox', '--', '/bin/bash', '-i'],
cols: 80,
rows: 24,
cwd: '/work',
graceMs: 10,
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?.env?.PTY_TEST_SECRET).toBeUndefined()
expect(initialized).toHaveBeenCalledWith(undefined)
expect((ctx.sandbox as RecordingSandbox).calls).toEqual([{
argv: ['/bin/bash', '-i'],
policy: { mode: 'workspace-write', sessionId: 'agent', workspaceRoot: '/workspace' },
}])
})
it('resolves session mode and root together before wrapping the shell', async () => {
const ctx = new Context()
await ctx.plugin(RecordingSandbox)
await ctx.plugin(SandboxPolicyService, { mode: 'read-only', workspaceRoot: '/deployment-fallback' })
const terminal = terminalHandle()
let spawned: SubprocessTerminalSpawnSpec | undefined
const spawnTerminal = async (spec: SubprocessTerminalSpawnSpec): Promise<SubprocessTerminalHandle> => {
spawned = spec
return terminal
}
const initialized = vi.fn<() => Promise<void>>().mockResolvedValue(undefined)
const session = { initialize: initialized } as unknown as LocalPtySession
const backend = new BashTerminalBackend(
ctx,
{ ...config(), shellArgs: ['-i'] },
spawnTerminal,
() => session,
)
const owner = agent(ctx, '/session-workspace')
setSandboxMode(owner.session, 'workspace-write')
expect(await backend.spawn(spec(owner))).toBe(session)
expect(spawned).toMatchObject({
argv: ['/sandbox', '--', '/bin/bash', '-i'],
cwd: '/session-workspace',
})
expect((ctx.sandbox as RecordingSandbox).calls).toEqual([{
argv: ['/bin/bash', '-i'],
policy: { mode: 'workspace-write', sessionId: 'agent', workspaceRoot: '/session-workspace' },
}])
})
it('rejects a confined spawn without a sandbox provider', async () => {
const confinedCtx = new Context()
await confinedCtx.plugin(SandboxPolicyService, { mode: 'workspace-write', workspaceRoot: '/workspace' })
const confined = new BashTerminalBackend(
confinedCtx,
config(),
async () => { throw new Error('terminal spawn must not run') },
() => stubLocalSession(),
)
await expect(confined.spawn(spec(agent(confinedCtx)))).rejects.toThrow(
'sandbox mode "workspace-write" requires a ctx.sandbox provider in the execution world',
)
})
it('forwards terminal allocation cancellation directly', async () => {
const ctx = new Context()
await ctx.plugin(EmptySandbox)
await ctx.plugin(SandboxPolicyService, { mode: 'danger-full-access', workspaceRoot: '/tmp' })
const publishedController = new AbortController()
let publishedSignal: AbortSignal | undefined
const published = new BashTerminalBackend(
ctx,
config(),
async (spawnSpec) => {
publishedSignal = spawnSpec.signal
return terminalHandle()
},
() => stubLocalSession(),
)
await published.spawn(spec(agent(ctx), publishedController.signal))
expect(publishedSignal).toBe(publishedController.signal)
publishedController.abort(new Error('originating turn ended'))
expect(publishedSignal?.aborted).toBe(true)
const pendingController = new AbortController()
const seen = Promise.withResolvers<AbortSignal>()
const pending = new BashTerminalBackend(
ctx,
config(),
async spawnSpec => await new Promise<SubprocessTerminalHandle>((_resolve, reject) => {
const setupSignal = spawnSpec.signal as AbortSignal
seen.resolve(setupSignal)
const onAbort = (): void => {
reject(setupSignal.reason instanceof Error ? setupSignal.reason : new Error(String(setupSignal.reason)))
}
setupSignal.addEventListener('abort', onAbort, { once: true })
}),
() => stubLocalSession(),
)
const spawning = pending.spawn(spec(agent(ctx), pendingController.signal))
const pendingSignal = await seen.promise
const reason = new Error('cancel pending allocation')
pendingController.abort(reason)
await expect(spawning).rejects.toBe(reason)
expect(pendingSignal.aborted).toBe(true)
})
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' })
const output = new PassThrough()
const outcome = Promise.withResolvers<{ exitCode: number | null; signal: NodeJS.Signals | null }>()
const terminal: SubprocessTerminalHandle = {
pid: 123,
output,
done: outcome.promise,
write: async () => {},
inspectForeground: async () => ({ processGroupId: 123, inputWaiting: true }),
signalForeground: async () => 123,
async terminate() {
output.end()
outcome.resolve({ exitCode: null, signal: 'SIGTERM' })
},
}
queueMicrotask(() => { output.write(Buffer.from('\x1b]133;D;0\x07dsh> ')) })
const backend = new BashTerminalBackend(
ctx,
config(),
async () => terminal,
)
const session = await backend.spawn(spec(agent(ctx)))
expect(session.motd).toBe('dsh> ')
await session.close('test complete')
})
})
describe('terminal-bash 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('terminal-bash')
expect(unwrapped.inject).toEqual(['terminals', 'sandboxPolicy', 'subprocess'])
expect(unwrapped.Config).toBeDefined()
})
it('validates config and registers the configured backend', async () => {
const ctx = new Context()
await ctx.plugin(AgentRegistry)
await ctx.plugin(TerminalSessionService)
await ctx.plugin(SandboxPolicyService, { mode: 'danger-full-access', workspaceRoot: '/tmp' })
await ctx.plugin(StubSubprocessRuntime)
const fiber = await ctx.plugin(ptyLocal, config())
expect(ctx.terminals.listBackends()).toEqual(['shell'])
await fiber.dispose()
expect(ctx.terminals.listBackends()).toEqual([])
})
it('ignores unrelated session events and mode changes without a live owner', async () => {
const ctx = new Context()
await ctx.plugin(SessionStore)
await ctx.plugin(AgentRegistry)
await ctx.plugin(TerminalSessionService)
await ctx.plugin(EmptySandbox)
await ctx.plugin(SandboxPolicyService, { mode: 'danger-full-access', workspaceRoot: '/tmp' })
await ctx.plugin(StubSubprocessRuntime)
await ctx.plugin(ptyLocal, config())
const session = ctx.sessions.create(SessionId('unowned-mode'))
expect(() => {
session.append('turn/start', { turn: 1 })
}).not.toThrow()
expect(() => { setSandboxMode(session, 'read-only') }).not.toThrow()
})
it('keeps the owner-lifetime sandbox fence after the local provider unloads', async () => {
const ctx = new Context()
await ctx.plugin(SessionStore)
await ctx.plugin(AgentRegistry)
await ctx.plugin(TerminalSessionService)
await ctx.plugin(RecordingSandbox)
await ctx.plugin(SandboxPolicyService, { mode: 'danger-full-access', workspaceRoot: '/tmp' })
await ctx.plugin(StubSubprocessRuntime)
const session = ctx.sessions.create(SessionId('mode-owner'))
const ownerFiber = await ctx.plugin(() => {})
const owner: Agent = {
id: session.id, options: {}, session, inbox: new Inbox(session, { inserted: () => {}, discarded: () => {}, claimed: () => {} }),
status: 'idle',
ctx: ownerFiber.ctx,
send: () => {},
followup: () => {}, steer: () => {}, inject: () => {}, cancel() {},
runMaintenance: task => task(new AbortController().signal),
whenIdle: () => Promise.resolve(),
}
ctx.agents.register(owner)
const providerFiber = await registerStubLocalBackend(ctx, () => stubLocalSession())
const created = await ctx.terminals.spawn(owner, { type: 'stub' })
const unrelated = ctx.sessions.create(SessionId('unrelated-mode'))
expect(() => { setSandboxMode(unrelated, 'read-only') }).not.toThrow()
expect(() => {
session.append('turn/start', { turn: 1 })
}).not.toThrow()
expect(() => { setSandboxMode(session, 'danger-full-access') }).not.toThrow()
await providerFiber.dispose()
expect(ctx.terminals.listBackends()).toEqual([])
expect(() => { setSandboxMode(session, 'read-only') }).toThrow(
'cannot change sandbox mode from "danger-full-access" to "read-only" while persistent terminal sessions are open or being created; wait for creation to settle and close them first',
)
expect(session.events.filter(event => event.type === 'sandbox/mode')).toHaveLength(1)
const replacementFiber = await registerStubLocalBackend(ctx, () => stubLocalSession())
const second = await ctx.terminals.spawn(owner, { type: 'stub' })
await replacementFiber.dispose()
expect(() => { setSandboxMode(session, 'read-only') }).toThrow('open or being created')
await ctx.terminals.kill(owner, created.sessionId)
await ctx.terminals.kill(owner, second.sessionId)
expect(() => { setSandboxMode(session, 'read-only') }).not.toThrow()
expect(session.events.filter(event => event.type === 'sandbox/mode')).toHaveLength(2)
})
it('also fences sandbox-mode changes across unpublished PTY creation', async () => {
const ctx = new Context()
await ctx.plugin(SessionStore)
await ctx.plugin(AgentRegistry)
await ctx.plugin(TerminalSessionService)
await ctx.plugin(RecordingSandbox)
await ctx.plugin(SandboxPolicyService, { mode: 'danger-full-access', workspaceRoot: '/tmp' })
await ctx.plugin(StubSubprocessRuntime)
const session = ctx.sessions.create(SessionId('pending-mode-owner'))
const ownerFiber = await ctx.plugin(() => {})
const owner: Agent = {
id: session.id, options: {}, session, inbox: new Inbox(session, { inserted: () => {}, discarded: () => {}, claimed: () => {} }),
status: 'idle',
ctx: ownerFiber.ctx,
send: () => {},
followup: () => {}, steer: () => {}, inject: () => {}, cancel() {},
runMaintenance: task => task(new AbortController().signal),
whenIdle: () => Promise.resolve(),
}
ctx.agents.register(owner)
const gate = Promise.withResolvers<undefined>()
await registerStubLocalBackend(ctx, () => stubLocalSession(() => gate.promise))
const spawning = ctx.terminals.spawn(owner, { type: 'stub' })
expect(ctx.terminals.hasOwnerActivity(owner)).toBe(true)
expect(() => { setSandboxMode(session, 'read-only') }).toThrow('open or being created')
gate.resolve(undefined)
const created = await spawning
await ctx.terminals.kill(owner, created.sessionId)
expect(ctx.terminals.hasOwnerActivity(owner)).toBe(false)
})
})
@@ -0,0 +1,249 @@
import { existsSync, mkdtempSync, readFileSync, 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 '@deepseek-ai/cordis'
import { Session, SessionId } from '@deepseek-ai/dsh-session'
import AgentRegistry, { Inbox } from '@deepseek-ai/dsh-agent'
import type { Agent } from '@deepseek-ai/dsh-agent'
import TerminalSessionService from '@deepseek-ai/dsh-terminal'
import type { TerminalSendOperation } from '@deepseek-ai/dsh-terminal'
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 LocalSubprocessRuntime from '@deepseek-ai/dsh-subprocess-local'
import * as ptyLocal from '@deepseek-ai/dsh-terminal-bash'
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: [], runnerFailureRules: [] }
}
}
function stubAgent(ctx: Context, rawId: string): Agent {
const id = SessionId(rawId)
const scope = ctx.plugin(() => {})
const session = Session.create(id)
return {
id, options: {}, session, inbox: new Inbox(session, { inserted: () => {}, discarded: () => {}, claimed: () => {} }),
status: 'idle',
ctx: scope.ctx,
send: () => {},
followup: () => {}, steer: () => {}, inject: () => {}, cancel() {},
runMaintenance: task => task(new AbortController().signal),
whenIdle: () => Promise.resolve(),
}
}
async function harness(
mode: 'danger-full-access' | 'workspace-write',
timing: { idleSilenceMs?: number; handoffGraceMs?: number; timeoutMs?: number } = {},
) {
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(TerminalSessionService)
await ctx.plugin(PassthroughSandbox)
await ctx.plugin(SandboxPolicyService, { mode, workspaceRoot: root })
await ctx.plugin(LocalSubprocessRuntime)
const fiber = await ctx.plugin(ptyLocal, {
pollIntervalMs: 10,
exactProbeAfterMs: 20,
idleSilenceMs: timing.idleSilenceMs ?? 250,
handoffGraceMs: timing.handoffGraceMs ?? 250,
timeoutMs: timing.timeoutMs ?? 2_000,
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 }
}
// TerminalSendOperation.append drops output once the operation settles, so this only
// observes a marker the child prints while `operation` is still active. A caller
// whose child is slow to print must raise the harness `timing` bounds too;
// extending this deadline alone cannot recover output the operation never collected.
async function waitForOutput(operation: TerminalSendOperation, expected: string, timeoutMs = 2_000): Promise<void> {
const deadline = Date.now() + timeoutMs
let output = ''
while (!output.includes(expected) && Date.now() < deadline) {
output += operation.readOutput().delta
if (!output.includes(expected)) await new Promise(resolve => setTimeout(resolve, 10))
}
expect(output).toContain(expected)
}
// A send the test interrupts settles when bash returns to its prompt, so the
// kernel may publish the foreground handoff on either side of the silence
// bound. `handoffGraceMs` widens the window that wins the exact attribution but
// cannot remove the race on a loaded host, so these settles assert that the
// session became usable again, not which readiness tier observed it.
function expectReadyForNextSend(waitReason: string): void {
expect(['stdin_read', 'inferred_idle']).toContain(waitReason)
}
function processIsRunning(pid: number): boolean {
try {
process.kill(pid, 0)
} catch (_missingProcess) {
return false
}
if (process.platform !== 'linux') return true
try {
const stat = readFileSync(`/proc/${pid}/stat`, 'utf8')
const state = stat.slice(stat.lastIndexOf(')') + 2).split(/\s+/, 1)[0]
return !/^[ZXx]$/.test(state ?? '')
} catch (_unreadableProcEntry) {
return false
}
}
describe('terminal-bash 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.terminals.spawn(agent, { type: 'shell', name: 'main', cwd: root })
expect(created.motd).toContain('dsh> ')
const first = ctx.terminals.startSend(agent, created.sessionId, { text: 'export KEEP=ok; cd /', submit: true })
expect((await first.done).waitReason).toBe('stdin_read')
const second = ctx.terminals.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.terminals.read(agent, created.sessionId, { offset: 0, count: 20 }).text).toContain('cwd=/ keep=ok secret=unset')
expect(await ctx.terminals.kill(agent, created.sessionId)).toBe(true)
expect(ctx.terminals.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.terminals.spawn(agent, { type: 'shell' })
expect(sandbox.calls).toEqual([{
argv: ['/bin/bash', '--noprofile', '--norc', '-i'],
policy: { mode: 'workspace-write', workspaceRoot: realpathSync.native(root), sessionId: 'agent-workspace-write' },
}])
await fiber.dispose()
expect(ctx.terminals.listBackends()).toEqual([])
expect(ctx.terminals.list(agent)).toHaveLength(1)
await ctx.terminals.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.terminals.spawn(agent, { type: 'shell' })
const foreground = ctx.terminals.startSend(agent, created.sessionId, { text: 'sleep 60', submit: true })
await new Promise(resolve => setTimeout(resolve, 50))
expect((await ctx.terminals.signal(agent, created.sessionId, 'SIGINT')).delivered).toBe(true)
expectReadyForNextSend((await foreground.done).waitReason)
const background = ctx.terminals.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.terminals.kill(agent, created.sessionId)
expect(() => process.kill(pid, 0)).toThrow()
}, 10_000)
it('quiesces a disowned same-session descendant after the shell exits naturally', async () => {
const { ctx, root, agent } = await harness('danger-full-access')
const created = await ctx.terminals.spawn(agent, { type: 'shell' })
const pidFile = join(root, 'disowned.pid')
let pid: number | undefined
try {
const background = ctx.terminals.startSend(agent, created.sessionId, {
text: `sh -c 'trap "" TERM; printf "%s" "$$" > "$1"; sleep 60' dsh "${pidFile}" & disown`,
submit: true,
})
await background.done
const pidDeadline = Date.now() + 2_000
let childPid = 0
while (childPid === 0 && Date.now() < pidDeadline) {
if (existsSync(pidFile)) childPid = Number(readFileSync(pidFile, 'utf8'))
if (childPid > 0) break
await new Promise(resolve => setTimeout(resolve, 10))
}
expect(existsSync(pidFile), ctx.terminals.read(agent, created.sessionId, { offset: 0, count: 100 }).text).toBe(true)
expect(childPid).toBeGreaterThan(0)
pid = childPid
expect(() => process.kill(childPid, 0)).not.toThrow()
await ctx.terminals.startSend(agent, created.sessionId, { text: 'exit', submit: true }).done
const deadline = Date.now() + 2_000
while (ctx.terminals.list(agent)[0]?.status.kind !== 'exited' && Date.now() < deadline) {
await new Promise(resolve => setTimeout(resolve, 10))
}
expect(ctx.terminals.list(agent)[0]?.status.kind).toBe('exited')
await ctx.terminals.kill(agent, created.sessionId)
expect(processIsRunning(childPid)).toBe(false)
} finally {
if (pid !== undefined) {
try {
process.kill(pid, 'SIGKILL')
} catch (_alreadyReaped) {
// Product cleanup is the expected path; this only contains a failed regression.
}
}
}
}, 10_000)
it('cancels a slow-starting raw-mode foreground process with a real SIGINT', async () => {
const { ctx, agent } = await harness('danger-full-access', {
idleSilenceMs: 10_000,
timeoutMs: 15_000,
})
const created = await ctx.terminals.spawn(agent, { type: 'shell' })
const controller = new AbortController()
const ready = 'RAW_READY'
// Delay readiness beyond the shared harness's short send bound so this
// process test owns enough slack for loaded macOS startup and shell echo.
// The interactive shell echoes the command, so only child output may contain the readiness marker.
const command = 'python3 -c \'import signal,sys,termios,time; signal.signal(signal.SIGINT, lambda *_: (print("SIGINT_SEEN", flush=True), sys.exit(0))); attrs=termios.tcgetattr(0); attrs[3] &= ~termios.ISIG; termios.tcsetattr(0, termios.TCSANOW, attrs); time.sleep(2.1); print("RAW_" + "READY", flush=True); time.sleep(60)\''
expect(command).not.toContain(ready)
const foreground = ctx.terminals.startSend(agent, created.sessionId, {
text: command,
submit: true,
signal: controller.signal,
})
await waitForOutput(foreground, ready, 15_000)
controller.abort()
const result = await foreground.done
expectReadyForNextSend(result.waitReason)
const afterReady = 'AFTER_SIGINT'
const afterCommand = 'printf "AFTER_%s\\n" SIGINT'
expect(afterCommand).not.toContain(afterReady)
const after = ctx.terminals.startSend(agent, created.sessionId, {
text: afterCommand,
submit: true,
})
await waitForOutput(after, afterReady, 15_000)
expectReadyForNextSend((await after.done).waitReason)
await ctx.terminals.kill(agent, created.sessionId)
}, 35_000)
})
@@ -0,0 +1,76 @@
import { describe, expect, it } from 'vitest'
import { normalizeTerminalText, TerminalSanitizer } from '@deepseek-ai/dsh-terminal-bash/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, promptTail: 'dsh> ' })
})
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('carries a trailing carriage return across data chunks and flushes standalone CR', () => {
const sanitizer = new TerminalSanitizer(64)
expect(sanitizer.push('a\r')).toEqual({ text: 'a', prompt: false })
expect(sanitizer.push('\nb')).toEqual({ text: '\nb', prompt: false })
expect(sanitizer.push('\r')).toEqual({ text: '', prompt: false })
expect(sanitizer.flush()).toBe('\n')
})
it('reports printable prompt text that follows a marker in a later chunk', () => {
const sanitizer = new TerminalSanitizer(64)
expect(sanitizer.push('\x1b]133;D;0\x07')).toEqual({ text: '', prompt: true, promptTail: '' })
expect(sanitizer.push('dsh> ')).toEqual({ text: 'dsh> ', prompt: false, promptTail: 'dsh> ' })
})
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 })
})
})
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,42 @@
{
"extends": "../../../tsconfig.base.json",
"compilerOptions": {
"rootDir": "src",
"outDir": "lib/types"
},
"include": [
"src"
],
"references": [
{
"path": "../../../vendor/cosmokit"
},
{
"path": "../../../vendor/cordis"
},
{
"path": "../../../vendor/schemastery"
},
{
"path": "../../core/agent"
},
{
"path": "../../core/session"
},
{
"path": "../terminal"
},
{
"path": "../../sandbox/sandbox"
},
{
"path": "../../sandbox/sandbox-policy"
},
{
"path": "../../subprocess/subprocess"
},
{
"path": "../../runtime-diagnostics/invariants"
}
]
}
@@ -0,0 +1,6 @@
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/terminal/terminal/README.md
README.md: e8959a1128575a00285a9d3b1165569d2dfbdc04
README.zh.md: 16a24ad94ea07f27927a9ac30fc026b13e084874
+41
View File
@@ -0,0 +1,41 @@
# @deepseek-ai/dsh-terminal
English | [中文](README.zh.md)
Owner-scoped persistent PTY seam. `TerminalSessionService` registers as `ctx.terminals`, 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 `TerminalBackendSession`; failed or cancelled setup must clean partial resources, and a failed cleanup rejects with `TerminalBackendCleanupError` so the registry can retain it across cancellation.
- Spawn cancellation preserves the caller's exact abort reason. Service disposal and owner loss remain distinct machine-routable failures after backend setup.
- Owner and service disposal abort unpublished setup through a service-owned signal and await backend settlement plus rollback before returning.
- A rollback-close or backend-reported startup cleanup failure rejects the disposing lifecycle instead of claiming quiescence. Caller-triggered cancellation still receives its exact reason; lifecycle-triggered rollback failure also rejects the pending spawn.
- A backend cleanup failure that follows caller cancellation remains owner activity until owner or service disposal consumes and reports it, so lifecycle policy cannot mistake failed cleanup for quiescence.
- `hasOwnerActivity(owner)` spans unpublished setup through final close, so lifecycle policy can fence the exact owner without a publication race.
- A successful spawn publishes one `TerminalSessionId`. 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.
- `TerminalSendResult.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 and clears the matching backend and registry fences so a later close can retry without disturbing a newer attempt.
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-job registration.
## Model Experience
### Indirect consumer
#### What the model sees
Nothing directly. This package registers no prompt or tool; `@deepseek-ai/dsh-tool-terminal` 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.
+41
View File
@@ -0,0 +1,41 @@
# @deepseek-ai/dsh-terminal
[English](README.md) | 中文
限定所有者范围的持久 PTY seam。`TerminalSessionService` 注册为 `ctx.terminals`,生成不透明的会话 id,通过具名后端路由创建操作,将每个操作限制在完全相同的活跃 `Agent` 内,并在该 agent(智能体)或服务 dispose(资源释放)时等待后端完全停稳。
## 约定
- 后端注册一个稳定的 `type`,并返回尚未发布的 `TerminalBackendSession`;失败或取消的设置过程必须清理部分资源。若清理失败,则以 `TerminalBackendCleanupError` 拒绝,使注册表能在取消后继续保留该清理失败。
- spawn 取消会保留调用方提供的确切中止原因。后端设置完成后,服务 dispose 与所有者消失仍分别对应可供机器路由的不同失败。
- 所有者与服务的 dispose 会通过服务持有的信号中止尚未发布的设置,并等待后端结算和回滚后才返回。
- 如果回滚关闭失败,或后端报告启动清理失败,dispose 生命周期会以拒绝结束,不会声称已经完全停稳。调用方触发的取消仍收到其确切原因;生命周期触发的回滚失败也会拒绝待完成的 spawn。
- 调用方取消后发生的后端清理失败仍算作所有者活动,直到所有者或服务 dispose 并消费、报告该失败,避免生命周期策略把失败的清理误判为完全停稳。
- `hasOwnerActivity(owner)` 覆盖从尚未发布的设置到最终关闭的全过程,使生命周期策略能精确限制对应所有者,不受发布竞态影响。
- 成功的 spawn 会发布一个 `TerminalSessionId`。可选的 `name` 只是所有者本地的显示元数据,绝不代表权限。
- 一个会话最多接受一个活跃的发送操作。读取和信号操作可以观察该发送;在当前操作结算前,另一项发送会失败。
- `TerminalSendResult.waitReason``sessionStatus` 相互独立。`session_exit` 描述顶层 PTY 进程,而不是任意前台命令。
- `kill()` 与 dispose 只会在后端捕获的进程树完全停稳后完成。清理失败会以拒绝结束,而非声称成功;同时它会清除匹配的后端和注册表限制,使后续关闭能够重试,且不会干扰较新的尝试。
该 seam 不包含 `node-pty`、沙箱、工具 schema、提示词、任务或终端渲染策略。实现负责终端机制;消费方负责模型呈现和可选的后台任务注册。
## 模型体验
### 间接消费方
#### 模型看到的内容
没有直接可见内容。此包不注册提示词或工具;可见 schema 和结果文本由 `@deepseek-ai/dsh-tool-terminal` 负责。
#### Token 影响
没有直接影响。活跃会话状态会保留在进程本地,直到消费方返回有界结果。
#### KV Cache 影响
不会直接失效;请求前缀变更由上述消费方负责。
## 已知限制与暂缓事项
- 会话只存在于进程本地,harness 重启后不会恢复。
- 系统有意不支持跨 agent 共享;未来的共享会话设计需要独立的权限约定。
+47
View File
@@ -0,0 +1,47 @@
{
"name": "@deepseek-ai/dsh-terminal",
"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-rc.2",
"publishConfig": {
"access": "restricted"
},
"repository": {
"type": "git",
"url": "git+https://github.com/deepseek-ai/deepseek-harness.git",
"directory": "packages/terminal/terminal"
},
"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"
],
"license": "BSD-3-Clause",
"peerDependencies": {
"@deepseek-ai/dsh-agent": "workspace:^",
"@deepseek-ai/dsh-brand": "workspace:^",
"@deepseek-ai/dsh-invariants": "workspace:^",
"@deepseek-ai/cordis": "workspace:^"
},
"devDependencies": {
"@deepseek-ai/dsh-agent": "workspace:^",
"@deepseek-ai/dsh-brand": "workspace:^",
"@deepseek-ai/dsh-invariants": "workspace:^",
"@deepseek-ai/dsh-session": "workspace:^",
"@deepseek-ai/cordis": "workspace:^"
}
}
+476
View File
@@ -0,0 +1,476 @@
/**
* Owner-scoped persistent PTY registry. Backends own terminal mechanics while
* this service owns ids, publication, authorization, and awaited cleanup.
* @module @deepseek-ai/dsh-terminal
*/
import { Context, Service } from '@deepseek-ai/cordis'
import type { Agent } from '@deepseek-ai/dsh-agent'
import { TerminalBackendCleanupError } from './types.ts'
import type {
TerminalBackend,
TerminalBackendSession,
TerminalReadRequest,
TerminalReadResult,
TerminalSendOperation,
TerminalSendRequest,
TerminalSessionIdValue,
TerminalSessionSnapshot,
TerminalSignal,
TerminalSignalResult,
TerminalSpawnRequest,
TerminalSpawnResult,
} from './types.ts'
export type {
TerminalBackend,
TerminalBackendSession,
TerminalBackendSpawnSpec,
TerminalReadRequest,
TerminalReadResult,
TerminalSendOperation,
TerminalSendRead,
TerminalSendRequest,
TerminalSendResult,
TerminalSessionSnapshot,
TerminalSessionStatus,
TerminalSignal,
TerminalSignalResult,
TerminalSpawnRequest,
TerminalSpawnResult,
TerminalWaitReason,
} from './types.ts'
export { TerminalBackendCleanupError } from './types.ts'
/** Opaque identity minted by {@link TerminalSessionService} for one live PTY session. */
export type TerminalSessionId = TerminalSessionIdValue
declare module '@deepseek-ai/cordis' {
interface Context {
terminals: TerminalSessionService
}
}
/** Machine-routable PTY service failures. */
export type TerminalErrorCode =
| 'DUPLICATE_BACKEND'
| 'DUPLICATE_NAME'
| 'FOREIGN_SESSION'
| 'NO_BACKEND'
| 'NO_SESSION'
| 'OWNER_NOT_LIVE'
| 'SEND_ACTIVE'
| 'SERVICE_DISPOSING'
/** Error carrying a stable {@link TerminalErrorCode}. */
export class TerminalError extends Error {
constructor(message: string, readonly code: TerminalErrorCode) {
super(message)
this.name = 'TerminalError'
}
}
/**
* Brand one registry-minted string as a {@link TerminalSessionId}.
* @param value - raw registry-issued id.
* @returns Same string with the PTY session brand.
*/
export function TerminalSessionId(value: string): TerminalSessionId {
return value as TerminalSessionId
}
interface SessionRecord {
readonly id: TerminalSessionId
readonly owner: Agent
readonly name: string | undefined
readonly type: string
readonly session: TerminalBackendSession
active: TerminalSendOperation | undefined
closing: Promise<void> | undefined
}
interface PendingSpawn {
readonly owner: Agent
readonly controller: AbortController
readonly settled: Promise<void>
cleanupFailure: { error: unknown } | undefined
}
interface SpawnReservation {
readonly signal: AbortSignal
release(cleanupFailure: { error: unknown } | undefined): void
}
/** In-process registry for replaceable PTY backends and exact-Agent sessions. */
export class TerminalSessionService extends Service {
private readonly backends = new Map<string, TerminalBackend>()
private readonly sessions = new Map<TerminalSessionId, SessionRecord>()
private readonly reservedNames = new Map<Agent, Set<string>>()
private readonly pendingSpawns = new Map<Agent, Set<PendingSpawn>>()
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, 'terminals')
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: TerminalBackend): () => void {
if (backend.type.length === 0) throw new Error('pty backend type must be non-empty')
if (this.backends.has(backend.type)) {
throw new TerminalError(`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: TerminalSpawnRequest, signal?: AbortSignal): Promise<TerminalSpawnResult> {
this.assertActive()
signal?.throwIfAborted()
this.ensureOwnerCleanup(owner)
const backend = this.backends.get(request.type)
if (backend === undefined) throw new TerminalError(`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')
const releaseName = this.reserveName(owner, request.name)
const spawnReservation = this.reserveSpawn(owner)
const backendSignal = signal === undefined
? spawnReservation.signal
: AbortSignal.any([signal, spawnReservation.signal])
const sessionId = TerminalSessionId(`pty-${++this.nextId}`)
let session: TerminalBackendSession | undefined
let cleanupFailure: { error: unknown } | undefined
try {
session = await backend.spawn({
sessionId,
owner,
type: request.type,
...request.name !== undefined ? { name: request.name } : {},
...request.cwd !== undefined ? { cwd: request.cwd } : {},
signal: backendSignal,
})
signal?.throwIfAborted()
if (this.disposing) {
throw new TerminalError('PTY service is disposing', 'SERVICE_DISPOSING')
}
if (!this.isLiveOwner(owner)) {
throw new TerminalError('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 (error instanceof TerminalBackendCleanupError) {
cleanupFailure = { error: error.cleanupError }
}
let rollbackFailure: { error: unknown } | undefined
if (session !== undefined && !this.sessions.has(sessionId)) {
try {
await session.close('PTY spawn rolled back')
} catch (closeError: unknown) {
rollbackFailure = { error: closeError }
cleanupFailure = rollbackFailure
}
}
let failure: unknown = error
try {
signal?.throwIfAborted()
spawnReservation.signal.throwIfAborted()
} catch (cancellation: unknown) {
failure = cancellation
}
if (rollbackFailure !== undefined && signal?.aborted !== true) {
throw new AggregateError([failure, rollbackFailure.error], 'PTY spawn and rollback both failed')
}
throw failure
} finally {
spawnReservation.release(cleanupFailure)
releaseName()
}
}
/**
* Test whether an exact owner has a published session or unpublished spawn.
* @param owner - exact live owner to inspect.
* @returns true across the entire spawn-to-close interval, with no publication gap.
*/
hasOwnerActivity(owner: Agent): boolean {
return (this.pendingSpawns.get(owner)?.size ?? 0) > 0
|| [...this.sessions.values()].some(record => record.owner === owner)
}
/**
* 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: TerminalSessionId, request: TerminalSendRequest): TerminalSendOperation {
const record = this.expectOwned(owner, id)
if (record.closing !== undefined) throw new Error(`PTY session ${id} is closing`)
if (record.active !== undefined) throw new TerminalError(`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: TerminalSessionId, request: TerminalReadRequest = {}): TerminalReadResult {
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: TerminalSessionId, signal: TerminalSignal): Promise<TerminalSignalResult> {
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: TerminalSessionId, reason: string = '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): TerminalSessionSnapshot[] {
return [...this.sessions.values()]
.filter(record => record.owner === owner)
.map(record => this.snapshot(record))
}
private assertActive(): void {
if (this.disposing) throw new TerminalError('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 TerminalError(`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 TerminalError(`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 TerminalError(`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 reserveSpawn(owner: Agent): SpawnReservation {
const controller = new AbortController()
const settlement = Promise.withResolvers<void>()
const pending: PendingSpawn = { owner, controller, settled: settlement.promise, cleanupFailure: undefined }
const owned = this.pendingSpawns.get(owner) ?? new Set<PendingSpawn>()
owned.add(pending)
this.pendingSpawns.set(owner, owned)
return {
signal: controller.signal,
release: (cleanupFailure) => {
pending.cleanupFailure = cleanupFailure
if (cleanupFailure === undefined) this.removePendingSpawn(pending)
settlement.resolve()
},
}
}
private removePendingSpawn(pending: PendingSpawn): void {
const owned = this.pendingSpawns.get(pending.owner)
if (owned === undefined) return
owned.delete(pending)
if (owned.size === 0) this.pendingSpawns.delete(pending.owner)
}
private async abortPendingSpawns(owner: Agent | undefined, reason: TerminalError): Promise<void> {
const pending = owner === undefined
? [...this.pendingSpawns.values()].flatMap(owned => [...owned])
: [...(this.pendingSpawns.get(owner) ?? [])]
for (const spawn of pending) spawn.controller.abort(reason)
await Promise.all(pending.map(spawn => spawn.settled))
const failures = pending.flatMap(spawn => spawn.cleanupFailure === undefined ? [] : [spawn.cleanupFailure.error])
for (const spawn of pending) this.removePendingSpawn(spawn)
if (failures.length > 0) {
throw new AggregateError(failures, 'failed to roll back unpublished PTY setup')
}
}
private expectOwned(owner: Agent, id: TerminalSessionId): SessionRecord {
const record = this.sessions.get(id)
if (record === undefined) throw new TerminalError(`unknown PTY session ${id}`, 'NO_SESSION')
if (record.owner !== owner) throw new TerminalError(`PTY session ${id} belongs to another agent`, 'FOREIGN_SESSION')
return record
}
private snapshot(record: SessionRecord): TerminalSessionSnapshot
private snapshot(record: SessionRecord, motd: string): TerminalSpawnResult
private snapshot(record: SessionRecord, motd?: string): TerminalSpawnResult | TerminalSessionSnapshot {
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 abortAndClose(owner: Agent | undefined, abortReason: TerminalError, closeReason: string): Promise<void> {
const failures: unknown[] = []
try {
await this.abortPendingSpawns(owner, abortReason)
} catch (error: unknown) {
failures.push(error)
}
const records = [...this.sessions.values()].filter(record => owner === undefined || record.owner === owner)
try {
await this.closeRecords(records, closeReason)
} catch (error: unknown) {
failures.push(error)
}
if (failures.length > 0) throw new AggregateError(failures, 'failed to clean up PTY lifecycle')
}
private async disposeOwned(owner: Agent): Promise<void> {
try {
await this.abortAndClose(
owner,
new TerminalError('PTY owner is no longer live', 'OWNER_NOT_LIVE'),
'PTY owner disposed',
)
} finally {
this.reservedNames.delete(owner)
}
}
private async disposeAll(): Promise<void> {
this.disposing = true
// 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.abortAndClose(
undefined,
new TerminalError('PTY service is disposing', 'SERVICE_DISPOSING'),
'PTY service disposed',
)
} finally {
this.backends.clear()
this.reservedNames.clear()
this.pendingSpawns.clear()
const cleanups = [...this.ownerCleanups.values()]
this.ownerCleanups.clear()
await Promise.all(cleanups.map(cleanup => Promise.resolve(cleanup())))
}
}
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
try {
await closing
this.sessions.delete(record.id)
} catch (error: unknown) {
// A concurrent retry may already own a newer fence; never clear it.
if (record.closing === closing) record.closing = undefined
throw error
}
}))
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 TerminalSessionService
@@ -0,0 +1,30 @@
/**
* Package-owned invariant companion for `@deepseek-ai/dsh-terminal`.
* @module @deepseek-ai/dsh-terminal/invariant
*/
/* jscpd:ignore-start */
import type { Context } from '@deepseek-ai/cordis'
import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants'
const PACKAGE_NAME = '@deepseek-ai/dsh-terminal'
/** Cordis companion plugin name. */
export const name = 'terminal-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 */
+177
View File
@@ -0,0 +1,177 @@
/**
* Types shared by PTY backends, the owner-scoped registry, and tool consumers.
* Runtime service code lives in `./index.ts`.
* @module @deepseek-ai/dsh-terminal/types
*/
import type { Branded } from '@deepseek-ai/dsh-brand'
import type { Agent } from '@deepseek-ai/dsh-agent'
/** Internal exported basis for the public `TerminalSessionId` type/value pair. */
export type TerminalSessionIdValue = Branded<'TerminalSessionId'>
/**
* Backend-reported failure to clean partial resources after unpublished setup failed.
* @param spawnError - original setup or cancellation failure.
* @param cleanupError - failure that may leave backend-owned resources alive.
*/
export class TerminalBackendCleanupError extends AggregateError {
constructor(
readonly spawnError: unknown,
readonly cleanupError: unknown,
) {
super([spawnError, cleanupError], 'PTY backend startup and cleanup both failed')
this.name = 'TerminalBackendCleanupError'
}
}
/** Why one interactive send returned control to its caller. */
export type TerminalWaitReason = 'stdin_read' | 'inferred_idle' | 'timeout' | 'session_exit'
/**
* Signals the model-facing PTY surface permits for foreground process groups.
* Kept member-identical to `SubprocessTerminalSignal` in
* `@deepseek-ai/dsh-subprocess` without a cross-seam dependency; change both together.
*/
export type TerminalSignal = 'SIGINT' | 'SIGTERM' | 'SIGKILL' | 'SIGTSTP' | 'SIGHUP'
/** Top-level PTY process status, independent of a send's wait reason. */
export type TerminalSessionStatus =
| { kind: 'running' }
| { kind: 'exited'; exitCode: number | null; signal: NodeJS.Signals | null }
/** Request to create one owner-scoped PTY session. */
export interface TerminalSpawnRequest {
/** 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 TerminalBackendSpawnSpec extends TerminalSpawnRequest {
/** Registry-minted session identity. */
sessionId: TerminalSessionIdValue
/** 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 TerminalSendRequest {
/** 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 TerminalSendRead {
/** 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 TerminalSendResult {
/** Bounded rendered terminal delta remaining at settlement. */
viewport: string
/** Why the wait returned; this does not imply arbitrary child-process exit. */
waitReason: TerminalWaitReason
/** Top-level session status observed at settlement. */
sessionStatus: TerminalSessionStatus
/** 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 TerminalSendOperation {
/** Resolves after readiness, timeout, cancellation, or top-level process exit. */
done: Promise<TerminalSendResult>
/** Consume output produced since the prior call. */
readOutput(): TerminalSendRead
/** Request `SIGINT`; returns false after the operation settled. */
cancel(): boolean
}
/** Request for one backward scrollback page. */
export interface TerminalReadRequest {
/** 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 TerminalReadResult {
/** 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 TerminalSignalResult {
/** 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 TerminalSessionSnapshot {
/** Registry-minted identity used by every operation. */
sessionId: TerminalSessionIdValue
/** 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: TerminalSessionStatus
}
/** Backend-owned live session retained by {@link TerminalSessionService}. */
export interface TerminalBackendSession {
/** 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: TerminalSendRequest): TerminalSendOperation
/** Read one bounded page from retained scrollback. */
read(request: TerminalReadRequest): TerminalReadResult
/** Signal the verified foreground process group. */
signal(signal: TerminalSignal): Promise<TerminalSignalResult>
/** Observe top-level process status. */
status(): TerminalSessionStatus
/** Idempotently close the captured owned process tree and await quiescence. */
close(reason: string): Promise<void>
}
/** Replaceable provider for one PTY session type. */
export interface TerminalBackend {
/** Stable type selected by {@link TerminalSpawnRequest.type}. */
readonly type: string
/** Create an unpublished session or reject after cleaning partial resources; cleanup failure uses {@link TerminalBackendCleanupError}. */
spawn(spec: TerminalBackendSpawnSpec): Promise<TerminalBackendSession>
}
/** Successful publication returned by {@link TerminalSessionService.spawn}. */
export interface TerminalSpawnResult extends TerminalSessionSnapshot {
/** Initial bounded output captured before publication. */
motd: string
}
@@ -0,0 +1,603 @@
import { describe, expect, expectTypeOf, it } from 'vitest'
import { Context } from '@deepseek-ai/cordis'
import { Session, SessionId } from '@deepseek-ai/dsh-session'
import AgentRegistry, { Inbox } from '@deepseek-ai/dsh-agent'
import type { Agent } from '@deepseek-ai/dsh-agent'
import TerminalSessionService, { TerminalBackendCleanupError, TerminalError, TerminalSessionId } from '@deepseek-ai/dsh-terminal'
import type {
TerminalBackend,
TerminalBackendSession,
TerminalReadRequest,
TerminalSendOperation,
TerminalSendRequest,
TerminalSessionId as TerminalSessionIdType,
TerminalSessionStatus,
TerminalSignal,
} from '@deepseek-ai/dsh-terminal'
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 session = Session.create(id)
const agent: Agent = {
id,
options: {},
session,
inbox: new Inbox(session, { inserted: () => {}, discarded: () => {}, claimed: () => {} }),
status: 'idle',
ctx: scopeFiber.ctx,
send: () => {},
followup: () => {},
steer: () => ({ outcome: Promise.resolve({ status: 'rejected' as const }) }),
inject: () => {},
cancel() {},
runMaintenance: task => task(new AbortController().signal),
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 TerminalBackendSession {
readonly motd = 'stub ready'
readonly pid = 123
closed: string[] = []
statusValue: TerminalSessionStatus = { kind: 'running' }
operation: TerminalSendOperation | undefined
rejectSend = false
rejectClose = false
closeGate: PromiseWithResolvers<undefined> | undefined
startSend(_request: TerminalSendRequest): TerminalSendOperation {
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: TerminalSendOperation = {
done,
readOutput: () => ({ delta: 'delta', truncated: false }),
cancel: () => {
if (settled) return false
settled = true
settle()
return true
},
}
this.operation = operation
return operation
}
read(request: TerminalReadRequest) {
return { text: `${request.offset ?? 0}:${request.count ?? 0}`, totalLines: 1, lineBegin: 0, lineEnd: 1, truncated: false }
}
async signal(signal: TerminalSignal) {
return { delivered: true as const, targetPgid: signal === 'SIGINT' ? 12 : 13 }
}
status(): TerminalSessionStatus {
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: TerminalBackend = {
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(TerminalSessionService)
ptyServiceDisposers.set(ctx, async () => { await fiber.dispose() })
return ctx
}
async function disposeTerminalSessionService(ctx: Context): Promise<void> {
const dispose = ptyServiceDisposers.get(ctx)
if (dispose === undefined) throw new Error('missing PTY service fiber')
await dispose()
}
describe('TerminalSessionService backend registry', () => {
it('preserves the id brand and disposes exact backend contributions', async () => {
expectTypeOf(TerminalSessionId('pty-1')).toEqualTypeOf<TerminalSessionIdType>()
const ctx = await harness()
const first = backend()
const dispose = ctx.terminals.registerBackend(first.provider)
expect(ctx.terminals.listBackends()).toEqual(['stub'])
expect(() => ctx.terminals.registerBackend(backend().provider)).toThrow(TerminalError)
const internal = ctx.terminals as unknown as { backends: Map<string, TerminalBackend> }
internal.backends.set('stub', backend('replacement').provider)
dispose()
expect(ctx.terminals.listBackends()).toEqual(['stub'])
internal.backends.clear()
})
it('rejects empty backend types', async () => {
const ctx = await harness()
expect(() => ctx.terminals.registerBackend(backend('').provider)).toThrow('must be non-empty')
})
})
describe('TerminalSessionService 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.terminals.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.terminals.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.terminals.hasOwnerActivity(owner)).toBe(true)
expect(ctx.terminals.list(owner)).toHaveLength(1)
expect(ctx.terminals.list(foreign)).toEqual([])
expect(() => ctx.terminals.read(foreign, created.sessionId)).toThrow('belongs to another agent')
expect(() => ctx.terminals.signal(foreign, created.sessionId, 'SIGINT')).toThrow('belongs to another agent')
await expect(Promise.resolve().then(() => ctx.terminals.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.terminals.spawn(owner, { type: 'missing' })).rejects.toMatchObject({ code: 'OWNER_NOT_LIVE' })
ctx.agents.register(owner)
await expect(ctx.terminals.spawn(owner, { type: 'missing' })).rejects.toMatchObject({ code: 'NO_BACKEND' })
const b = backend()
ctx.terminals.registerBackend(b.provider)
const created = await ctx.terminals.spawn(owner, { type: 'stub', name: 'main' })
await expect(ctx.terminals.spawn(owner, { type: 'stub', name: '' })).rejects.toThrow('must be non-empty')
const aborted = new AbortController()
const abortReason = new Error('spawn aborted')
aborted.abort(abortReason)
await expect(ctx.terminals.spawn(owner, { type: 'stub' }, aborted.signal)).rejects.toBe(abortReason)
await expect(ctx.terminals.spawn(owner, { type: 'stub', name: 'main' })).rejects.toMatchObject({ code: 'DUPLICATE_NAME' })
const operation = ctx.terminals.startSend(owner, created.sessionId, { text: 'echo hi', submit: true })
expect(() => ctx.terminals.startSend(owner, created.sessionId, { text: 'pwd', submit: true })).toThrow(TerminalError)
expect(operation.readOutput()).toEqual({ delta: 'delta', truncated: false })
expect(operation.cancel()).toBe(true)
await operation.done
const next = ctx.terminals.startSend(owner, created.sessionId, { text: 'pwd', submit: true })
next.cancel()
await next.done
b.sessions[0]!.rejectSend = true
await expect(ctx.terminals.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<TerminalBackendSession>()
const session = new StubSession()
ctx.terminals.registerBackend({ type: 'slow', spawn: () => gate.promise })
const owner = stubAgent(ctx, 'owner')
ctx.agents.register(owner)
const pending = ctx.terminals.spawn(owner, { type: 'slow', name: 'main' })
await expect(ctx.terminals.spawn(owner, { type: 'slow', name: 'main' })).rejects.toMatchObject({ code: 'DUPLICATE_NAME' })
const disposal = disposeAgentScope(owner)
gate.resolve(session)
await expect(pending).rejects.toMatchObject({ code: 'OWNER_NOT_LIVE' })
await disposal
expect(session.closed).toEqual(['PTY spawn rolled back'])
})
it('preserves caller cancellation when a pending backend spawn completes', async () => {
const ctx = await harness()
const gate = Promise.withResolvers<TerminalBackendSession>()
const session = new StubSession()
ctx.terminals.registerBackend({ type: 'slow', spawn: () => gate.promise })
const owner = stubAgent(ctx, 'owner')
ctx.agents.register(owner)
const controller = new AbortController()
const reason = new Error('cancelled by caller')
const pending = ctx.terminals.spawn(owner, { type: 'slow' }, controller.signal)
controller.abort(reason)
gate.resolve(session)
await expect(pending).rejects.toBe(reason)
expect(session.closed).toEqual(['PTY spawn rolled back'])
expect(ctx.agents.get(owner.id)).toBe(owner)
})
it('preserves caller cancellation when unpublished rollback fails', async () => {
const ctx = await harness()
const gate = Promise.withResolvers<TerminalBackendSession>()
const session = new StubSession()
session.rejectClose = true
ctx.terminals.registerBackend({ type: 'slow', spawn: () => gate.promise })
const owner = stubAgent(ctx, 'owner')
ctx.agents.register(owner)
const controller = new AbortController()
const reason = new Error('cancelled by caller')
const pending = ctx.terminals.spawn(owner, { type: 'slow' }, controller.signal)
controller.abort(reason)
gate.resolve(session)
await expect(pending).rejects.toBe(reason)
expect(ctx.terminals.hasOwnerActivity(owner)).toBe(true)
const internal = ctx.terminals as unknown as { disposeAll(): Promise<void> }
await expect(internal.disposeAll()).rejects.toThrow('failed to clean up PTY lifecycle')
expect(ctx.terminals.hasOwnerActivity(owner)).toBe(false)
expect(session.closed).toEqual(['PTY spawn rolled back'])
})
it('preserves caller cancellation when a backend rejects in response to it', async () => {
const ctx = await harness()
const started = Promise.withResolvers<undefined>()
const backendFailure = new Error('backend observed cancellation')
ctx.terminals.registerBackend({
type: 'abortable',
spawn: ({ signal }) => new Promise((_resolve, reject) => {
if (signal === undefined) throw new Error('missing spawn signal')
started.resolve(undefined)
signal.addEventListener('abort', () => { reject(backendFailure) }, { once: true })
}),
})
const owner = stubAgent(ctx, 'owner')
ctx.agents.register(owner)
const controller = new AbortController()
const reason = new Error('cancelled by caller')
const pending = ctx.terminals.spawn(owner, { type: 'abortable' }, controller.signal)
await started.promise
controller.abort(reason)
await expect(pending).rejects.toBe(reason)
})
it.each(['owner', 'service'] as const)('retains caller-triggered backend cleanup failure until %s disposal', async (scope) => {
const ctx = await harness()
const started = Promise.withResolvers<undefined>()
const cleanupFailure = new Error('backend cleanup failed')
ctx.terminals.registerBackend({
type: 'cleanup-failing',
spawn: ({ signal }) => new Promise((_resolve, reject) => {
if (signal === undefined) throw new Error('missing spawn signal')
started.resolve(undefined)
signal.addEventListener('abort', () => {
reject(new TerminalBackendCleanupError(signal.reason, cleanupFailure))
}, { once: true })
}),
})
const owner = stubAgent(ctx, 'owner')
ctx.agents.register(owner)
const controller = new AbortController()
const reason = new Error('cancelled by caller')
const pending = ctx.terminals.spawn(owner, { type: 'cleanup-failing' }, controller.signal)
await started.promise
controller.abort(reason)
await expect(pending).rejects.toBe(reason)
expect(ctx.terminals.hasOwnerActivity(owner)).toBe(true)
const internal = ctx.terminals as unknown as {
disposeOwned(owner: Agent): Promise<void>
disposeAll(): Promise<void>
}
const disposal = scope === 'owner' ? internal.disposeOwned(owner) : internal.disposeAll()
await expect(disposal).rejects.toThrow('failed to clean up PTY lifecycle')
expect(ctx.terminals.hasOwnerActivity(owner)).toBe(false)
})
it.each([
{ scope: 'owner', code: 'OWNER_NOT_LIVE' },
{ scope: 'service', code: 'SERVICE_DISPOSING' },
] as const)('$scope disposal aborts and awaits unpublished backend setup', async ({ scope, code }) => {
const ctx = await harness()
const gate = Promise.withResolvers<TerminalBackendSession>()
const started = Promise.withResolvers<undefined>()
const session = new StubSession()
let backendSignal: AbortSignal | undefined
ctx.terminals.registerBackend({
type: 'slow',
spawn: (spec) => {
backendSignal = spec.signal
started.resolve(undefined)
return gate.promise
},
})
const owner = stubAgent(ctx, 'owner')
ctx.agents.register(owner)
const pending = ctx.terminals.spawn(owner, { type: 'slow' })
const pendingFailure = pending.then(
() => { throw new Error('pending spawn unexpectedly succeeded') },
(error: unknown) => error,
)
await started.promise
let disposalSettled = false
const disposal = (scope === 'owner' ? disposeAgentScope(owner) : disposeTerminalSessionService(ctx))
.then(() => { disposalSettled = true })
await new Promise(resolve => setTimeout(resolve, 0))
const signalAbortedBeforeRelease = backendSignal?.aborted ?? false
const signalReasonBeforeRelease = backendSignal?.reason as unknown
const disposalSettledBeforeRelease = disposalSettled
gate.resolve(session)
expect(await pendingFailure).toMatchObject({ code })
await disposal
expect(signalAbortedBeforeRelease).toBe(true)
expect(signalReasonBeforeRelease).toMatchObject({ code })
expect(disposalSettledBeforeRelease).toBe(false)
expect(session.closed).toEqual(['PTY spawn rolled back'])
})
it('reports unpublished rollback failure through service disposal', async () => {
const ctx = await harness()
const gate = Promise.withResolvers<TerminalBackendSession>()
const session = new StubSession()
session.rejectClose = true
ctx.terminals.registerBackend({ type: 'slow', spawn: () => gate.promise })
const owner = stubAgent(ctx, 'owner')
ctx.agents.register(owner)
const pending = ctx.terminals.spawn(owner, { type: 'slow' })
const pendingFailure = expect(pending).rejects.toThrow('PTY spawn and rollback both failed')
const internal = ctx.terminals as unknown as { disposeAll(): Promise<void> }
const disposalFailure = expect(internal.disposeAll()).rejects.toThrow('failed to clean up PTY lifecycle')
gate.resolve(session)
await pendingFailure
await disposalFailure
expect(session.closed).toEqual(['PTY spawn rolled back'])
})
it.each([
{ scope: 'owner', code: 'OWNER_NOT_LIVE' },
{ scope: 'service', code: 'SERVICE_DISPOSING' },
] as const)('$scope disposal retains backend-side startup cleanup failure', async ({ scope, code }) => {
const ctx = await harness()
const started = Promise.withResolvers<undefined>()
const cleanupFailure = new Error('backend cleanup failed')
let backendAbortReason: unknown
ctx.terminals.registerBackend({
type: 'cleanup-failing',
spawn: ({ signal }) => new Promise((_resolve, reject) => {
if (signal === undefined) throw new Error('missing spawn signal')
started.resolve(undefined)
signal.addEventListener('abort', () => {
backendAbortReason = signal.reason
reject(new TerminalBackendCleanupError(signal.reason, cleanupFailure))
}, { once: true })
}),
})
const owner = stubAgent(ctx, 'owner')
ctx.agents.register(owner)
const pending = ctx.terminals.spawn(owner, { type: 'cleanup-failing' })
await started.promise
const internal = ctx.terminals as unknown as {
disposeOwned(owner: Agent): Promise<void>
disposeAll(): Promise<void>
}
const disposal = scope === 'owner' ? internal.disposeOwned(owner) : internal.disposeAll()
const pendingError = await pending.then(
() => { throw new Error('pending spawn unexpectedly succeeded') },
(error: unknown) => error,
)
expect(pendingError).toBe(backendAbortReason)
expect(pendingError).toMatchObject({ code })
const disposalError = await disposal.then(
() => { throw new Error('disposal unexpectedly succeeded') },
(error: unknown) => error,
)
expect(disposalError).toMatchObject({ message: 'failed to clean up PTY lifecycle' })
const rollbackError = (disposalError as AggregateError).errors[0] as unknown
const cleanupErrors = (rollbackError as AggregateError).errors as unknown[]
expect(cleanupErrors).toEqual([cleanupFailure])
})
it('keeps independent reservations and handles provider failure before publication', async () => {
const ctx = await harness()
const firstGate = Promise.withResolvers<TerminalBackendSession>()
const secondGate = Promise.withResolvers<TerminalBackendSession>()
let count = 0
ctx.terminals.registerBackend({
type: 'slow',
spawn: () => ++count === 1 ? firstGate.promise : secondGate.promise,
})
const owner = stubAgent(ctx, 'owner')
ctx.agents.register(owner)
const first = ctx.terminals.spawn(owner, { type: 'slow', name: 'one' })
const second = ctx.terminals.spawn(owner, { type: 'slow', name: 'two' })
firstGate.resolve(new StubSession())
await first
secondGate.resolve(new StubSession())
await second
ctx.terminals.registerBackend({ type: 'throwing', spawn: () => Promise.reject(new Error('provider failed')) })
await expect(ctx.terminals.spawn(owner, { type: 'throwing' })).rejects.toThrow('provider failed')
const controller = new AbortController()
const b = backend('signaled')
ctx.terminals.registerBackend(b.provider)
await ctx.terminals.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.terminals.registerBackend({ type: 'virtual', spawn: () => Promise.resolve(session) })
expect(await ctx.terminals.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
let ownerDisposal = Promise.resolve()
const internal = ctx.terminals as unknown as {
disposedOwners: WeakSet<Agent>
disposeOwned(owner: Agent): Promise<void>
}
ctx.terminals.registerBackend({
type: 'bad-spawn',
async spawn({ signal }) {
if (signal === undefined) throw new Error('missing spawn signal')
internal.disposedOwners.add(owner)
ownerDisposal = internal.disposeOwned(owner)
if (!signal.aborted) {
await new Promise<undefined>((resolve) => {
signal.addEventListener('abort', () => { resolve(undefined) }, { once: true })
})
}
return failedSpawn
},
})
await expect(ctx.terminals.spawn(owner, { type: 'bad-spawn' })).rejects.toThrow('spawn and rollback both failed')
await expect(ownerDisposal).rejects.toThrow('failed to clean up PTY lifecycle')
const nextOwner = stubAgent(ctx, 'next')
ctx.agents.register(nextOwner)
const b = backend('bad-close')
ctx.terminals.registerBackend(b.provider)
const created = await ctx.terminals.spawn(nextOwner, { type: 'bad-close' })
b.sessions[0]!.rejectClose = true
await expect(ctx.terminals.kill(nextOwner, created.sessionId)).rejects.toThrow('close failed')
expect(ctx.terminals.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.terminals.registerBackend(b.provider)
const created = await ctx.terminals.spawn(owner, { type: 'stub' })
b.sessions[0]!.closeGate = Promise.withResolvers<undefined>()
const first = ctx.terminals.kill(owner, created.sessionId)
expect(() => ctx.terminals.startSend(owner, created.sessionId, { text: '', submit: false })).toThrow('closing')
const second = ctx.terminals.kill(owner, created.sessionId)
b.sessions[0]!.closeGate?.resolve(undefined)
expect(await first).toBe(true)
expect(await second).toBe(false)
expect(() => ctx.terminals.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.terminals.registerBackend(b.provider)
const owner = stubAgent(ctx, 'owner')
ctx.agents.register(owner)
const created = await ctx.terminals.spawn(owner, { type: 'stub' })
disposeBackend()
expect(ctx.terminals.listBackends()).toEqual([])
expect(ctx.terminals.read(owner, created.sessionId).text).toBe('0:0')
await disposeAgentScope(owner)
expect(b.sessions[0]?.closed).toEqual(['PTY owner disposed'])
expect(ctx.terminals.list(owner)).toEqual([])
})
it('kills idempotently and service disposal closes all owners', async () => {
const ctx = await harness()
const b = backend()
ctx.terminals.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.terminals.spawn(first, { type: 'stub' })
await ctx.terminals.spawn(second, { type: 'stub' })
expect(await ctx.terminals.kill(first, a.sessionId)).toBe(true)
expect(b.sessions[0]?.closed).toEqual(['model request'])
const service = ctx.terminals
await disposeTerminalSessionService(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.terminals
const b = backend()
ctx.terminals.registerBackend(b.provider)
const owner = stubAgent(ctx, 'owner')
ctx.agents.register(owner)
await ctx.terminals.spawn(owner, { type: 'stub' })
b.sessions[0]!.rejectClose = true
const internal = service as unknown as {
sessions: Map<TerminalSessionIdType, unknown>
closeRecords(records: unknown[], reason: string): Promise<void>
}
const records = [...internal.sessions.values()]
const firstFailure = expect(internal.closeRecords(records, 'test failure')).rejects.toThrow('failed to close 1 PTY session')
const joinedFailure = expect(internal.closeRecords(records, 'joined failure')).rejects.toThrow('failed to close 1 PTY session')
await firstFailure
await joinedFailure
b.sessions[0]!.rejectClose = false
await expect(internal.closeRecords([...internal.sessions.values()], 'retry')).resolves.toBeUndefined()
expect(b.sessions[0]!.closed).toEqual(['test failure', 'retry'])
expect(internal.sessions.size).toBe(0)
await disposeTerminalSessionService(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.terminals
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 clean up PTY lifecycle')
expect(internal.backends.size).toBe(0)
expect(internal.ownerCleanups.size).toBe(0)
})
})
+27
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": "../../runtime-diagnostics/invariants"
}
]
}
@@ -0,0 +1,6 @@
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/terminal/tool-terminal/README.md
README.md: f6fd4020d423425d7b20ad6bc21b4e009cf25c85
README.zh.md: 827d179515779f87665a80920e72a11be051023f
+71
View File
@@ -0,0 +1,71 @@
# @deepseek-ai/dsh-tool-terminal
English | [中文](README.zh.md)
Six model-facing tools over `ctx.terminals`: `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.jobs`; job preflight and the PTY service's exclusive per-session send reservation occur before the job id is returned, completion is collected with `job_output`, and `job_kill` delivers `SIGINT` to the foreground process group. Foreground sends use terminal call/result cards. Background sends use a generic execute card; open, read, signal, close, and list use generic `execute`, `read`, `execute`, `delete`, and `read` cards respectively. None declares source locations.
## Config
| key | default | meaning |
|---|---:|---|
| `enableRunInBackground` | `true` | expose and accept `run_in_background`; false omits the schema field and rejects a forced undeclared argument |
| `maxResultBytes` | `262144` | UTF-8 cap (minimum `64`) for each complete terminal result or PTY job output after wait, session, pagination, truncation, and task-status metadata |
Both values are validated at load. The minimum result cap keeps every registry-issued session or job id visible in its creation acknowledgement. When a result exceeds `maxResultBytes`, rendering reserves space for control metadata and a truncation marker when they fit; cuts preserve UTF-8 boundaries. Each terminal definition's final-content callback applies the same cap after normalized pre-, around-, and post-execute policy failures, denials, short-circuits, replacements, or blocks; a structured multi-block policy result retains its shape.
## 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 shell/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-terminal` catalog section](../../../docs/tool-catalog.md#deepseek-aidsh-tool-terminal). 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 job id. Every terminal-owned or policy-produced single-text result is capped by `maxResultBytes` after normalized tool or pipeline errors, denials, short-circuits, replacements, blocks, and generic job status text. Structured multi-block policy results retain their shape. Results remain in session history until compaction; incremental task reads do not repeat consumed output. Programmatic callers receive typed session snapshots, bounded provider read/send DTOs, signal and close outcomes, or `{ kind: "background", jobId }`; Native rendering applies the presentation cap above.
#### Token effect
Terminal-owned and policy-produced single-text results are data-dependent and bounded by `maxResultBytes`; a policy that deliberately substitutes structured multi-block content owns that content's bound. 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-jobs` and its model-facing controller.
@@ -0,0 +1,71 @@
# @deepseek-ai/dsh-tool-terminal
[English](README.md) | 中文
基于 `ctx.terminals` 提供 6 个面向模型的工具:`terminal_open``terminal_send``terminal_read``terminal_signal``terminal_close``terminal_list`。每项操作都要求提供完全相同的发起 `Agent`,因此即使模型获知另一个 agent(智能体)的 id,也无法操作其终端。
`terminal_send(run_in_background: true)` 会复用 `ctx.jobs`;任务预检和 PTY 服务对每个会话的独占发送预留都发生在返回 job id 之前。系统通过 `job_output` 收集完成结果,`job_kill` 则向前台进程组发送 `SIGINT`。前台发送使用终端调用/结果卡片。后台发送使用通用执行卡片;打开、读取、发送信号、关闭和列出操作则分别使用通用 `execute``read``execute``delete``read` 卡片。所有操作都不声明源位置。
## 配置
| 键 | 默认值 | 含义 |
|---|---:|---|
| `enableRunInBackground` | `true` | 公开并接受 `run_in_background`;设为 false 时,schema 会省略该字段,并拒绝强行传入未声明的参数 |
| `maxResultBytes` | `262144` | 每个完整终端结果或 PTY 任务输出的 UTF-8 上限(最小值 `64`);在等待、会话、分页、截断和任务状态元数据全部加入后计算 |
两个值都会在加载时验证。最小结果上限可保证注册表签发的每个会话或 job id 都能出现在创建确认中。结果超过 `maxResultBytes` 时,只要空间允许,渲染会为控制元数据和截断标记预留空间;截断会保留 UTF-8 边界。每个终端定义的最终内容回调都会应用同一个上限,涵盖经过规范化的 pre-execute、around-execute 与 post-execute 策略失败、拒绝、短路、替换或阻止;结构化的多块策略结果保留其结构。
## 模型体验
### 系统提示词
#### 模型看到的内容
该插件贡献以下固定指引章节:
##### 终端指引
```markdown
Use a terminal session only when work needs persistent terminal state or interactive stdin; prefer shell/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 影响
插件活跃期间,每次请求都会产生少量固定输入成本。
#### KV Cache 影响
注册范围和指引文本不变时,前缀保持稳定。
### 工具 schema
#### 模型看到的内容
6 个生成的 schema 列在 [`dsh-tool-terminal` 目录章节](../../../docs/tool-catalog.md#deepseek-aidsh-tool-terminal)中。此插件活跃时,请求中会包含它们的固定 schema token;按 agent 范围过滤工具时可能隐藏这些 schema。
#### Token 影响
工具可见的请求会产生固定的 schema 成本。
#### KV Cache 影响
工具可见性与定义不变时,前缀保持稳定。
### 工具结果与任务上下文
#### 模型看到的内容
spawn 会返回 id 和有界 MOTD。发送/读取会返回有界终端文本以及就绪/历史标记。后台模式返回通用 job id。所有终端自身或策略产生的单文本结果,在经过规范化的工具或流水线错误、拒绝、短路、替换、阻止与通用任务状态文本之后,都受 `maxResultBytes` 限制。结构化的多块策略结果保留其结构。结果会保留在会话历史中直到压缩(compaction);增量任务读取不会重复已经消费的输出。编程调用方会收到带类型的会话快照、有界的提供方读取/发送 DTO、信号与关闭结果,或 `{ kind: "background", jobId }`;Native 渲染会应用上述呈现上限。
#### Token 影响
终端自身与策略产生的单文本结果随数据变化,并受 `maxResultBytes` 限制;如果策略有意替换为结构化多块内容,则由该策略负责限制内容。每个返回结果都会保留在历史中直到压缩。
#### KV Cache 影响
仅追加;新结果位于可复用请求前缀之后。
## 已知限制与暂缓事项
- 不公开具名按键序列、TUI、BEL、调整大小、自动启动或跨 agent 共享 schema。
- 后台模式同时依赖 `@deepseek-ai/dsh-jobs` 及其面向模型的控制器。
@@ -0,0 +1,68 @@
{
"name": "@deepseek-ai/dsh-tool-terminal",
"description": "Six model-facing persistent PTY tools with owner isolation and generic background-job integration",
"version": "0.0.1-rc.2",
"publishConfig": {
"access": "restricted"
},
"repository": {
"type": "git",
"url": "git+https://github.com/deepseek-ai/deepseek-harness.git",
"directory": "packages/terminal/tool-terminal"
},
"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"
],
"license": "BSD-3-Clause",
"dependencies": {
"@deepseek-ai/schemastery": "workspace:^"
},
"peerDependencies": {
"@deepseek-ai/dsh-agent": "workspace:^",
"@deepseek-ai/dsh-invariants": "workspace:^",
"@deepseek-ai/dsh-llm": "workspace:^",
"@deepseek-ai/dsh-terminal": "workspace:^",
"@deepseek-ai/dsh-output-retention": "workspace:^",
"@deepseek-ai/dsh-system-prompt": "workspace:^",
"@deepseek-ai/dsh-jobs": "workspace:^",
"@deepseek-ai/dsh-tools": "workspace:^",
"@deepseek-ai/cordis": "workspace:^"
},
"devDependencies": {
"@deepseek-ai/cordis-plugin-include": "workspace:^",
"@deepseek-ai/cordis-plugin-loader": "workspace:^",
"@deepseek-ai/dsh-agent": "workspace:^",
"@deepseek-ai/dsh-invariants": "workspace:^",
"@deepseek-ai/dsh-llm": "workspace:^",
"@deepseek-ai/dsh-terminal": "workspace:^",
"@deepseek-ai/dsh-terminal-bash": "workspace:^",
"@deepseek-ai/dsh-output-retention": "workspace:^",
"@deepseek-ai/dsh-sandbox": "workspace:^",
"@deepseek-ai/dsh-sandbox-policy": "workspace:^",
"@deepseek-ai/dsh-session": "workspace:^",
"@deepseek-ai/dsh-subprocess-local": "workspace:^",
"@deepseek-ai/dsh-system-prompt": "workspace:^",
"@deepseek-ai/dsh-jobs": "workspace:^",
"@deepseek-ai/dsh-jobs-local": "workspace:^",
"@deepseek-ai/dsh-tool-jobs": "workspace:^",
"@deepseek-ai/dsh-tools": "workspace:^",
"@deepseek-ai/cordis": "workspace:^"
}
}
@@ -0,0 +1,399 @@
/**
* Six model-facing persistent terminal tools. Owner identity comes from the exact
* tool execution Agent; generic `ctx.jobs` owns background ids and collection.
* @module @deepseek-ai/dsh-tool-terminal
*/
import { Context } from '@deepseek-ai/cordis'
import z from '@deepseek-ai/schemastery'
import type { Agent } from '@deepseek-ai/dsh-agent'
import type { ContentBlock } from '@deepseek-ai/dsh-llm'
import { TerminalSessionId } from '@deepseek-ai/dsh-terminal'
import type { TerminalSendResult, TerminalSessionId as TerminalSessionIdType, TerminalSignal } from '@deepseek-ai/dsh-terminal'
import type {} from '@deepseek-ai/dsh-jobs'
import { defineTool } from '@deepseek-ai/dsh-tools'
import type { ToolDefinition } from '@deepseek-ai/dsh-tools'
import { boundTerminalText, renderList, renderRead, renderSend, renderSendRead, renderSpawn } from './render.ts'
declare module '@deepseek-ai/dsh-jobs' {
interface JobKindMap {
'pty-send': 'pty-send'
}
}
/** Cordis plugin name. */
export const name = 'tool-terminal'
/** Required capability, registry, and prompt services. */
export const inject = ['terminals', 'tools', 'systemPrompt']
/** Default cap for one complete model-facing terminal result. */
export const DEFAULT_MAX_RESULT_BYTES = 256 * 1024
/** Smallest cap that preserves every counter-backed PTY and job id in its creation acknowledgement. */
export const MIN_MAX_RESULT_BYTES = 64
/** Model-facing terminal tool configuration. */
export interface Config {
/** Expose `run_in_background` and accept background sends (default true). */
enableRunInBackground?: boolean
/** Maximum UTF-8 bytes in one complete terminal or task-output result. */
maxResultBytes?: number
}
/** Schemastery configuration for the terminal tool consumer. */
export const Config: z<Config> = z.object({
enableRunInBackground: z.boolean().default(true),
maxResultBytes: z.number().step(1).min(MIN_MAX_RESULT_BYTES).max(Number.MAX_SAFE_INTEGER).default(DEFAULT_MAX_RESULT_BYTES),
})
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: TerminalSignal
}
const SESSION_STATUS_SCHEMA = {
oneOf: [
{
type: 'object',
additionalProperties: false,
properties: {
kind: { type: 'string', required: true, const: 'running' },
},
},
{
type: 'object',
additionalProperties: false,
properties: {
kind: { type: 'string', required: true, const: 'exited' },
exitCode: { required: true, oneOf: [{ type: 'integer' }, { type: 'null' }] },
signal: { required: true, oneOf: [{ type: 'string' }, { type: 'null' }] },
},
},
],
} as const
const SESSION_SNAPSHOT_PROPERTIES = {
sessionId: { type: 'string', required: true },
name: { type: 'string' },
type: { type: 'string', required: true },
pid: { type: 'integer' },
status: { ...SESSION_STATUS_SCHEMA, required: true },
} as const
const SESSION_SNAPSHOT_SCHEMA = {
type: 'object',
additionalProperties: false,
properties: SESSION_SNAPSHOT_PROPERTIES,
} as const
const BACKGROUND_TASK_OUTPUT_SCHEMA = {
type: 'object',
additionalProperties: false,
properties: {
kind: { type: 'string', required: true, const: 'background' },
jobId: { type: 'string', required: true },
},
} as const
function requireAgent(agent: Agent | undefined): Agent {
if (agent === undefined) throw new Error('terminal tools require an initiating agent')
return agent
}
function sessionId(args: SessionArgs): TerminalSessionIdType {
if (args.sessionId.length === 0) {
throw new Error('sessionId must be a non-empty string')
}
return TerminalSessionId(args.sessionId)
}
function textResult(text: string, maxBytes: number): ContentBlock[] {
return [{ type: 'text', text: boundTerminalText(text, maxBytes) }]
}
function rawContentText(content: readonly ContentBlock[]): string | undefined {
if (content.length !== 1) return undefined
const block = content[0]
return block?.type === 'text' ? block.text : undefined
}
function sendDetail(result: TerminalSendResult): 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, config: Config = {}): void {
const enableRunInBackground = config.enableRunInBackground ?? true
const maxResultBytes = config.maxResultBytes ?? DEFAULT_MAX_RESULT_BYTES
if (!Number.isSafeInteger(maxResultBytes) || maxResultBytes < MIN_MAX_RESULT_BYTES) {
throw new Error(`tool-terminal: maxResultBytes must be a safe integer of at least ${MIN_MAX_RESULT_BYTES}`)
}
const finalizeContent: NonNullable<ToolDefinition['finalizeContent']> = (_exec, result) => {
const raw = rawContentText(result.content)
return raw === undefined ? undefined : textResult(raw, maxResultBytes)
}
ctx.systemPrompt.section({
name: 'tool:pty',
order: 106,
text: 'Use a terminal session only when work needs persistent terminal state or interactive stdin; prefer shell/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.' },
},
finalizeContent,
output: {
schema: {
type: 'object',
additionalProperties: false,
properties: {
...SESSION_SNAPSHOT_PROPERTIES,
motd: { type: 'string', required: true },
},
},
render: (_args, value) => [{ type: 'text', text: renderSpawn(value, maxResultBytes) }],
},
async execute(args: SpawnArgs, exec) {
if (args.type.length === 0) throw new Error('type must be a non-empty string')
const result = await ctx.terminals.spawn(requireAgent(exec.agent), {
type: args.type,
...args.name !== undefined ? { name: args.name } : {},
...args.cwd !== undefined ? { cwd: args.cwd } : {},
}, exec.signal)
return 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.'
+ (enableRunInBackground ? ' Background mode returns a job id for job_output/job_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.' },
...enableRunInBackground
? { run_in_background: { type: 'boolean' as const, description: 'Return a job id immediately; collect with job_output or stop with job_kill.' } }
: {},
},
finalizeContent,
output: {
schema: {
oneOf: [
BACKGROUND_TASK_OUTPUT_SCHEMA,
{
type: 'object',
additionalProperties: false,
properties: {
kind: { type: 'string', required: true, const: 'foreground' },
viewport: { type: 'string', required: true },
waitReason: {
type: 'string',
required: true,
enum: ['stdin_read', 'inferred_idle', 'timeout', 'session_exit'],
},
sessionStatus: { ...SESSION_STATUS_SCHEMA, required: true },
truncated: { type: 'boolean', required: true },
},
},
],
},
render: (_args, value) => [{
type: 'text',
text: value.kind === 'background'
? `started background job ${value.jobId}`
: renderSend(value, maxResultBytes),
}],
presentationMeta: (_args, value) => value.kind === 'foreground'
? {
viewport: value.viewport,
waitReason: value.waitReason,
sessionStatus: value.sessionStatus,
truncated: value.truncated,
}
: null,
},
async execute(args: SendArgs, exec) {
const owner = requireAgent(exec.agent)
const id = sessionId(args)
const request = { text: args.text, submit: args.submit ?? true }
if (args.run_in_background === true) {
if (!enableRunInBackground) throw new Error('background terminal sends are disabled by tool-terminal configuration')
const jobs = ctx.get('jobs')
if (jobs === undefined) throw new Error('background terminal sends require @deepseek-ai/dsh-jobs and @deepseek-ai/dsh-tool-jobs')
let cancelRequested = false
const jobId = jobs.start({
kind: 'pty-send',
label: `${id}: ${args.text || '(input)'}`,
owner,
outputLimitBytes: maxResultBytes,
run: () => {
const operation = ctx.terminals.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 { kind: 'background' as const, jobId }
}
const operation = ctx.terminals.startSend(owner, id, { ...request, signal: exec.signal })
const result = await operation.done
if (exec.signal.aborted) throw new Error('terminal send aborted')
return { kind: 'foreground' as const, ...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 = rawContentText(result.content)
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).' },
},
finalizeContent,
output: {
schema: {
type: 'object',
additionalProperties: false,
properties: {
text: { type: 'string', required: true },
totalLines: { type: 'integer', required: true },
lineBegin: { type: 'integer', required: true },
lineEnd: { type: 'integer', required: true },
truncated: { type: 'boolean', required: true },
},
},
render: (_args, value) => [{ type: 'text', text: renderRead(value, maxResultBytes) }],
},
execute(args: ReadArgs, exec) {
const result = ctx.terminals.read(requireAgent(exec.agent), sessionId(args), {
...args.offset !== undefined ? { offset: args.offset } : {},
...args.count !== undefined ? { count: args.count } : {},
})
return Promise.resolve(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.' },
},
finalizeContent,
output: {
schema: {
type: 'object',
additionalProperties: false,
properties: {
delivered: { type: 'boolean', required: true, const: true },
targetPgid: { type: 'integer', required: true },
},
},
render: (args, value) => [{ type: 'text', text: `delivered ${args.signal} to foreground process group ${value.targetPgid}` }],
},
async execute(args: SignalArgs, exec) {
return ctx.terminals.signal(requireAgent(exec.agent), sessionId(args), args.signal)
},
presentCall: args => ({ card: 'generic', title: `Signal terminal ${args.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.' },
},
finalizeContent,
output: {
schema: {
type: 'object',
additionalProperties: false,
properties: {
sessionId: { type: 'string', required: true },
outcome: { type: 'string', required: true, enum: ['closed', 'already-closing'] },
},
},
render: (_args, value) => [{
type: 'text',
text: value.outcome === 'closed'
? `closed terminal session ${value.sessionId}`
: `terminal session ${value.sessionId} was already closing`,
}],
},
async execute(args: SessionArgs, exec) {
const id = sessionId(args)
const closed = await ctx.terminals.kill(requireAgent(exec.agent), id)
return { sessionId: id, outcome: closed ? 'closed' as const : 'already-closing' as const }
},
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: {},
finalizeContent,
output: {
schema: { type: 'array', items: SESSION_SNAPSHOT_SCHEMA },
render: (_args, value) => [{ type: 'text', text: renderList(value, maxResultBytes) }],
},
execute(_args: Record<string, never>, exec) {
return Promise.resolve(ctx.terminals.list(requireAgent(exec.agent)))
},
presentCall: () => ({ card: 'generic', title: 'List terminal sessions', kind: 'read' }),
}))
}
@@ -0,0 +1,30 @@
/**
* Package-owned invariant companion for `@deepseek-ai/dsh-tool-terminal`.
* @module @deepseek-ai/dsh-tool-terminal/invariant
*/
/* jscpd:ignore-start */
import type { Context } from '@deepseek-ai/cordis'
import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants'
const PACKAGE_NAME = '@deepseek-ai/dsh-tool-terminal'
/** Cordis companion plugin name. */
export const name = 'tool-terminal-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-job 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 */
@@ -0,0 +1,177 @@
/** Model and UI rendering for persistent terminal tool results. */
import { TextRetainer } from '@deepseek-ai/dsh-output-retention'
interface RenderedSessionStatusRunning {
kind: 'running'
}
interface RenderedSessionStatusExited {
kind: 'exited'
exitCode: number | null
signal: string | null
}
type RenderedSessionStatus = RenderedSessionStatusRunning | RenderedSessionStatusExited
interface RenderedSessionSnapshot {
sessionId: string
name?: string
type: string
pid?: number
status: RenderedSessionStatus
}
interface RenderedSpawnResult extends RenderedSessionSnapshot {
motd: string
}
interface RenderedSendResult {
viewport: string
waitReason: 'stdin_read' | 'inferred_idle' | 'timeout' | 'session_exit'
sessionStatus: RenderedSessionStatus
truncated: boolean
}
interface RenderedSendRead {
delta: string
truncated: boolean
}
interface RenderedReadResult {
text: string
totalLines: number
lineBegin: number
lineEnd: number
truncated: boolean
}
const encoder = new TextEncoder()
const TRUNCATED = '\n[output truncated]'
function byteLength(text: string): number {
return encoder.encode(text).byteLength
}
function retain(text: string, maxBytes: number, kind: 'head' | 'tail'): string {
const retainer = new TextRetainer({ kind, maxBytes })
retainer.push(text)
return retainer.finish().text
}
function fitWithSuffix(content: string, suffix: string, maxBytes: number): string {
const fixedBytes = byteLength(suffix)
if (fixedBytes >= maxBytes) return retain(suffix, maxBytes, 'tail')
return `${retain(content, maxBytes - fixedBytes, 'tail')}${suffix}`
}
function fitWithPrefix(prefix: string, content: string, maxBytes: number): string {
const fixed = `${prefix}${TRUNCATED}`
const fixedBytes = byteLength(fixed)
if (fixedBytes >= maxBytes) return retain(fixed, maxBytes, 'head')
return `${prefix}${retain(content, maxBytes - fixedBytes, 'tail')}${TRUNCATED}`
}
function boundBodyWithSuffix(
content: string,
metadata: string,
upstreamTruncated: boolean,
maxBytes: number,
): string {
const suffix = `${metadata}${upstreamTruncated ? TRUNCATED : ''}`
const complete = `${content}${suffix}`
if (byteLength(complete) <= maxBytes) return complete
return fitWithSuffix(content, `${metadata}${TRUNCATED}`, maxBytes)
}
/**
* Bound one complete terminal acknowledgement while preserving UTF-8 cuts.
* @param text - complete acknowledgement text.
* @param maxBytes - positive final result cap.
* @returns bounded text with a truncation marker when it fits.
*/
export function boundTerminalText(text: string, maxBytes: number): string {
if (byteLength(text) <= maxBytes) return text
const markerBytes = byteLength(TRUNCATED)
if (markerBytes >= maxBytes) return retain(TRUNCATED, maxBytes, 'tail')
return `${retain(text, maxBytes - markerBytes, 'head')}${TRUNCATED}`
}
/**
* Render one created session and its bounded MOTD.
* @param result - published spawn result.
* @param maxBytes - complete UTF-8 result cap.
* @returns Model-facing session acknowledgement.
*/
export function renderSpawn(result: RenderedSpawnResult, maxBytes: number): string {
const label = result.name === undefined ? result.sessionId : `${result.sessionId} (${result.name})`
const prefix = `started terminal session ${label} [type: ${result.type}]\n`
const motd = result.motd || '(no startup output)'
const complete = `${prefix}${motd}`
return byteLength(complete) <= maxBytes ? complete : fitWithPrefix(prefix, motd, maxBytes)
}
/**
* Render one settled interactive send.
* @param result - settled send outcome.
* @param maxBytes - complete UTF-8 result cap.
* @returns Terminal output plus wait/session markers.
*/
export function renderSend(result: RenderedSendResult, maxBytes: number): string {
const output = result.viewport || '(no new output)'
const status = result.sessionStatus.kind === 'running'
? 'running'
: `exited code=${result.sessionStatus.exitCode ?? 'null'} signal=${result.sessionStatus.signal ?? 'null'}`
return boundBodyWithSuffix(
output,
`\n[wait: ${result.waitReason}]\n[session: ${status}]`,
result.truncated,
maxBytes,
)
}
/**
* Render one incremental background operation read.
* @param read - consuming operation delta.
* @returns Delta plus its upstream truncation marker. The generic task control
* applies the producer's complete-result cap after adding job status.
*/
export function renderSendRead(read: RenderedSendRead): string {
const separator = read.delta.endsWith('\n') || read.delta.length === 0 ? '' : '\n'
return `${read.delta}${read.truncated ? `${separator}[output truncated]` : ''}`
}
/**
* Render one bounded historical page.
* @param result - retained scrollback page.
* @param maxBytes - complete UTF-8 result cap.
* @returns Page text plus pagination and truncation markers.
*/
export function renderRead(result: RenderedReadResult, maxBytes: number): string {
const output = result.text || '(no retained output)'
return boundBodyWithSuffix(
output,
`\n[lines: ${result.lineBegin}-${result.lineEnd} of ${result.totalLines}]`,
result.truncated,
maxBytes,
)
}
/**
* Render owner-visible live sessions.
* @param sessions - fresh owner-scoped snapshots.
* @param maxBytes - complete UTF-8 result cap.
* @returns One line per session or the empty marker.
*/
export function renderList(sessions: readonly RenderedSessionSnapshot[], maxBytes: number): string {
if (sessions.length === 0) return '(no terminal sessions)'
const text = sessions.map((session) => {
const name = session.name === undefined ? '' : ` (${session.name})`
const pid = session.pid === undefined ? '' : ` pid=${session.pid}`
const status = session.status.kind === 'running'
? 'running'
: `exited code=${session.status.exitCode ?? 'null'} signal=${session.status.signal ?? 'null'}`
return `${session.sessionId}${name} [${session.type}] ${status}${pid}`
}).join('\n')
return boundBodyWithSuffix(text, '', false, maxBytes)
}
@@ -0,0 +1,130 @@
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 '@deepseek-ai/cordis'
import Loader from '@deepseek-ai/cordis-plugin-loader'
import Include from '@deepseek-ai/cordis-plugin-include'
import { CallId } from '@deepseek-ai/dsh-llm'
import { Session, SessionId } from '@deepseek-ai/dsh-session'
import AgentRegistry, { Inbox } from '@deepseek-ai/dsh-agent'
import type { Agent } from '@deepseek-ai/dsh-agent'
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
import ToolRuntime from '@deepseek-ai/dsh-tools'
import TerminalSessionService from '@deepseek-ai/dsh-terminal'
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 LocalSubprocessRuntime from '@deepseek-ai/dsh-subprocess-local'
import * as TerminalLocal from '@deepseek-ai/dsh-terminal-bash'
import * as ToolPty from '@deepseek-ai/dsh-tool-terminal'
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: [], runnerFailureRules: [] }
}
}
function agent(ctx: Context): Agent {
const scope = ctx.plugin(() => {})
const id = SessionId('pty-loader-agent')
const session = Session.create(id)
const value: Agent = {
id, options: {}, session, inbox: new Inbox(session, { inserted: () => {}, discarded: () => {}, claimed: () => {} }),
status: 'idle',
ctx: scope.ctx,
send: () => {},
followup: () => {}, steer: () => {}, inject: () => {}, cancel() {},
runMaintenance: job => job(new AbortController().signal),
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-terminal'",
"- 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-subprocess-local'",
"- name: '@deepseek-ai/dsh-terminal-bash'",
' config:',
' pollIntervalMs: 10',
' exactProbeAfterMs: 20',
' idleSilenceMs: 250',
' handoffGraceMs: 250',
' timeoutMs: 2000',
' disposeGraceMs: 500',
"- name: '@deepseek-ai/dsh-tool-terminal'",
'',
].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', ToolRuntime],
['@deepseek-ai/dsh-terminal', TerminalSessionService],
['@deepseek-ai/dsh-test-sandbox', PassthroughSandbox],
['@deepseek-ai/dsh-sandbox-policy', SandboxPolicyService],
['@deepseek-ai/dsh-subprocess-local', LocalSubprocessRuntime],
['@deepseek-ai/dsh-terminal-bash', TerminalLocal],
['@deepseek-ai/dsh-tool-terminal', 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.terminals.list(owner)).toHaveLength(1)
}, 15_000)
})
@@ -0,0 +1,73 @@
import { describe, expect, it } from 'vitest'
import { TerminalSessionId } from '@deepseek-ai/dsh-terminal'
import { boundTerminalText, renderList, renderRead, renderSend, renderSendRead, renderSpawn } from '@deepseek-ai/dsh-tool-terminal/src/render.ts'
describe('tool-terminal rendering', () => {
it('renders spawn with and without names or MOTD', () => {
expect(renderSpawn({ sessionId: TerminalSessionId('pty-1'), type: 'shell', status: { kind: 'running' }, motd: '' }, 1024))
.toBe('started terminal session pty-1 [type: shell]\n(no startup output)')
expect(renderSpawn({ sessionId: TerminalSessionId('pty-2'), name: 'main', type: 'shell', pid: 2, status: { kind: 'running' }, motd: 'ready' }, 1024))
.toContain('pty-2 (main)')
})
it('renders running, exited, empty, and truncated sends', () => {
expect(renderSend({ viewport: '', waitReason: 'timeout', sessionStatus: { kind: 'running' }, truncated: true }, 1024))
.toBe('(no new output)\n[wait: timeout]\n[session: running]\n[output truncated]')
expect(renderSend({ viewport: 'bye', waitReason: 'session_exit', sessionStatus: { kind: 'exited', exitCode: null, signal: 'SIGTERM' }, truncated: false }, 1024))
.toContain('exited code=null signal=SIGTERM')
expect(renderSend({ viewport: 'bye', waitReason: 'session_exit', sessionStatus: { kind: 'exited', exitCode: 2, signal: null }, truncated: false }, 1024))
.toContain('exited code=2 signal=null')
expect(renderSend({ viewport: 'bye', waitReason: 'session_exit', sessionStatus: { kind: 'exited', exitCode: null, signal: null }, truncated: false }, 1024))
.toContain('exited code=null signal=null')
expect(renderSendRead({ delta: '', truncated: true })).toBe('[output truncated]')
expect(renderSendRead({ delta: 'x', truncated: true })).toBe('x\n[output truncated]')
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 }, 1024))
.toBe('(no retained output)\n[lines: 0-0 of 0]\n[output truncated]')
expect(renderList([], 1024)).toBe('(no terminal sessions)')
expect(renderList([
{ sessionId: TerminalSessionId('pty-1'), type: 'shell', status: { kind: 'running' } },
{ sessionId: TerminalSessionId('pty-2'), name: 'done', type: 'shell', pid: 9, status: { kind: 'exited', exitCode: 2, signal: null } },
{ sessionId: TerminalSessionId('pty-3'), type: 'shell', status: { kind: 'exited', exitCode: null, signal: 'SIGTERM' } },
{ sessionId: TerminalSessionId('pty-4'), type: 'shell', status: { kind: 'exited', exitCode: null, signal: null } },
], 1024)).toBe('pty-1 [shell] running\npty-2 (done) [shell] exited code=2 signal=null pid=9\npty-3 [shell] exited code=null signal=SIGTERM\npty-4 [shell] exited code=null signal=null')
})
it('bounds complete UTF-8 results while retaining terminal metadata when it fits', () => {
const send = renderSend({
viewport: `prefix-${'界'.repeat(40)}`,
waitReason: 'stdin_read',
sessionStatus: { kind: 'running' },
truncated: false,
}, 64)
expect(Buffer.byteLength(send)).toBeLessThanOrEqual(64)
expect(send).toContain('[wait: stdin_read]')
expect(send).toContain('[output truncated]')
const read = renderRead({
text: 'x'.repeat(200), totalLines: 20, lineBegin: 0, lineEnd: 10, truncated: false,
}, 48)
expect(Buffer.byteLength(read)).toBeLessThanOrEqual(48)
expect(read).toContain('[lines: 0-10 of 20]')
expect(Buffer.byteLength(renderSpawn({
sessionId: TerminalSessionId('pty-1'), type: 'shell', status: { kind: 'running' }, motd: 'x'.repeat(200),
}, 32))).toBeLessThanOrEqual(32)
const boundedSpawn = renderSpawn({
sessionId: TerminalSessionId('pty-1'), type: 'shell', status: { kind: 'running' }, motd: 'x'.repeat(200),
}, 96)
expect(boundedSpawn).toContain('started terminal session pty-1')
expect(boundedSpawn).toContain('[output truncated]')
expect(Buffer.byteLength(renderSend({
viewport: 'x'.repeat(200), waitReason: 'stdin_read', sessionStatus: { kind: 'running' }, truncated: false,
}, 8))).toBeLessThanOrEqual(8)
expect(boundTerminalText('x'.repeat(200), 8)).toHaveLength(8)
expect(boundTerminalText('x'.repeat(200), 32).endsWith('[output truncated]')).toBe(true)
})
})
@@ -0,0 +1,495 @@
import { describe, expect, it } from 'vitest'
import { Context } from '@deepseek-ai/cordis'
import { CallId } from '@deepseek-ai/dsh-llm'
import { Session, SessionId } from '@deepseek-ai/dsh-session'
import AgentRegistry, { Inbox } from '@deepseek-ai/dsh-agent'
import type { Agent } from '@deepseek-ai/dsh-agent'
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
import ToolRuntime, { renderToolsSdk } from '@deepseek-ai/dsh-tools'
import type { ToolSdkSchema } from '@deepseek-ai/dsh-tools/src/ts-types.ts'
import TerminalSessionService, { TerminalSessionId } from '@deepseek-ai/dsh-terminal'
import type { TerminalBackend, TerminalBackendSession, TerminalSendOperation, TerminalSendRequest, TerminalSessionStatus, TerminalSignal } from '@deepseek-ai/dsh-terminal'
import LocalJobRegistry from '@deepseek-ai/dsh-jobs-local'
import * as ToolTasks from '@deepseek-ai/dsh-tool-jobs'
import * as ToolPty from '@deepseek-ai/dsh-tool-terminal'
function fakeAgent(ctx: Context, rawId: string): Agent {
const scope = ctx.plugin(() => {})
const id = SessionId(rawId)
const session = Session.create(id)
const agent: Agent = {
id, options: {}, session, inbox: new Inbox(session, { inserted: () => {}, discarded: () => {}, claimed: () => {} }),
status: 'idle',
ctx: scope.ctx,
send: () => {},
followup: () => {}, steer: () => {}, inject: () => {}, cancel() {},
runMaintenance: job => job(new AbortController().signal),
whenIdle: () => Promise.resolve(),
}
ctx.agents.register(agent)
return agent
}
class StubSession implements TerminalBackendSession {
readonly motd = 'stub prompt'
readonly pid = 42
statusValue: TerminalSessionStatus = { kind: 'running' }
operation: TerminalSendOperation | undefined
autoSettle = true
rejectOperation = false
closeGate: PromiseWithResolvers<undefined> | undefined
viewport = 'command output'
delta = 'live output'
deltaTruncated = false
startSend(_request: TerminalSendRequest): TerminalSendOperation {
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' : this.viewport,
waitReason: 'stdin_read' as const,
sessionStatus: this.statusValue,
truncated: false,
}))
const operation: TerminalSendOperation = {
done,
readOutput: () => ({ delta: this.delta, truncated: this.deltaTruncated }),
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: TerminalSignal) {
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: TerminalBackend = {
type: 'stub',
async spawn() {
const session = new StubSession()
sessions.push(session)
return session
},
}
return { backend, sessions }
}
async function setup(jobs: boolean, config: ToolPty.Config = {}) {
const base = await setupBase(jobs)
await base.ctx.plugin(ToolPty, config)
return base
}
async function setupBase(jobs: boolean) {
const ctx = new Context()
await ctx.plugin(SystemPrompt)
await ctx.plugin(ToolRuntime)
await ctx.plugin(AgentRegistry)
await ctx.plugin(TerminalSessionService)
const stub = stubBackend()
ctx.terminals.registerBackend(stub.backend)
if (jobs) {
await ctx.plugin(LocalJobRegistry)
await ctx.plugin(ToolTasks)
}
return { ctx, stub, agent: fakeAgent(ctx, jobs ? 'with-tasks' : 'foreground') }
}
let callNumber = 0
const TOOL_NAMES = ['terminal_open', 'terminal_send', 'terminal_read', 'terminal_signal', 'terminal_close', 'terminal_list'] as const
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-terminal foreground API', () => {
it('registers exactly six schemas and drives the full owner-scoped lifecycle', async () => {
const { ctx, agent } = await setup(false)
expect(TOOL_NAMES.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(spawned).toMatchObject({
isError: false,
value: {
sessionId: 'pty-1',
name: 'main',
type: 'stub',
pid: 42,
status: { kind: 'running' },
motd: 'stub prompt',
},
})
const listed = await call(ctx, 'terminal_list', {}, agent)
expect(text(listed)).toContain('pty-1 (main) [stub] running pid=42')
expect(listed).toMatchObject({ isError: false, value: [{ sessionId: 'pty-1', name: 'main', type: 'stub', pid: 42, status: { kind: 'running' } }] })
const read = await call(ctx, 'terminal_read', { sessionId: 'pty-1' }, agent)
expect(text(read)).toContain('history\n[lines: 0-1 of 1]')
expect(read).toMatchObject({ isError: false, value: { text: 'history', totalLines: 1, lineBegin: 0, lineEnd: 1, truncated: false } })
const signalled = await call(ctx, 'terminal_signal', { sessionId: 'pty-1', signal: 'SIGINT' }, agent)
expect(text(signalled)).toBe('delivered SIGINT to foreground process group 10')
expect(signalled).toMatchObject({ isError: false, value: { delivered: true, targetPgid: 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(sent).toMatchObject({
isError: false,
value: {
kind: 'foreground',
viewport: 'command output',
waitReason: 'stdin_read',
sessionStatus: { kind: 'running' },
truncated: false,
},
meta: {
viewport: 'command output',
waitReason: 'stdin_read',
sessionStatus: { kind: 'running' },
truncated: false,
},
})
const closed = await call(ctx, 'terminal_close', { sessionId: 'pty-1' }, agent)
expect(text(closed)).toBe('closed terminal session pty-1')
expect(closed).toMatchObject({ isError: false, value: { sessionId: 'pty-1', outcome: 'closed' } })
const empty = await call(ctx, 'terminal_list', {}, agent)
expect(text(empty)).toBe('(no terminal sessions)')
expect(empty).toMatchObject({ isError: false, value: [] })
})
it('projects every terminal DTO into the generated Code Mode output map', async () => {
const { ctx } = await setup(false)
const schemas = TOOL_NAMES.map((toolName): ToolSdkSchema => {
const definition = ctx.tools.get(toolName)
if (definition === undefined) throw new Error(`missing terminal tool ${toolName}`)
return {
name: definition.name,
description: definition.description,
parameters: definition.parameters,
output: definition.output.schema,
}
})
const sdk = renderToolsSdk(schemas)
const outputMapStart = sdk.indexOf('interface ToolOutputMap')
const outputMapEnd = sdk.indexOf('\n\ntype ToolName', outputMapStart)
expect(sdk.slice(outputMapStart, outputMapEnd)).toMatchInlineSnapshot(`
"interface ToolOutputMap {
terminal_close: {
sessionId: string;
outcome: "closed" | "already-closing";
};
terminal_list: ({
sessionId: string;
name?: string;
type: string;
pid?: number;
status: {
kind: "running";
} | {
kind: "exited";
exitCode: number | null;
signal: string | null;
};
})[];
terminal_open: {
sessionId: string;
name?: string;
type: string;
pid?: number;
status: {
kind: "running";
} | {
kind: "exited";
exitCode: number | null;
signal: string | null;
};
motd: string;
};
terminal_read: {
text: string;
totalLines: number;
lineBegin: number;
lineEnd: number;
truncated: boolean;
};
terminal_send: {
kind: "background";
jobId: string;
} | {
kind: "foreground";
viewport: string;
waitReason: "stdin_read" | "inferred_idle" | "timeout" | "session_exit";
sessionStatus: {
kind: "running";
} | {
kind: "exited";
exitCode: number | null;
signal: string | null;
};
truncated: boolean;
};
terminal_signal: {
delivered: true;
targetPgid: number;
};
}"
`)
})
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' })
})
it('configuration-gates background sends and validates the final result bound', async () => {
const disabled = await setup(true, { enableRunInBackground: false })
const definition = disabled.ctx.tools.get('terminal_send')
expect(definition?.parameters).not.toHaveProperty('properties.run_in_background')
expect(definition?.description).not.toContain('Background mode')
await call(disabled.ctx, 'terminal_open', { type: 'stub' }, disabled.agent)
expect((await call(disabled.ctx, 'terminal_send', {
sessionId: 'pty-1', text: 'work', run_in_background: true,
}, disabled.agent)).isError).toBe(true)
const defaults = await setupBase(false)
ToolPty.apply(defaults.ctx)
expect(defaults.ctx.tools.get('terminal_send')?.parameters).toHaveProperty('properties.run_in_background')
const invalid = await setupBase(false)
expect(() => { ToolPty.apply(invalid.ctx, { maxResultBytes: 0 }) }).toThrow('maxResultBytes')
expect(() => { ToolPty.apply(invalid.ctx, { maxResultBytes: 63 }) }).toThrow('at least 64')
})
it('bounds normalized errors and preserves allocated ids at the minimum result cap', async () => {
const { ctx, agent } = await setup(true, { maxResultBytes: 64 })
const failed = await call(ctx, 'terminal_open', { type: 'x'.repeat(1_000) }, agent)
expect(failed.isError).toBe(true)
expect(Buffer.byteLength(text(failed))).toBeLessThanOrEqual(64)
expect(text(failed)).toContain('[output truncated]')
const opened = await call(ctx, 'terminal_open', { type: 'stub', name: 'n'.repeat(1_000) }, agent)
expect(text(opened)).toContain('pty-1')
expect(Buffer.byteLength(text(opened))).toBeLessThanOrEqual(64)
const background = await call(ctx, 'terminal_send', {
sessionId: 'pty-1', text: 'work', run_in_background: true,
}, agent)
expect(text(background)).toContain('pty-send-1')
expect(Buffer.byteLength(text(background))).toBeLessThanOrEqual(64)
})
it('bounds terminal results after policy decisions and pipeline failures', async () => {
const { ctx, agent } = await setup(false, { maxResultBytes: 64 })
ctx.on('tools/pre-execute', async (exec, next) => {
if (exec.name === 'terminal_list') return { kind: 'deny', reason: 'd'.repeat(1_000) }
if (exec.name === 'terminal_signal') throw new Error(`pre failed: ${'p'.repeat(1_000)}`)
return next()
})
ctx.on('tools/execute', async (exec, next) => {
if (exec.name === 'terminal_close') throw new Error(`around failed: ${'e'.repeat(1_000)}`)
return next()
})
ctx.on('tools/post-execute', async (exec, _result, next) => {
if (exec.name === 'terminal_open') {
return { kind: 'accept', content: [{ type: 'text', text: 'a'.repeat(1_000) }] }
}
if (exec.name === 'terminal_read') {
return { kind: 'block', feedback: [{ type: 'text', text: 'b'.repeat(1_000) }] }
}
if (exec.name === 'terminal_send') throw new Error(`post failed: ${'o'.repeat(1_000)}`)
return next()
})
const denied = await call(ctx, 'terminal_list', {}, agent)
expect(denied.isError).toBe(true)
expect(Buffer.byteLength(text(denied))).toBeLessThanOrEqual(64)
expect(text(denied)).toContain('[output truncated]')
const replaced = await call(ctx, 'terminal_open', { type: 'stub' }, agent)
expect(replaced.isError).toBe(false)
expect(Buffer.byteLength(text(replaced))).toBeLessThanOrEqual(64)
expect(text(replaced)).toContain('[output truncated]')
const blocked = await call(ctx, 'terminal_read', { sessionId: 'pty-1' }, agent)
expect(blocked.isError).toBe(true)
expect(Buffer.byteLength(text(blocked))).toBeLessThanOrEqual(64)
expect(text(blocked)).toContain('[output truncated]')
const failures = [
await call(ctx, 'terminal_signal', { sessionId: 'pty-1', signal: 'SIGINT' }, agent),
await call(ctx, 'terminal_close', { sessionId: 'pty-1' }, agent),
await call(ctx, 'terminal_send', { sessionId: 'pty-1', text: 'work' }, agent),
]
for (const failure of failures) {
expect(failure.isError).toBe(true)
expect(Buffer.byteLength(text(failure))).toBeLessThanOrEqual(64)
expect(text(failure)).toContain('[output truncated]')
}
})
it('leaves a structured around-dispatch failure unchanged', async () => {
const { ctx, agent } = await setup(false, { maxResultBytes: 64 })
ctx.on('tools/execute', async (exec, next) => exec.name === 'terminal_list'
? { content: [], isError: true, error: { message: 'structured failure' } }
: next())
const result = await call(ctx, 'terminal_list', {}, agent)
expect(result.isError).toBe(true)
expect(result.content).toEqual([])
})
})
describe('tool-terminal 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)
const started = await call(ctx, 'terminal_send', { sessionId: 'pty-1', text: 'build', run_in_background: true }, agent)
expect(text(started)).toBe('started background job pty-send-1')
expect(started).toMatchObject({ isError: false, value: { kind: 'background', jobId: 'pty-send-1' } })
const output = await call(ctx, 'job_output', { job_id: 'pty-send-1', wait: true }, agent)
expect(text(output)).toContain('live output')
expect(text(output)).toContain('[status: completed, wait: stdin_read]')
})
it('bounds foreground and background results after terminal and task metadata', async () => {
const { ctx, agent, stub } = await setup(true, { maxResultBytes: 64 })
await call(ctx, 'terminal_open', { type: 'stub' }, agent)
stub.sessions[0]!.viewport = '界'.repeat(100)
const foreground = await call(ctx, 'terminal_send', { sessionId: 'pty-1', text: 'foreground' }, agent)
expect(Buffer.byteLength(text(foreground))).toBeLessThanOrEqual(64)
stub.sessions[0]!.delta = '界'.repeat(100)
stub.sessions[0]!.deltaTruncated = true
await call(ctx, 'terminal_send', { sessionId: 'pty-1', text: 'background', run_in_background: true }, agent)
const background = await call(ctx, 'job_output', { job_id: 'pty-send-1', wait: true }, agent)
expect(Buffer.byteLength(text(background))).toBeLessThanOrEqual(64)
expect(text(background)).toContain('[status: completed')
expect(text(background).match(/\[output truncated\]/g)).toHaveLength(1)
expect(text(background)).toContain('[output truncated]\n[status: completed')
})
it('rejects pre-aborted background calls, maps job 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, 'job_kill', { job_id: 'pty-send-1' }, agent))).toContain('requested cancellation')
await new Promise(resolve => setTimeout(resolve, 0))
expect(text(await call(ctx, 'job_output', { job_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, 'job_output', { job_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.terminals.kill(agent, TerminalSessionId('pty-1'))
const second = call(ctx, 'terminal_close', { sessionId: 'pty-1' }, agent)
stub.sessions[0]!.closeGate?.resolve(undefined)
await first
const result = await second
expect(text(result)).toBe('terminal session pty-1 was already closing')
expect(result).toMatchObject({ isError: false, value: { sessionId: 'pty-1', outcome: '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, 'job_output', { job_id: 'pty-send-1', wait: true }, agent)
expect(text(output)).toContain('session exited: unknown')
})
})
describe('tool-terminal plugin shape', () => {
it('is a named function plugin with no default export', () => {
expect('default' in ToolPty).toBe(false)
expect(ToolPty.name).toBe('tool-terminal')
expect(ToolPty.inject).toEqual(['terminals', 'tools', 'systemPrompt'])
})
})
@@ -0,0 +1,42 @@
{
"extends": "../../../tsconfig.base.json",
"compilerOptions": {
"rootDir": "src",
"outDir": "lib/types"
},
"include": [
"src"
],
"references": [
{
"path": "../../../vendor/cosmokit"
},
{
"path": "../../../vendor/cordis"
},
{
"path": "../../util/output-retention"
},
{
"path": "../terminal"
},
{
"path": "../../core/agent"
},
{
"path": "../../llm/llm"
},
{
"path": "../../core/system-prompt"
},
{
"path": "../../core/tools"
},
{
"path": "../../jobs/jobs"
},
{
"path": "../../runtime-diagnostics/invariants"
}
]
}