refactor(pty): follow the naming-contract renames across the persistent pwsh stack

This commit is contained in:
Huanqi Cao
2026-08-13 09:52:56 +08:00
parent 1ae9df33eb
commit d6010c2d88
19 changed files with 1491 additions and 1402 deletions
@@ -208,7 +208,7 @@ async function respondToSessionExit(
ctx: Context,
shells: PersistentShells,
owner: Agent,
id: PtySessionId,
id: TerminalSessionId,
status: { exitCode: number | null; signal: NodeJS.Signals | null },
marker: CommandMarkers,
fallback: string,
@@ -319,7 +319,7 @@ async function executeCommand(
// The shell may flip to exited between iterations (a fast `exit` can
// settle the previous send while its exit event is still in flight);
// re-observing status before the next send closes that gap.
const status = ctx.pty.list(owner).find(session => session.sessionId === id)?.status
const status = ctx.terminals.list(owner).find(session => session.sessionId === id)?.status
if (status?.kind === 'exited') {
return await respondToSessionExit(
ctx, shells, owner, id, status, marker, fallback, fallbackTruncated, config,
@@ -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/shell/tool-pwsh-persistent/README.md
README.md: 7bb66477ab7ffe52039b0c699d48c9ca761ac04c
README.zh.md: b20041b1d42908d4d1e893455d825eafa2e2f87d
@@ -0,0 +1,55 @@
# @deepseek-ai/dsh-tool-pwsh-persistent
English | [中文](README.zh.md)
Model-facing `pwsh(command)` backed by one owner-scoped `ctx.terminals` shell. The package owns the tool contract and shell reuse; deployments select the terminal backend (a `terminal-bash` instance configured with `shellDialect: pwsh`) and sandbox policy. It is the Windows counterpart of `tool-bash-persistent`: same persistent-state contract, PowerShell dialect.
## Config
| Key | Default | Meaning |
|---|---:|---|
| `backendType` | `shell` | Registered terminal backend used for each Agent shell. |
| `timeoutMs` | `300000` | Wall-clock limit for one command; timeout closes the shell. |
| `maxOutputChars` | `16000` | Maximum retained command-output characters; fixed diagnostics are added afterward. |
| `description` | Persistent-shell description | Model-facing environment contract. |
## Model Experience
### Tool schema
#### What the model sees
The generated [`pwsh` schema](../../../docs/tool-catalog.md#deepseek-aidsh-tool-pwsh-persistent), including the configured `description`. The plugin contributes no standalone system-prompt section; the deployment owns persona and environment guidance.
#### Token effect
Fixed schema cost while `pwsh` is visible.
#### KV Cache effect
Prefix-stable while the configured description and schema remain unchanged.
### Tool results
#### What the model sees
Commands share one shell per Agent, so cwd, `$env:` variables, functions, and background jobs persist across calls. Results exclude private completion markers, the shell prompt, and the echoed input line (PSReadLine renders submitted input back into the stream; the marker-anchored extraction and the wrapper-source strip remove it). A nonzero wrapped command appends `[exit code: N]` — the exact native exit code when the command ran a native program, `1` for a terminating PowerShell error. A shell that exits before reporting that status instead appends `[shell exited: code N]`, `[shell killed by signal: SIG]`, or `[shell exited]` when the backend supplies neither (Windows forced termination reports exit 1 without a signal), then resets and tells the model that the next call starts fresh. Long output keeps the earliest retained prefix plus a clipping notice; if the terminal has already dropped that prefix, the result says so explicitly. Timeout returns bounded partial output, closes the uncertain shell, and reports the reset.
#### Token effect
Data-dependent. `maxOutputChars` bounds retained command output; fixed clipping, lost-prefix, status, timeout, and reset diagnostics can extend the result.
#### KV Cache effect
Append-only tool results follow the reusable request prefix.
## Known Limitations and Deferred Work
- The tool requires an owning Agent and a real terminal backend with a pwsh dialect (Windows ConPTY or a POSIX pwsh).
- **Input echo is unavoidable**: PowerShell's PSReadLine renders submitted input back into the terminal stream, and there is no `stty -echo` equivalent. The marker-anchored extraction excludes the echo in complete results; the wrapper-source strip covers fallback paths, but a wrapper that wraps across the terminal width may leave a partial echo in partial-output results, bounded by `maxOutputChars`.
- Raw ESC characters inside model commands are unsupported: PSReadLine consumes them before execution. The wrapper escapes the control bytes it needs (`[char]27`-built OSC markers, backtick escapes for the body).
- A model redefinition of the `prompt` function removes the readiness marker; the shell then settles on the silence tier instead of the marker fast path.
- There is no interactive stdin during a command: a foreground command that reads input blocks until the readiness timeout, which resets the shell.
- SIGTSTP/SIGHUP are unavailable on Windows (backend-rejected); SIGINT is delivered as a console-wide Ctrl-C input write, which at a prompt cancels the pending line instead of signalling a process.
- Under the Windows ACL sandbox's read-only mode, pwsh starts in ConstrainedLanguage, which may deny the prompt function's `[Console]::` call; the backend's documented `Write-Host -NoNewline` fallback is selected by the Windows-native lane evidence.
- The BEL-terminated OSC marker remains a readiness signal only; a BEL event channel to the model stays deferred, aligned with the current implementation.
@@ -0,0 +1,55 @@
# @deepseek-ai/dsh-tool-pwsh-persistent
[English](README.md) | 中文
模型侧 `pwsh(command)`,由一个 owner 作用域的 `ctx.terminals` shell 支撑。本包拥有工具契约与 shell 复用;部署方选择 terminal backend(配置 `shellDialect: pwsh``terminal-bash` 实例)与沙箱策略。它是 `tool-bash-persistent` 的 Windows 对应物:相同的持久状态契约,PowerShell 方言。
## 配置
| 键 | 默认值 | 含义 |
|---|---:|---|
| `backendType` | `shell` | 每个 Agent shell 使用的已注册 terminal backend。 |
| `timeoutMs` | `300000` | 单条命令的墙钟上限;超时关闭 shell。 |
| `maxOutputChars` | `16000` | 保留的命令输出字符上限;固定诊断文本在其后追加。 |
| `description` | 持久 shell 描述 | 模型可见的环境契约。 |
## 模型体验
### 工具 schema
#### 模型看到什么
生成的 [`pwsh` schema](../../../docs/tool-catalog.md#deepseek-aidsh-tool-pwsh-persistent),含配置的 `description`。本插件不贡献独立的 system-prompt 段落;persona 与环境指引由部署方负责。
#### Token 影响
`pwsh` 可见期间每个请求有固定的 schema 成本。
#### KV Cache 影响
配置的 description 与 schema 不变时前缀稳定。
### 工具结果
#### 模型看到什么
命令共享每个 Agent 的一个 shell,因此 cwd、`$env:` 变量、函数和后台任务跨调用保留。结果排除私有完成标记、shell 提示符与回显的输入行(PSReadLine 会把提交的输入渲染回输出流;marker 锚定提取与包装器原文剥离将其移除)。非零包装命令追加 `[exit code: N]` —— 命令运行原生程序时是精确的原生退出码,PowerShell 终止性错误为 `1`。shell 在报告状态前退出的,改为追加 `[shell exited: code N]``[shell killed by signal: SIG]``[shell exited]`backend 两者都没有时;Windows 强杀按无 signal 的 exit 1 报告),然后重置并告知模型下一次调用从全新 shell 开始。长输出保留最早的前缀并附裁剪提示;若 PTY 已丢弃该前缀,结果会明确说明。超时返回有界的部分输出、关闭不确定的 shell 并报告重置。
#### Token 影响
数据相关。`maxOutputChars` 限制保留的命令输出;固定裁剪、前缀丢失、状态、超时与重置诊断可能扩展结果。
#### KV Cache 影响
追加式工具结果跟随可复用的请求前缀。
## 已知限制与延后工作
- 工具需要拥有 Agent 与一个真实支持 pwsh 方言的 terminal backendWindows ConPTY 或 POSIX 上的 pwsh)。
- **输入回显不可避免**PowerShell 的 PSReadLine 会把提交的输入渲染回终端流,且没有 `stty -echo` 的对应物。完整结果中 marker 锚定提取排除回显;包装器原文剥离覆盖回退路径,但跨越终端宽度的包装器折行可能在部分输出结果中残留片段回显,受 `maxOutputChars` 约束。
- 模型命令中的裸 ESC 字符不受支持:PSReadLine 会在执行前吞掉它们。包装器转义它需要的控制字节(`[char]27` 构造的 OSC 标记、body 的反引号转义)。
- 模型重定义 `prompt` 函数会移除就绪标记;shell 随后退化为静默档而非 marker 快路径。
- 命令执行期间没有交互 stdin:读取输入的前台命令会阻塞到就绪超时,随后重置 shell。
- SIGTSTP/SIGHUP 在 Windows 不可用(backend 拒绝);SIGINT 以控制台级 Ctrl-C 输入写入投递,在提示符处取消当前行而非向进程发信号。
- 在 Windows ACL 沙箱的只读模式下,pwsh 以 ConstrainedLanguage 启动,可能拒绝 prompt 函数的 `[Console]::` 调用;backend 文档化的 `Write-Host -NoNewline` 回退由 Windows-native 车道证据裁决。
- BEL 终结的 OSC 标记仍只是就绪信号;面向模型的 BEL 事件通道保持延后,与当前实现对齐。
@@ -0,0 +1,62 @@
{
"name": "@deepseek-ai/dsh-tool-pwsh-persistent",
"description": "Model-facing owner-scoped persistent PowerShell tool backed by the Harness PTY service",
"version": "0.0.1-rc.1",
"publishConfig": {
"access": "restricted"
},
"repository": {
"type": "git",
"url": "git+https://github.com/deepseek-ai/deepseek-harness.git",
"directory": "packages/shell/tool-pwsh-persistent"
},
"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"
},
"./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-timeout": "workspace:^",
"@deepseek-ai/dsh-tools": "workspace:^",
"@deepseek-ai/cordis": "workspace:^"
},
"dependencies": {
"@deepseek-ai/schemastery": "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-pwsh-local": "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-timeout": "workspace:^",
"@deepseek-ai/dsh-tools": "workspace:^",
"@deepseek-ai/cordis": "workspace:^"
}
}
@@ -0,0 +1,510 @@
/**
* Model-facing persistent `pwsh` tool over the owner-scoped PTY seam.
* @module @deepseek-ai/dsh-tool-pwsh-persistent
*/
import { randomUUID } from 'node:crypto'
import type { Context } from '@deepseek-ai/cordis'
import z from '@deepseek-ai/schemastery'
import type { Agent } from '@deepseek-ai/dsh-agent'
import type { TerminalReadResult, TerminalSendResult, TerminalSessionId } from '@deepseek-ai/dsh-terminal'
import { deadline, timeoutOf } from '@deepseek-ai/dsh-timeout'
import { defineTool } from '@deepseek-ai/dsh-tools'
// TODO: Replace the file-search advice; arbitrary command output need not come from a searchable file.
const TRUNCATED_MESSAGE = '<response clipped><NOTE>To save on context only part of this file has been shown to you. You should retry this tool after you have searched inside the file with Select-String in order to find the line numbers of what you are looking for.</NOTE>'
const LOST_PREFIX_MESSAGE = '<response clipped><NOTE>The beginning of this command output was dropped by the terminal scrollback limit. The following text is the earliest retained output.</NOTE>\n'
const SHELL_RESET_MESSAGE = 'The persistent pwsh shell was reset; the next pwsh call starts from the workspace with a fresh current directory and environment.'
const SHELL_PROMPT = '__DSH_PERSISTENT_PWSH_PROMPT__ '
const TIMEOUT_CODE = 'PERSISTENT_PWSH_TIMEOUT'
// One page is enough to find a just-emitted completion marker; the full
// scrollback is assembled only when a command settles or needs partial output.
const SCROLLBACK_PAGE_LINES = 1_000
const POLL_INTERVAL_MS = 25
const DEFAULT_DESCRIPTION = 'Run commands in a persistent PowerShell shell. State, including the current directory and exported environment variables, persists across calls for this agent.'
interface ResolvedConfig {
backendType: string
timeoutMs: number
maxOutputChars: number
description: string
}
interface CommandMarkers {
start: string
end: string
}
interface RetainedOutput {
text: string
truncated: boolean
}
interface CapturedOutput {
text: string
incomplete: boolean
exitCode?: number
}
interface PersistentShells {
get(owner: Agent, signal: AbortSignal): Promise<TerminalSessionId>
reset(owner: Agent, reason: string): Promise<void>
}
function maybeTruncate(content: string, maxOutputChars: number, incomplete = false): string {
if (content.length <= maxOutputChars && !incomplete) return content
return content.length <= maxOutputChars
? content + TRUNCATED_MESSAGE
: content.slice(0, maxOutputChars) + TRUNCATED_MESSAGE
}
function markers(): CommandMarkers {
const nonce = randomUUID()
return {
start: `__DSH_PERSISTENT_PWSH_START_${nonce}__`,
end: `__DSH_PERSISTENT_PWSH_END_${nonce}:`,
}
}
/**
* Escape a command body for embedding in the wrapper's double-quoted string.
* Backtick escapes keep every character literal: backtick first so the
* escapes this function inserts are never re-escaped, `$` so no expansion
* happens at wrapper construction, and `\r\n`/ESC so multi-line commands and
* raw control bytes ride one physical input line without PSReadLine mangling.
* @param value - the model's PowerShell command text.
* @returns the escaped double-quoted-string body.
*/
function quoteForPwsh(value: string): string {
return value
.replaceAll('`', '``')
.replaceAll('"', '`"')
.replaceAll('$', '`$')
.replaceAll('\r', '')
.replaceAll('\n', '`n')
.replaceAll('\x1b', '`e')
}
function wrapCommand(command: string, marker: CommandMarkers): string {
// Keep the wrapper on one physical line: PSReadLine renders the echoed
// input, and a wrapped line would split the echo the extraction strips.
// The echoed END nonce can never fabricate completion because the status
// regex needs digits immediately after it and the echo continues with
// quote characters.
const body = quoteForPwsh(command)
return `Write-Output '${marker.start}'; $LASTEXITCODE = $null; $__s = 1; try { Invoke-Expression "${body}"; $__ok = $? } catch { $__ok = $false }; if ($null -ne $LASTEXITCODE) { $__s = [int]$LASTEXITCODE } else { $__s = if ($__ok) { 0 } else { 1 } }; Write-Output ('${marker.end}' + $__s)`
}
function stripPrompt(text: string): string {
let result = text.replace(/\r?\n$/, '')
while (result.endsWith(SHELL_PROMPT)) {
result = result.slice(0, -SHELL_PROMPT.length)
}
return result.endsWith('\n') ? result.slice(0, -1) : result
}
function commandOutput(
snapshot: RetainedOutput,
marker: CommandMarkers,
wrapper: string,
): CapturedOutput | undefined {
const text = snapshot.text
const end = text.lastIndexOf(marker.end)
const status = /^(\d+)\r?\n/.exec(text.slice(end + marker.end.length))?.[1]
if (status === undefined) return undefined
const startMarker = text.lastIndexOf(marker.start, end)
const start = startMarker < 0 ? 0 : startMarker + marker.start.length
let captured = text.slice(start, end)
// The PSReadLine echo carries the wrapper source (including both marker
// nonces) before the real markers; anchor on the real markers excludes it,
// and stripping the wrapper covers the rare case where the real START
// scrolled out and extraction fell back to the echoed copy.
captured = captured.replaceAll(wrapper, '')
return {
text: stripPrompt(captured.replace(/^\r?\n/, '')),
incomplete: startMarker < 0,
exitCode: Number(status),
}
}
function promptCompleted(result: TerminalSendResult): boolean {
return result.viewport.endsWith(SHELL_PROMPT)
|| result.viewport.endsWith(`${SHELL_PROMPT}\r\n`)
|| result.viewport.endsWith(`${SHELL_PROMPT}\n`)
}
function partialOutput(
snapshot: RetainedOutput,
marker: CommandMarkers,
wrapper: string,
fallback: string,
fallbackTruncated = false,
): CapturedOutput {
const startMarker = snapshot.text.lastIndexOf(marker.start)
if (startMarker >= 0) {
return {
text: stripPrompt(snapshot.text.slice(startMarker + marker.start.length).replace(/^\r?\n/, '')),
incomplete: false,
}
}
const fallbackStart = fallback.lastIndexOf(marker.start)
const afterStart = fallbackStart < 0
? fallback
: fallback.slice(fallbackStart + marker.start.length).replace(/^\r?\n/, '')
const fallbackEnd = afterStart.lastIndexOf(marker.end)
const beforeEnd = fallbackEnd < 0 ? afterStart : afterStart.slice(0, fallbackEnd)
return {
text: stripPrompt(beforeEnd.replaceAll(SHELL_PROMPT, '').replaceAll(wrapper, '')),
incomplete: fallbackTruncated || fallbackStart < 0,
}
}
async function pause(): Promise<void> {
await new Promise(resolve => setTimeout(resolve, POLL_INTERVAL_MS))
}
function nextScrollbackOffset(page: TerminalReadResult, offset: number): number | undefined {
if (page.text.length === 0 || page.lineEnd <= offset) return undefined
return page.lineEnd
}
function retainedScrollback(
ctx: Context,
owner: Agent,
id: TerminalSessionId,
latest = ctx.terminals.read(owner, id, { offset: 0, count: SCROLLBACK_PAGE_LINES }),
): RetainedOutput {
const pages: string[] = latest.text.length === 0 ? [] : [latest.text]
let offset = latest.lineEnd
let truncated = latest.truncated
while (true) {
if (offset >= latest.totalLines) break
const page = ctx.terminals.read(owner, id, { offset, count: SCROLLBACK_PAGE_LINES })
truncated ||= page.truncated
if (page.text.length > 0) pages.unshift(page.text)
const next = nextScrollbackOffset(page, offset)
if (next === undefined || next >= page.totalLines) break
offset = next
}
return { text: pages.join('\n'), truncated }
}
function renderCaptured(output: CapturedOutput, maxOutputChars: number): string {
const rendered = maybeTruncate(output.text, maxOutputChars, output.incomplete)
const withPrefix = output.incomplete && output.text.length > 0
? LOST_PREFIX_MESSAGE + rendered
: rendered
const marker = output.exitCode !== undefined && output.exitCode !== 0
? `[exit code: ${output.exitCode}]`
: undefined
return appendStatusMarker(withPrefix, marker)
}
function appendStatusMarker(content: string, marker: string | undefined): string {
if (marker === undefined) return content
return content.length === 0 ? marker : `${content}\n${marker}`
}
function renderShellExitStatus(
content: string,
exitCode: number | null,
signal: NodeJS.Signals | null,
): string {
const marker = signal !== null
? `[shell killed by signal: ${signal}]`
: exitCode !== null
? `[shell exited: code ${exitCode}]`
: '[shell exited]'
return appendStatusMarker(content, marker)
}
/**
* Render the exited-session result, reset the owner's shell, and reset the
* message that tells the model the next call starts fresh.
* @param shells - the owner-scoped registry to reset.
* @param status - the exited session status (exit code and signal).
* @returns the complete model-facing result.
*/
async function respondToSessionExit(
ctx: Context,
shells: PersistentShells,
owner: Agent,
id: TerminalSessionId,
status: { exitCode: number | null; signal: NodeJS.Signals | null },
marker: CommandMarkers,
wrapped: string,
fallback: string,
fallbackTruncated: boolean,
config: ResolvedConfig,
): Promise<string> {
const snapshot = retainedScrollback(ctx, owner, id)
await shells.reset(owner, 'persistent pwsh shell exited')
return [
renderShellExitStatus(
renderCaptured(partialOutput(snapshot, marker, wrapped, fallback, fallbackTruncated), config.maxOutputChars),
status.exitCode,
status.signal,
),
SHELL_RESET_MESSAGE,
].filter(part => part.length > 0).join('\n')
}
/**
* The pwsh prompt function that overrides the backend bootstrap value with
* this tool's own prompt. `[char]27`/`[char]7` build the OSC bytes at runtime
* because raw ESC characters in submitted input are unreliable under
* PSReadLine.
*/
const PWSH_PROMPT_SETUP =
"function prompt { [Console]::Write([char]27 + ']133;D;' + [int]$LASTEXITCODE + [char]7); '" + SHELL_PROMPT + "' }"
function persistentShells(ctx: Context, config: ResolvedConfig): PersistentShells {
const pending = new WeakMap<Agent, Promise<TerminalSessionId>>()
const live = new Map<Agent, TerminalSessionId>()
const creating = new Set<Promise<TerminalSessionId>>()
const ownerCleanupInstalled = new WeakSet<Agent>()
const lifecycle = new AbortController()
const close = async (owner: Agent, id: TerminalSessionId, reason: string): Promise<void> => {
if (!ctx.terminals.list(owner).some(snapshot => snapshot.sessionId === id)) return
await ctx.terminals.kill(owner, id, reason)
}
ctx.effect(() => async () => {
lifecycle.abort(new Error('tool-pwsh-persistent disposed during shell creation'))
await Promise.allSettled([...creating])
const closing = [...live].map(async ([owner, id]) => { await close(owner, id, 'tool-pwsh-persistent disposed') })
await Promise.all(closing)
live.clear()
}, 'tool-pwsh-persistent shell cleanup')
const reset = async (owner: Agent, reason: string): Promise<void> => {
pending.delete(owner)
const id = live.get(owner)
live.delete(owner)
if (id !== undefined) await close(owner, id, reason)
}
const get = (owner: Agent, signal: AbortSignal): Promise<TerminalSessionId> => {
const existing = pending.get(owner)
if (existing !== undefined) return existing
const combinedSignal = AbortSignal.any([signal, lifecycle.signal])
const creation = (async () => {
try {
const cwd = owner.session.header.cwd
const spawned = await ctx.terminals.spawn(owner, {
type: config.backendType,
...cwd === undefined ? {} : { cwd },
}, combinedSignal)
live.set(owner, spawned.sessionId)
if (!ownerCleanupInstalled.has(owner)) {
ownerCleanupInstalled.add(owner)
owner.ctx.effect(() => () => {
pending.delete(owner)
live.delete(owner)
}, 'tool-pwsh-persistent owner cache cleanup')
}
const setup = ctx.terminals.startSend(owner, spawned.sessionId, {
text: PWSH_PROMPT_SETUP,
submit: true,
signal: combinedSignal,
})
const result = await setup.done
if (result.sessionStatus.kind === 'exited' || result.waitReason === 'timeout') {
throw new Error('persistent pwsh shell did not accept initialization')
}
return spawned.sessionId
} catch (error: unknown) {
await reset(owner, 'persistent pwsh initialization failed')
throw error
}
})()
const tracked = creation.finally(() => {
creating.delete(tracked)
})
creating.add(tracked)
pending.set(owner, tracked)
return tracked
}
return { get, reset }
}
async function executeCommand(
ctx: Context,
shells: PersistentShells,
owner: Agent,
command: string,
config: ResolvedConfig,
upstream: AbortSignal,
): Promise<string> {
using commandDeadline = deadline(upstream, config.timeoutMs, TIMEOUT_CODE)
const id = await shells.get(owner, commandDeadline.signal)
const marker = markers()
const wrapped = wrapCommand(command, marker)
let first = true
let fallback = ''
let fallbackTruncated = false
while (true) {
// The shell may flip to exited between iterations (a fast `exit` can
// settle the previous send while its exit event is still in flight, and
// the echoed wrapper can then carry a marker end without status digits);
// re-observing status before the next send closes that gap.
const status = ctx.terminals.list(owner).find(session => session.sessionId === id)?.status
if (status?.kind === 'exited') {
return await respondToSessionExit(
ctx, shells, owner, id, status, marker, wrapped, fallback, fallbackTruncated, config,
)
}
let operation
let result
try {
operation = ctx.terminals.startSend(owner, id, {
text: first ? wrapped : '',
submit: first,
signal: commandDeadline.signal,
})
first = false
result = await operation.done
} catch (error: unknown) {
await shells.reset(owner, 'persistent pwsh send failed')
throw error
}
const incremental = operation.readOutput()
fallback = incremental.delta.length > 0 ? fallback + incremental.delta : result.viewport
fallbackTruncated ||= incremental.truncated || result.truncated
const latest = ctx.terminals.read(owner, id, { offset: 0, count: SCROLLBACK_PAGE_LINES })
const timedOut = timeoutOf(commandDeadline.signal, TIMEOUT_CODE)
if (timedOut !== undefined) {
const snapshot = retainedScrollback(ctx, owner, id, latest)
const partial = renderCaptured(
partialOutput(snapshot, marker, wrapped, fallback, fallbackTruncated),
config.maxOutputChars,
)
await shells.reset(owner, 'persistent pwsh command timed out')
return [
// TODO: Report a timeout only; this signal does not establish an OOM.
`Your command timed out after ${Math.round(timedOut.timeoutMs / 1000)} seconds or experienced an OOM error. Below is partial output:`,
partial,
SHELL_RESET_MESSAGE,
].join('\n')
}
if (commandDeadline.signal.aborted) {
await shells.reset(owner, 'persistent pwsh command aborted')
commandDeadline.signal.throwIfAborted()
}
if (latest.text.includes(marker.end)) {
const complete = commandOutput(retainedScrollback(ctx, owner, id, latest), marker, wrapped)
if (complete !== undefined) return renderCaptured(complete, config.maxOutputChars)
}
if (result.sessionStatus.kind === 'exited') {
return await respondToSessionExit(
ctx, shells, owner, id, result.sessionStatus, marker, wrapped, fallback, fallbackTruncated, config,
)
}
if (promptCompleted(result)) {
const snapshot = retainedScrollback(ctx, owner, id, latest)
return renderCaptured(
partialOutput(snapshot, marker, wrapped, fallback, fallbackTruncated),
config.maxOutputChars,
)
}
await pause()
}
}
/**
* Register the model-facing persistent `pwsh` tool.
* @param ctx - plugin context carrying tools and the owner-scoped PTY service.
* @param config - selected PTY backend and command deadline.
*/
function registerPersistentPwsh(ctx: Context, config: ResolvedConfig): void {
const shells = persistentShells(ctx, config)
const queues = new WeakMap<Agent, Promise<void>>()
const serialized = async <T>(owner: Agent, operation: () => Promise<T>): Promise<T> => {
const prior = queues.get(owner) ?? Promise.resolve()
const run = prior.then(operation, operation)
const tail = run.then(() => undefined, () => undefined)
queues.set(owner, tail)
try {
return await run
} finally {
if (queues.get(owner) === tail) queues.delete(owner)
}
}
ctx.tools.register(defineTool({
name: 'pwsh',
description: config.description,
parameters: {
command: {
type: 'string',
required: true,
description: 'The PowerShell command to run. Relative path is preferred in the command.',
},
},
output: {
schema: { type: 'string' },
render: (_args, value) => [{ type: 'text', text: value }],
},
async execute(args, exec) {
if (args.command.trim().length === 0) throw new Error('command must be a non-empty string')
const owner = exec.agent
if (owner === undefined) throw new Error('pwsh requires an owning agent session')
return serialized(owner, async () => {
exec.signal.throwIfAborted()
return executeCommand(ctx, shells, owner, args.command, config, exec.signal)
})
},
presentCall: args => ({ card: 'terminal', title: args.command }),
}))
}
export const name = 'tool-pwsh-persistent'
export const inject = ['tools', 'terminals']
/** Configuration for the persistent pwsh tool. */
export interface Config {
/** PTY backend used for each owner-isolated persistent shell (default `shell`). */
backendType?: string
/** Wall-clock limit for one command (default 300000). */
timeoutMs?: number
/** Maximum returned command-output characters before clipping (default 16000). */
maxOutputChars?: number
/** Model-facing tool description; deployments may describe their environment. */
description?: string
}
/** Runtime configuration schema for the persistent pwsh tool. */
export const Config: z<Config> = z.object({
backendType: z.string().default('shell'),
timeoutMs: z.number().default(300_000),
maxOutputChars: z.number().default(16_000),
description: z.string().default(DEFAULT_DESCRIPTION),
})
/** Register one owner-scoped persistent `pwsh` tool. */
export function apply(ctx: Context, config: Config): void {
const resolved: ResolvedConfig = {
backendType: config.backendType ?? 'shell',
timeoutMs: config.timeoutMs ?? 300_000,
maxOutputChars: config.maxOutputChars ?? 16_000,
description: config.description ?? DEFAULT_DESCRIPTION,
}
if (resolved.backendType.trim().length === 0) {
throw new Error('tool-pwsh-persistent: backendType must be non-empty')
}
if (!Number.isSafeInteger(resolved.timeoutMs) || resolved.timeoutMs <= 0) {
throw new Error('tool-pwsh-persistent: timeoutMs must be a positive safe integer')
}
if (!Number.isSafeInteger(resolved.maxOutputChars) || resolved.maxOutputChars <= 0) {
throw new Error('tool-pwsh-persistent: maxOutputChars must be a positive safe integer')
}
if (resolved.description.trim().length === 0) {
throw new Error('tool-pwsh-persistent: description must be non-empty')
}
registerPersistentPwsh(ctx, resolved)
}
@@ -0,0 +1,31 @@
/**
* Package-owned invariant companion for `@deepseek-ai/dsh-tool-pwsh-persistent`.
* @module @deepseek-ai/dsh-tool-pwsh-persistent/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-pwsh-persistent'
/** Cordis companion plugin name. */
export const name = 'tool-pwsh-persistent-invariant'
/** Service required before the companion can reserve package ownership. */
export const inject = ['invariants']
/**
* No runtime invariant: the adapter's private owner-to-shell cache has no
* observable event or data relation. Lifecycle tests prove its cleanup without
* adding a public API solely for an invariant.
*/
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,167 @@
import { spawnSync } from 'node:child_process'
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 TerminalSessionService from '@deepseek-ai/dsh-terminal'
import * as TerminalBash from '@deepseek-ai/dsh-terminal-bash'
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 LocalSubprocessService from '@deepseek-ai/dsh-subprocess-local'
import { resolvePwshPath } from '@deepseek-ai/dsh-pwsh-local/src/resolve.ts'
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
import ToolRegistry from '@deepseek-ai/dsh-tools'
import * as ToolPwshPersistent from '@deepseek-ai/dsh-tool-pwsh-persistent'
const hasPwsh = spawnSync(
resolvePwshPath(), ['-NoLogo', '-NoProfile', '-NonInteractive', '-Command', '$true'],
{ encoding: 'utf8' },
).status === 0
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, cwd: string): Agent {
const id = SessionId('persistent-pwsh-loader-agent')
const scope = ctx.plugin(() => {})
const session = Session.create(id, [], { version: 0, id, createdAt: 0, cwd })
const value: Agent = {
id,
options: {},
session,
inbox: new Inbox(session, { inserted: () => {}, discarded: () => {}, claimed: () => {} }),
status: 'idle',
ctx: scope.ctx,
send: () => {},
followup: () => {},
steer: () => ({ outcome: Promise.resolve({ status: 'rejected' as const }) }),
inject: () => {},
cancel() {},
runMaintenance: task => task(new AbortController().signal),
whenIdle: () => Promise.resolve(),
}
ctx.agents.register(value)
return value
}
function text(result: { content: { type: string; text?: string }[] }): string {
return result.content.filter(block => block.type === 'text').map(block => block.text).join('')
}
describe.skipIf(!hasPwsh)('persistent pwsh through a real cordis.yml Loader composition', () => {
it('preserves cwd and environment across calls', async () => {
root = await mkdtemp(join(tmpdir(), 'dsh-persistent-pwsh-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:',
' shellDialect: pwsh',
' pollIntervalMs: 10',
' exactProbeAfterMs: 20',
' idleSilenceMs: 300',
' handoffGraceMs: 300',
' scrollbackLines: 20000',
' timeoutMs: 8000',
' disposeGraceMs: 500',
"- name: '@deepseek-ai/dsh-tool-pwsh-persistent'",
' config:',
' timeoutMs: 20000',
'',
].join('\n'))
context = new Context()
context.baseUrl = pathToFileURL(root).href + '/'
await context.plugin(Loader)
context.loader.builtins.include = Include
const modules = new Map<string, unknown>([
['@deepseek-ai/dsh-agent', AgentRegistry],
['@deepseek-ai/dsh-system-prompt', SystemPrompt],
['@deepseek-ai/dsh-tools', ToolRegistry],
['@deepseek-ai/dsh-terminal', TerminalSessionService],
['@deepseek-ai/dsh-test-sandbox', PassthroughSandbox],
['@deepseek-ai/dsh-sandbox-policy', SandboxPolicyService],
['@deepseek-ai/dsh-subprocess-local', LocalSubprocessService],
['@deepseek-ai/dsh-terminal-bash', TerminalBash],
['@deepseek-ai/dsh-tool-pwsh-persistent', ToolPwshPersistent],
])
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, root)
const signal = new AbortController().signal
const execute = (id: string, command: string) => context!.tools.execute({
signal,
callId: CallId(id),
name: 'pwsh',
arguments: { command },
agent: owner,
})
expect(context.tools.schemas().map(schema => schema.name)).toEqual(['pwsh'])
await execute('state', '$env:KEEP = "loader"; New-Item -ItemType Directory -Force -Path nested | Out-Null; Set-Location nested')
const observed = text(await execute('observe', 'Write-Output "cwd=$PWD keep=$env:KEEP"'))
expect(observed).toContain(`cwd=${join(root, 'nested')} keep=loader`)
expect(observed).not.toContain('DSH_PERSISTENT_PWSH')
const multiline = text(await execute(
'multiline',
'$value = "line one"\nWrite-Output "${value}:it\'s fine"',
))
expect(multiline).toBe("line one:it's fine")
expect(multiline).not.toContain('DSH_PERSISTENT_PWSH')
const hereString = text(await execute(
'here-string',
"$h = @'\nalpha\nbeta\n'@\nWrite-Output $h",
))
expect(hereString).toBe('alpha\nbeta')
const large = text(await execute('large-output', '1..12050 | ForEach-Object { $_ }'))
expect(large.startsWith('1\n2\n3\n')).toBe(true)
expect(large).toContain('<response clipped>')
expect(large).not.toContain('beginning of this command output was dropped')
const exited = text(await execute('exit', 'exit'))
expect(exited).toContain('next pwsh call starts from the workspace')
expect(text(await execute('after-exit', 'Write-Output "$PWD"'))).toBe(root)
}, 60_000)
})
@@ -0,0 +1,624 @@
import { afterEach, 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 TerminalSessionService from '@deepseek-ai/dsh-terminal'
import type {
TerminalBackend,
TerminalBackendSession,
TerminalReadRequest,
TerminalSendOperation,
TerminalSendRequest,
TerminalSessionStatus,
TerminalSignal,
TerminalWaitReason,
} from '@deepseek-ai/dsh-terminal'
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
import ToolRegistry from '@deepseek-ai/dsh-tools'
import * as ToolPwshPersistent from '@deepseek-ai/dsh-tool-pwsh-persistent'
const contexts: Context[] = []
let callNumber = 0
afterEach(async () => {
for (const ctx of contexts.splice(0)) await ctx.fiber.dispose()
})
function agent(ctx: Context, cwd: string | undefined): Agent {
const id = SessionId(`persistent-pwsh-owner-${callNumber}`)
const scope = ctx.plugin(() => {})
const session = Session.create(id, [], {
version: 0,
id,
createdAt: 0,
...cwd === undefined ? {} : { cwd },
})
const value: Agent = {
id,
options: {},
session,
inbox: new Inbox(session, { inserted: () => {}, discarded: () => {}, claimed: () => {} }),
status: 'idle',
ctx: scope.ctx,
send: () => {},
followup: () => {},
steer: () => ({ outcome: Promise.resolve({ status: 'rejected' as const }) }),
inject: () => {},
cancel() {},
runMaintenance: task => task(new AbortController().signal),
whenIdle: () => Promise.resolve(),
}
ctx.agents.register(value)
return value
}
function text(result: { content: { type: string; text?: string }[] }): string {
return result.content.filter(block => block.type === 'text').map(block => block.text).join('')
}
function call(
ctx: Context,
owner: Agent | undefined,
command: string,
signal = new AbortController().signal,
) {
return ctx.tools.execute({
signal,
callId: CallId(`persistent-pwsh-${++callNumber}`),
name: 'pwsh',
arguments: { command },
...owner === undefined ? {} : { agent: owner },
})
}
type StubMode =
| 'normal'
| 'prompt-only'
| 'prompt-crlf'
| 'empty-read'
| 'stalled-read'
| 'exit'
| 'signal-exit'
| 'unknown-exit'
| 'wait-for-abort'
| 'end-on-abort'
| 'idle-then-normal'
| 'large'
| 'nonzero'
| 'torn-status'
| 'finish-torn-status'
| 'end-only'
| 'init-exit'
| 'init-timeout'
| 'spawn-error'
| 'send-error'
| 'prompt-after-idle'
| 'incremental-fallback'
| 'empty-page-after-latest'
| 'paged-scrollback'
| 'with-echo'
| 'exit-after-send'
const START_PATTERN = /__DSH_PERSISTENT_PWSH_START_[^_]+(?:-[^_]+)*__/
const END_PATTERN = /__DSH_PERSISTENT_PWSH_END_[^:]+:/
class StubTerminalSession implements TerminalBackendSession {
readonly motd = '__DSH_PERSISTENT_PWSH_PROMPT__ '
readonly pid = 123
statusValue: TerminalSessionStatus = { kind: 'running' }
scrollback = this.motd
closed: string[] = []
mode: StubMode
sends = 0
pendingText = ''
historyTruncated = false
throwOnSend = false
constructor(mode: StubMode) {
this.mode = mode
}
startSend(request: TerminalSendRequest): TerminalSendOperation {
this.sends += 1
if (request.text.startsWith('function prompt')) {
if (this.mode === 'init-exit') {
this.statusValue = { kind: 'exited', exitCode: 1, signal: null }
return this.operation(Promise.resolve(this.result('', 'session_exit')))
}
if (this.mode === 'init-timeout') {
return this.operation(Promise.resolve(this.result('', 'timeout')))
}
return this.operation(Promise.resolve(this.result(this.motd, 'stdin_read')))
}
if (this.mode === 'send-error') throw new Error('stub send failed')
if (this.throwOnSend) throw new Error('PTY session has exited')
if (this.mode === 'wait-for-abort' || this.mode === 'end-on-abort') {
const done = new Promise<ReturnType<StubTerminalSession['result']>>((resolve) => {
request.signal?.addEventListener('abort', () => {
const start = START_PATTERN.exec(request.text)?.[0]
const end = END_PATTERN.exec(request.text)?.[0]
const output = this.mode === 'end-on-abort'
? `${start ?? ''}\ninterrupted\n${end ?? ''}130\n${this.motd}`
: 'partial output'
this.scrollback += output
resolve(this.result(output, 'stdin_read'))
}, { once: true })
})
return this.operation(done)
}
if (this.mode === 'idle-then-normal') {
this.mode = 'normal'
this.pendingText = request.text
return this.operation(Promise.resolve(this.result('', 'inferred_idle')))
}
if (this.mode === 'prompt-after-idle') {
if (request.text.length > 0) {
const start = START_PATTERN.exec(request.text)?.[0]
const output = `${start ?? ''}\npartial syntax output\n`
this.scrollback += output
return this.operation(Promise.resolve(this.result(output, 'inferred_idle')))
}
const output = `pwsh: syntax error\n${this.motd}`
this.scrollback += output
return this.operation(Promise.resolve(this.result(output, 'stdin_read')))
}
if (this.mode === 'prompt-only' || this.mode === 'prompt-crlf') {
const newline = this.mode === 'prompt-crlf' ? '\r\n' : '\n'
const output = `pwsh: syntax error${newline}${this.motd}${newline}`
this.scrollback += output
return this.operation(Promise.resolve(this.result(output, 'stdin_read')))
}
const sent = request.text.length > 0 ? request.text : this.pendingText
this.pendingText = ''
const start = START_PATTERN.exec(sent)?.[0]
const end = END_PATTERN.exec(sent)?.[0]
if (this.mode === 'with-echo') {
// The PSReadLine echo renders the submitted wrapper before the real
// markers; the tool must strip it from the captured result.
const output = `${sent}\n${start ?? ''}\nhello from stub\n${end ?? ''}0\n${this.motd}`
this.scrollback += output
return this.operation(Promise.resolve(this.result(output, 'stdin_read')))
}
if (this.mode === 'exit-after-send') {
// A fast `exit` settles the send with an echoed wrapper (marker end,
// no status digits) while the exit event is still in flight; the shell
// flips to exited before the tool's next poll, exactly like the real
// ConPTY backend. The tool must re-observe status instead of sending.
const output = `${sent}\n${start ?? ''}\n`
this.scrollback += output
const settled = this.result(output, 'inferred_idle')
this.statusValue = { kind: 'exited', exitCode: 9, signal: null }
this.throwOnSend = true
return this.operation(Promise.resolve(settled))
}
if (this.mode === 'incremental-fallback') {
const incremental = `${start ?? ''}\nincrement\n${this.motd}`
return this.operation(Promise.resolve(this.result(this.motd, 'stdin_read')), incremental)
}
if (this.mode === 'torn-status') {
const output = `${start ?? ''}\nhello from stub\n${end ?? ''}`
this.scrollback += output
this.mode = 'finish-torn-status'
return this.operation(Promise.resolve(this.result(output, 'inferred_idle')))
}
if (this.mode === 'finish-torn-status') {
const output = `7\n${this.motd}`
this.scrollback += output
return this.operation(Promise.resolve(this.result(output, 'stdin_read')))
}
if (this.mode === 'end-only') {
const output = `recovered output\n${end ?? ''}0\n${this.motd}`
this.scrollback += output
return this.operation(Promise.resolve(this.result(output, 'stdin_read')))
}
const commandOutput = this.mode === 'large'
? 'x'.repeat(100)
: this.mode === 'nonzero' ? '' : 'hello from stub'
const exitCode = this.mode === 'nonzero' ? 7 : 0
const output = `${start ?? ''}\n${commandOutput}\n${end ?? ''}${exitCode}\n${this.motd}`
this.scrollback += output
if (this.mode === 'exit' || this.mode === 'signal-exit' || this.mode === 'unknown-exit') {
const exitedOutput = `${start ?? ''}\nhello from stub\n`
this.scrollback = this.scrollback.slice(0, -output.length) + exitedOutput
this.statusValue = this.mode === 'signal-exit'
? { kind: 'exited', exitCode: null, signal: 'SIGTERM' }
: this.mode === 'exit'
? { kind: 'exited', exitCode: 9, signal: null }
: { kind: 'exited', exitCode: null, signal: null }
return this.operation(Promise.resolve(this.result(exitedOutput, 'session_exit')))
}
return this.operation(Promise.resolve(this.result(output, 'stdin_read')))
}
read(request: TerminalReadRequest) {
if (this.mode === 'empty-read') {
return { text: '', totalLines: 0, lineBegin: 0, lineEnd: 0, truncated: false }
}
if (this.mode === 'stalled-read') {
return { text: 'stalled', totalLines: 1, lineBegin: 0, lineEnd: 0, truncated: false }
}
if (this.mode === 'empty-page-after-latest' && (request.offset ?? 0) > 0) {
return { text: '', totalLines: 2, lineBegin: 1, lineEnd: 1, truncated: false }
}
const lines = this.scrollback.split('\n')
if (this.mode === 'paged-scrollback') {
const offset = request.offset ?? 0
const end = lines.length - offset
const start = Math.max(0, end - 3)
const returnedLines = end - start
return {
text: lines.slice(start, end).join('\n'),
totalLines: lines.length,
lineBegin: offset,
lineEnd: offset + returnedLines,
truncated: this.historyTruncated,
}
}
return {
text: this.scrollback,
totalLines: this.mode === 'empty-page-after-latest' ? lines.length + 1 : lines.length,
lineBegin: 0,
lineEnd: this.mode === 'empty-page-after-latest' ? 1 : lines.length,
truncated: this.historyTruncated,
}
}
signal(_signal: TerminalSignal) {
return Promise.resolve({ delivered: true as const, targetPgid: 123 })
}
status() {
return this.statusValue
}
async close(reason: string) {
this.closed.push(reason)
this.statusValue = { kind: 'exited', exitCode: 0, signal: null }
}
private result(viewport: string, waitReason: TerminalWaitReason) {
return { viewport, waitReason, sessionStatus: this.statusValue, truncated: false }
}
private operation(done: Promise<ReturnType<StubTerminalSession['result']>>, delta = ''): TerminalSendOperation {
return {
done,
readOutput: () => ({ delta, truncated: false }),
cancel: () => false,
}
}
}
function stubBackend(initialMode: StubMode = 'normal') {
const sessions: StubTerminalSession[] = []
const backend: TerminalBackend = {
type: 'stub',
async spawn() {
if (initialMode === 'spawn-error') throw new Error('stub spawn failed')
const session = new StubTerminalSession(initialMode)
sessions.push(session)
return session
},
}
return { backend, sessions }
}
async function setup(
config: ToolPwshPersistent.Config = { backendType: 'stub' },
initialMode: StubMode = 'normal',
) {
const ctx = new Context()
contexts.push(ctx)
await ctx.plugin(SystemPrompt)
await ctx.plugin(ToolRegistry)
await ctx.plugin(AgentRegistry)
await ctx.plugin(TerminalSessionService)
const stub = stubBackend(initialMode)
ctx.terminals.registerBackend(stub.backend)
const fiber = await ctx.plugin(ToolPwshPersistent, config)
return { ctx, stub, fiber, owner: agent(ctx, '/workspace') }
}
describe('tool-pwsh-persistent', () => {
it('registers a configurable schema and reuses one owner shell', async () => {
const { ctx, owner, stub, fiber } = await setup({
backendType: 'stub',
description: 'deployment-specific persistent shell',
})
const schema = ctx.tools.schemas()[0]
expect(ctx.tools.schemas().map(item => item.name)).toEqual(['pwsh'])
expect(schema?.description).toBe('deployment-specific persistent shell')
expect(schema?.parameters).toMatchObject({
required: ['command'],
properties: { command: { type: 'string' } },
})
expect(ctx.tools.get('pwsh')?.presentCall?.({ command: 'pwd' }))
.toEqual({ card: 'terminal', title: 'pwd' })
expect(text(await call(ctx, owner, 'Write-Output one'))).toBe('hello from stub')
expect(text(await call(ctx, owner, 'Write-Output two'))).toBe('hello from stub')
expect(stub.sessions).toHaveLength(1)
expect(stub.sessions[0]?.sends).toBe(3)
const ownerWithoutCwd = agent(ctx, undefined)
expect(text(await call(ctx, ownerWithoutCwd, 'pwd'))).toBe('hello from stub')
expect(stub.sessions).toHaveLength(2)
await fiber.dispose()
expect(ctx.tools.schemas()).toEqual([])
expect(ctx.tools.get('pwsh')).toBeUndefined()
})
it('strips the echoed wrapper from captured output', async () => {
const { ctx, owner, stub } = await setup({ backendType: 'stub' })
await call(ctx, owner, 'warm up')
stub.sessions[0]!.mode = 'with-echo'
const result = text(await call(ctx, owner, 'Write-Output hi'))
expect(result).toBe('hello from stub')
expect(result).not.toContain('__DSH_PERSISTENT_PWSH_START_')
expect(result).not.toContain('__DSH_PERSISTENT_PWSH_END_')
expect(result).not.toContain('Invoke-Expression')
})
it('reports the exit path when the shell exits between send settlement and the next poll', async () => {
const { ctx, owner, stub } = await setup({ backendType: 'stub' })
await call(ctx, owner, 'warm up')
const session = stub.sessions[0]!
session.mode = 'exit-after-send'
const result = text(await call(ctx, owner, 'exit'))
expect(result).toContain('[shell exited: code 9]')
expect(result).toContain('next pwsh call starts from the workspace')
expect(session.closed).toContain('persistent pwsh shell exited')
expect(text(await call(ctx, owner, 'Write-Output "$PWD"'))).toBe('hello from stub')
expect(stub.sessions).toHaveLength(2)
})
it('handles inferred idle, prompt fallback, shell exit, clipping, and cleanup', async () => {
const { ctx, owner, stub, fiber } = await setup({
backendType: 'stub',
maxOutputChars: 10,
})
await call(ctx, owner, 'warm up')
const session = stub.sessions[0]!
session.mode = 'idle-then-normal'
expect(text(await call(ctx, owner, 'silent then complete'))).toContain('hello from')
session.mode = 'incremental-fallback'
session.scrollback = ''
expect(text(await call(ctx, owner, 'incremental fallback'))).toBe('increment')
session.mode = 'prompt-only'
const promptFallback = text(await call(ctx, owner, 'bad {'))
expect(promptFallback).toContain('pwsh: synt')
expect(promptFallback).not.toContain('DSH_PERSISTENT_PWSH_PROMPT')
session.mode = 'prompt-crlf'
session.scrollback = ''
const crlfPromptFallback = text(await call(ctx, owner, 'bad {'))
expect(crlfPromptFallback).toContain('pwsh: synt')
expect(crlfPromptFallback).not.toContain('DSH_PERSISTENT_PWSH_PROMPT')
session.mode = 'end-only'
session.scrollback = ''
const missingStart = text(await call(ctx, owner, 'recover marker'))
expect(missingStart).toContain('recovered')
expect(missingStart).toContain('beginning of this command output was dropped')
expect(missingStart).toContain('<response clipped>')
session.mode = 'large'
expect(text(await call(ctx, owner, 'large'))).toContain('<response clipped>')
session.mode = 'nonzero'
expect(text(await call(ctx, owner, 'false'))).toBe('[exit code: 7]')
session.mode = 'exit'
const exited = text(await call(ctx, owner, 'exit'))
expect(exited).toContain('hello from')
expect(exited).toContain('[shell exited: code 9]')
expect(exited).not.toContain('[exit code: 9]')
expect(exited).toContain('next pwsh call starts from the workspace')
expect(session.closed).toContain('persistent pwsh shell exited')
await call(ctx, owner, 'new shell')
expect(stub.sessions).toHaveLength(2)
const replacement = stub.sessions[1]!
replacement.mode = 'signal-exit'
expect(text(await call(ctx, owner, 'kill shell')))
.toContain('[shell killed by signal: SIGTERM]')
await call(ctx, owner, 'another shell')
expect(stub.sessions).toHaveLength(3)
const externallyClosed = ctx.terminals.list(owner)[0]?.sessionId
expect(externallyClosed).toBeDefined()
await ctx.terminals.kill(owner, externallyClosed!, 'external cleanup')
await fiber.dispose()
expect(stub.sessions[2]?.closed).toEqual(['external cleanup'])
})
it('waits for status digits after a torn completion marker', async () => {
const { ctx, owner, stub } = await setup({ backendType: 'stub', maxOutputChars: 1_000 })
await call(ctx, owner, 'warm up')
stub.sessions[0]!.mode = 'torn-status'
stub.sessions[0]!.scrollback = ''
expect(text(await call(ctx, owner, 'torn status'))).toBe('hello from stub\n[exit code: 7]')
})
it('reports a shell exit when the backend has no code or signal', async () => {
const { ctx, owner, stub } = await setup({ backendType: 'stub' })
await call(ctx, owner, 'warm up')
stub.sessions[0]!.mode = 'unknown-exit'
expect(text(await call(ctx, owner, 'exit without status'))).toContain('[shell exited]')
})
it('marks a short missing-prefix result and tolerates exhausted scrollback pages', async () => {
const { ctx, owner, stub } = await setup({ backendType: 'stub', maxOutputChars: 1_000 })
await call(ctx, owner, 'warm up')
const session = stub.sessions[0]!
session.mode = 'end-only'
session.scrollback = ''
expect(text(await call(ctx, owner, 'missing start')))
.toContain('beginning of this command output was dropped')
session.mode = 'empty-read'
expect(text(await call(ctx, owner, 'empty page'))).toContain('hello from stub')
session.mode = 'stalled-read'
expect(text(await call(ctx, owner, 'stalled page'))).toContain('hello from stub')
session.mode = 'empty-page-after-latest'
expect(text(await call(ctx, owner, 'empty continuation page'))).toContain('hello from stub')
})
it('assembles retained output across backward scrollback pages', async () => {
const { ctx, owner, stub } = await setup({ backendType: 'stub', maxOutputChars: 1_000 })
await call(ctx, owner, 'warm up')
const session = stub.sessions[0]!
session.mode = 'paged-scrollback'
session.scrollback = 'older one\nolder two\nolder three\nolder four\n'
expect(text(await call(ctx, owner, 'paged output'))).toBe('hello from stub')
})
it('sanitizes a prompt fallback reached after multiple polling rounds', async () => {
const { ctx, owner, stub } = await setup({ backendType: 'stub', maxOutputChars: 1_000 })
await call(ctx, owner, 'warm up')
const session = stub.sessions[0]!
session.mode = 'prompt-after-idle'
session.scrollback = ''
const result = text(await call(ctx, owner, 'bad {'))
expect(result).toContain('partial syntax output')
expect(result).toContain('pwsh: syntax error')
expect(result).not.toContain('DSH_PERSISTENT_PWSH_PROMPT')
expect(result).not.toContain('DSH_PERSISTENT_PWSH_START')
})
it('does not attribute old scrollback truncation to a complete current command', async () => {
const { ctx, owner, stub } = await setup({ backendType: 'stub', maxOutputChars: 1_000 })
await call(ctx, owner, 'warm up')
stub.sessions[0]!.historyTruncated = true
const result = text(await call(ctx, owner, 'short command'))
expect(result).toBe('hello from stub')
expect(result).not.toContain('<response clipped>')
expect(result).not.toContain('beginning of this command output was dropped')
})
it('closes a timed-out shell and reports bounded partial output', async () => {
const { ctx, owner, stub } = await setup({ backendType: 'stub', timeoutMs: 10 })
await call(ctx, owner, 'warm up')
stub.sessions[0]!.mode = 'wait-for-abort'
const result = await call(ctx, owner, 'hang')
expect(text(result)).toContain('timed out after 0 seconds or experienced an OOM error')
expect(text(result)).toContain('partial output')
expect(text(result)).toContain('next pwsh call starts from the workspace')
expect(stub.sessions[0]?.closed).toContain('persistent pwsh command timed out')
})
it.each(['wait-for-abort', 'end-on-abort'] as const)(
'cancels %s work, resets the shell, and releases a queued call',
async (mode) => {
const { ctx, owner, stub } = await setup({ backendType: 'stub', timeoutMs: 5_000 })
await call(ctx, owner, 'warm up')
stub.sessions[0]!.mode = mode
const controller = new AbortController()
const cancelled = call(ctx, owner, 'hang', controller.signal)
const queued = call(ctx, owner, 'after cancellation')
setTimeout(() => {
controller.abort(new Error('caller stopped'))
}, 5)
expect((await cancelled).isError).toBe(true)
expect(text(await queued)).toBe('hello from stub')
expect(stub.sessions[0]?.closed).toContain('persistent pwsh command aborted')
expect(stub.sessions).toHaveLength(2)
},
)
it.each(['init-exit', 'init-timeout'] as const)(
'fails initialization and closes the unusable shell for %s',
async (mode) => {
const { ctx, owner, stub } = await setup({ backendType: 'stub' }, mode)
expect((await call(ctx, owner, 'pwd')).isError).toBe(true)
expect(stub.sessions[0]?.closed).toContain('persistent pwsh initialization failed')
},
)
it('clears a failed spawn without trying to close an unpublished shell', async () => {
const { ctx, owner, stub } = await setup({ backendType: 'stub' }, 'spawn-error')
expect((await call(ctx, owner, 'pwd')).isError).toBe(true)
expect(stub.sessions).toHaveLength(0)
})
it('resets a cached shell after startSend fails', async () => {
const { ctx, owner, stub } = await setup()
await call(ctx, owner, 'warm up')
stub.sessions[0]!.mode = 'send-error'
expect((await call(ctx, owner, 'fails')).isError).toBe(true)
expect(stub.sessions[0]?.closed).toContain('persistent pwsh send failed')
expect(text(await call(ctx, owner, 'recovers'))).toBe('hello from stub')
expect(stub.sessions).toHaveLength(2)
})
it('cancels and awaits a pending shell spawn when the plugin is disposed', async () => {
const ctx = new Context()
contexts.push(ctx)
await ctx.plugin(SystemPrompt)
await ctx.plugin(ToolRegistry)
await ctx.plugin(AgentRegistry)
await ctx.plugin(TerminalSessionService)
const spawnStarted = Promise.withResolvers<undefined>()
const spawnAborted = Promise.withResolvers<undefined>()
ctx.terminals.registerBackend({
type: 'slow',
spawn: spec => new Promise((_resolve, reject) => {
spawnStarted.resolve(undefined)
spec.signal?.addEventListener('abort', () => {
spawnAborted.resolve(undefined)
const reason: unknown = spec.signal?.reason
reject(reason instanceof Error
? reason
: new Error('slow PTY spawn aborted', { cause: reason }))
}, { once: true })
}),
})
const fiber = await ctx.plugin(ToolPwshPersistent, { backendType: 'slow' })
const owner = agent(ctx, '/workspace')
const running = call(ctx, owner, 'pwd')
await spawnStarted.promise
await fiber.dispose()
await spawnAborted.promise
expect((await running).isError).toBe(true)
expect(ctx.terminals.list(owner)).toEqual([])
})
it('rejects invalid config and invalid calls', async () => {
const { ctx, owner, stub } = await setup()
expect((await call(ctx, undefined, 'pwd')).isError).toBe(true)
expect(text(await call(ctx, owner, ' '))).toContain('command must be a non-empty string')
const controller = new AbortController()
controller.abort(new Error('caller stopped'))
expect((await call(ctx, owner, 'pwd', controller.signal)).isError).toBe(true)
expect(stub.sessions).toHaveLength(0)
expect(() => {
ToolPwshPersistent.apply(new Context(), { backendType: '' })
}).toThrow('backendType must be non-empty')
expect(() => {
ToolPwshPersistent.apply(new Context(), { timeoutMs: 0 })
}).toThrow('timeoutMs must be a positive safe integer')
expect(() => {
ToolPwshPersistent.apply(new Context(), { maxOutputChars: 0 })
}).toThrow('maxOutputChars must be a positive safe integer')
expect(() => {
ToolPwshPersistent.apply(new Context(), { description: ' ' })
}).toThrow('description must be non-empty')
})
})
@@ -0,0 +1,17 @@
{
"extends": "../../../tsconfig.base.json",
"compilerOptions": {
"rootDir": "src",
"outDir": "lib/types"
},
"include": ["src"],
"references": [
{ "path": "../../../vendor/cordis" },
{ "path": "../../../vendor/schemastery" },
{ "path": "../../core/agent" },
{ "path": "../../core/tools" },
{ "path": "../../terminal/terminal" },
{ "path": "../../runtime-diagnostics/invariants" },
{ "path": "../../util/timeout" }
]
}