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:36:22 +08:00
parent 101df7cf58
commit a2d0f7f411
3281 changed files with 21730 additions and 21592 deletions

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/tool-terminal/README.md
README.md: f6fd4020d423425d7b20ad6bc21b4e009cf25c85
README.zh.md: 827d179515779f87665a80920e72a11be051023f

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.

View File

@@ -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` 及其面向模型的控制器。

View File

@@ -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:^"
}
}

View File

@@ -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' }),
}))
}

View File

@@ -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 */

View File

@@ -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)
}

View File

@@ -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)
})

View File

@@ -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)
})
})

View File

@@ -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'])
})
})

View File

@@ -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"
}
]
}