feat: add persistent PTY sessions

This commit is contained in:
NI0317
2026-07-21 16:01:00 +08:00
parent b4750f6333
commit 58cde5103a
71 changed files with 5677 additions and 82 deletions
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority; # 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: # after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write # pnpm run verify-translation-pairing --write
2026-07-16-persistent-pty-sessions.md: 3028d3a527e177f2b30c557dc45443af99783d6c 2026-07-16-persistent-pty-sessions.md: ef2d149c9d7ba4b1df03f003166f94a2996e5d45
2026-07-16-persistent-pty-sessions.zh.md: f244992abccc9c107bc2cf4da392dc39d39d14cc 2026-07-16-persistent-pty-sessions.zh.md: 7f2ee00804b6971b5c64242c8ee84d1631e8b656
@@ -1,6 +1,6 @@
# Agent Note: persistent PTY sessions # Agent Note: persistent PTY sessions
Status: proposed Status: implemented
English | [中文](2026-07-16-persistent-pty-sessions.zh.md) English | [中文](2026-07-16-persistent-pty-sessions.zh.md)
@@ -12,11 +12,11 @@ That gap excludes workflows whose state lives in a terminal rather than a file:
The existing `bash`, `read`, `write`, and `edit` tools remain the reliable default for bounded, auditable operations. A PTY is an additional capability for work that genuinely requires terminal state, not evidence that those tools are defective or candidates for removal. The existing `bash`, `read`, `write`, and `edit` tools remain the reliable default for bounded, auditable operations. A PTY is an additional capability for work that genuinely requires terminal state, not evidence that those tools are defective or candidates for removal.
## Proposal ## Decision
Add an optional `packages/pty/` capability family that exposes agent-owned, persistent, line-oriented PTY sessions. It follows the repository's [capability pattern](../../implemented/architecture/2026-06-13-capability-seams.md), coexists with the existing command and filesystem tools, and does not change `agent-loop`. The optional `packages/pty/` capability family exposes agent-owned, persistent, line-oriented PTY sessions. It follows the repository's [capability pattern](../../implemented/architecture/2026-06-13-capability-seams.md), coexists with the existing command and filesystem tools, and does not change `agent-loop`.
The first delivery supports interactive shells and line-oriented REPLs on Linux and macOS. Full-screen terminal applications, keystroke sequences, BEL-triggered control flow, session restoration after process loss, and cross-agent session sharing are explicitly deferred until the basic lifecycle is proven. The implementation supports interactive shells and line-oriented REPLs on Linux and macOS. Full-screen terminal applications, keystroke sequences, BEL-triggered control flow, session restoration after process loss, and cross-agent session sharing are explicitly deferred.
### Package topology ### Package topology
@@ -32,7 +32,7 @@ Idle detection is backend behavior, not a second public seam. A remote or contai
`PtyService` stores live sessions process-locally, but every session is owned by the exact `Agent` passed through the tool execution context. The service mints an opaque `PtySessionId`; an optional model-chosen `name` is display metadata and is unique only within that owner. Every operation targets `sessionId`, and `list`/`read`/`signal`/`kill` reject callers other than the owner. `PtyService` stores live sessions process-locally, but every session is owned by the exact `Agent` passed through the tool execution context. The service mints an opaque `PtySessionId`; an optional model-chosen `name` is display metadata and is unique only within that owner. Every operation targets `sessionId`, and `list`/`read`/`signal`/`kill` reject callers other than the owner.
The initial design has no plugin-load auto-start sessions. `pty_spawn` creates a session only during an agent tool call, when ownership and the owning event-sourced session are known. Deployments that later need declarative startup must compose it through unpublished agent setup rather than create shared global terminals. There are no plugin-load auto-start sessions. `pty_spawn` creates a session only during an agent tool call, when ownership and the owning event-sourced session are known. A future declarative startup feature must compose through unpublished agent setup rather than create shared global terminals.
Agent-scope disposal closes registrations first, then awaits quiescent teardown of every owned PTY. Backend or tool-plugin reload does not orphan sessions: ownership lives in `PtyService` until the agent ends, following the same service-owned-record pattern as [`ctx.tasks`](../../../../packages/tasks/tasks/README.md). Agent-scope disposal closes registrations first, then awaits quiescent teardown of every owned PTY. Backend or tool-plugin reload does not orphan sessions: ownership lives in `PtyService` until the agent ends, following the same service-owned-record pattern as [`ctx.tasks`](../../../../packages/tasks/tasks/README.md).
@@ -41,11 +41,11 @@ Agent-scope disposal closes registrations first, then awaits quiescent teardown
A registered `shell` backend constrains how a terminal starts; it does not constrain commands typed after startup. `dsh-pty-local` therefore applies two protections before spawning: A registered `shell` backend constrains how a terminal starts; it does not constrain commands typed after startup. `dsh-pty-local` therefore applies two protections before spawning:
- It builds a scrubbed child environment using the same credential-shaped-name policy as `bash-local`, removing ambient `*KEY*`, `*SECRET*`, `*TOKEN*`, and harness-managed variables unless an explicit trusted mapping supplies them. - It builds a scrubbed child environment using the same credential-shaped-name policy as `bash-local`, removing ambient `*KEY*`, `*SECRET*`, `*TOKEN*`, and harness-managed variables unless an explicit trusted mapping supplies them.
- Its `sandbox` config is `required | optional | disabled`, defaulting to `required`. `required` fails plugin load when `ctx.sandbox` is unavailable; `optional` uses the provider when present; `disabled` is an explicit unconfined opt-in. The selected provider wraps the session argv once and remains the process boundary for the PTY lifetime. - It requires `ctx.sandbox` and the shared `ctx.sandboxPolicy`. At spawn, the backend resolves the owner's effective session mode over the deployment default and wraps the shell argv once; that mode and workspace root remain the process boundary for the PTY lifetime. `danger-full-access` is the existing explicit unconfined choice rather than a PTY-specific bypass.
Sandboxing confines local process effects but does not make arbitrary shell input safe: network calls and other external side effects remain governed by deployment policy. Tool descriptions state that PTY sessions are less auditable than one-shot tools and should be used only when persistence or interactive stdin is necessary. Sandboxing confines local process effects but does not make arbitrary shell input safe: network calls and other external side effects remain governed by deployment policy. Tool descriptions state that PTY sessions are less auditable than one-shot tools and should be used only when persistence or interactive stdin is necessary.
The implementation uses only public `node-pty` capabilities: child PID, `data` and `exit` notifications, `write`, `resize`, and `kill`. It does not assume access to the native master fd or call `waitpid` from TypeScript. Platform process inspectors derive process-group and session membership from `/proc` on Linux and `ps` on macOS. The implementation uses only public `node-pty` capabilities: child PID, `data` and `exit` notifications, `write`, `resize`, and `kill`. It does not assume access to the native master fd or call `waitpid` from TypeScript. Platform process inspectors derive foreground process groups and parent/child identity from `/proc` on Linux and `ps` on macOS.
### Six model-facing tools ### Six model-facing tools
@@ -58,11 +58,11 @@ The implementation uses only public `node-pty` capabilities: child PID, `data` a
| `pty_kill` | Close one session and await process-tree quiescence | `{ killed }` | | `pty_kill` | Close one session and await process-tree quiescence | `{ killed }` |
| `pty_list` | List the caller's live sessions | owner-scoped session summaries | | `pty_list` | List the caller's live sessions | owner-scoped session summaries |
`pty_send({ sessionId, text, submit?, background? })` treats `text` as UTF-8 bytes and resolves `submit` to `true` in the tool implementation. When `submit` is true it writes the platform Enter sequence after the text; when false it writes only the text, allowing control characters and REPL fragments without hidden content heuristics. `pty_send({ sessionId, text, submit?, run_in_background? })` treats `text` as UTF-8 bytes and resolves `submit` to `true` in the tool implementation. When `submit` is true it writes the platform Enter sequence after the text; when false it writes only the text, allowing control characters and REPL fragments without hidden content heuristics.
Foreground sends return a bounded rendered delta and two independent facts: `waitReason` (`stdin_read | inferred_idle | timeout | session_exit`) and `sessionStatus` (`running` or `exited` with exit code or signal). `session_exit` refers to the PTY's top-level shell process, not an arbitrary foreground command whose status the shell consumes. A timeout never implies process exit. Foreground sends return a bounded rendered delta and two independent facts: `waitReason` (`stdin_read | inferred_idle | timeout | session_exit`) and `sessionStatus` (`running` or `exited` with exit code or signal). `session_exit` refers to the PTY's top-level shell process, not an arbitrary foreground command whose status the shell consumes. A timeout never implies process exit.
With `background: true`, `dsh-tool-pty` registers the in-flight send on `ctx.tasks` and returns immediately with `taskId`. `task_output(wait: true)` waits, reads incremental output, and records the final result; `task_kill` forwards cancellation as `SIGINT` and escalates only through the PTY backend's owned teardown path. If the task surface is absent, background mode fails before writing input. No PTY-specific `sleep` tool or general wake-up seam is added. With `run_in_background: true`, `dsh-tool-pty` registers the in-flight send on `ctx.tasks` and returns immediately with `taskId`. `task_output(wait: true)` waits, reads incremental output, and records the final result; `task_kill` forwards cancellation as `SIGINT` and escalates only through the PTY backend's owned teardown path. If the task surface is absent, background mode fails before writing input. No PTY-specific `sleep` tool or general wake-up seam is added.
`pty_read` pages backward from the newest retained line. The backend enforces both line and UTF-8 byte caps on retained scrollback and the complete returned value, so one oversized line cannot bypass the bound. `truncated` distinguishes retention loss from an ordinary viewport delta. `pty_read` pages backward from the newest retained line. The backend enforces both line and UTF-8 byte caps on retained scrollback and the complete returned value, so one oversized line cannot bypass the bound. `truncated` distinguishes retention loss from an ordinary viewport delta.
@@ -70,7 +70,7 @@ With `background: true`, `dsh-tool-pty` registers the in-flight send on `ctx.tas
### Local readiness detection ### Local readiness detection
The local backend runs three bounded tiers. All timings are validated config fields: `pollIntervalMs`, `exactProbeAfterMs`, `idleSilenceMs`, and `timeoutMs`. The local backend first recognizes a private OSC prompt marker emitted by its controlled bash startup, then runs three bounded fallback tiers. The marker is removed before output reaches the model and avoids a fixed silence delay for ordinary shell commands on both platforms. All timings are validated config fields: `pollIntervalMs`, `exactProbeAfterMs`, `idleSilenceMs`, and `timeoutMs`.
On Linux, the inspector reads the shell's terminal foreground PGID from `/proc/<shellPid>/stat`, enumerates every process and thread in that process group, and probes their current syscalls. A positive Tier 1 result requires an observed stdin wait: direct `read(0)`, a permitted read of a `select`/`pselect6` or `poll`/`ppoll` argument containing fd 0, or an epoll interest list containing fd 0. Unreadable process memory and unrecognized syscalls are misses, never positive guesses. Architecture tables contain only syscall numbers defined by the corresponding Linux UAPI; unsupported architectures skip Tier 1. On Linux, the inspector reads the shell's terminal foreground PGID from `/proc/<shellPid>/stat`, enumerates every process and thread in that process group, and probes their current syscalls. A positive Tier 1 result requires an observed stdin wait: direct `read(0)`, a permitted read of a `select`/`pselect6` or `poll`/`ppoll` argument containing fd 0, or an epoll interest list containing fd 0. Unreadable process memory and unrecognized syscalls are misses, never positive guesses. Architecture tables contain only syscall numbers defined by the corresponding Linux UAPI; unsupported architectures skip Tier 1.
@@ -78,7 +78,7 @@ On macOS there is no exact syscall tier. Output silence returns `inferred_idle`
Tier 2 returns `inferred_idle` after `idleSilenceMs` without output. A sleeping or network-blocked command can therefore look ready. Tier 3 returns `timeout` after `timeoutMs` so a foreground tool call cannot hold the agent indefinitely. The result preserves the distinction; callers may wait through `ctx.tasks`, signal the foreground group, or inspect from another session. Tier 2 returns `inferred_idle` after `idleSilenceMs` without output. A sleeping or network-blocked command can therefore look ready. Tier 3 returns `timeout` after `timeoutMs` so a foreground tool call cannot hold the agent indefinitely. The result preserves the distinction; callers may wait through `ctx.tasks`, signal the foreground group, or inspect from another session.
`node-pty` data notifications feed one streaming decoder and terminal parser. Parser carry state handles UTF-8 and terminal query sequences split across chunks. The first delivery normalizes line-oriented output and detects alternate-screen entry, but it does not promise correct interaction with a full-screen application. `node-pty` data notifications feed one streaming decoder and terminal parser. Parser carry state handles UTF-8 and terminal query sequences split across chunks. The implementation normalizes line-oriented output and detects alternate-screen entry, but it does not promise correct interaction with a full-screen application.
### Model-visible output and durability ### Model-visible output and durability
@@ -88,9 +88,9 @@ Background sends use the existing task completion notice and `task_output` resul
### Process-tree teardown ### Process-tree teardown
The top-level `node-pty` child is treated as the POSIX session leader, but the owned resource is the complete OS process session, not one PID. On close, the backend stops callbacks, sends `SIGTERM` to all still-matching session members, closes the PTY, awaits `node-pty` exit plus process-inspector quiescence, then sends `SIGKILL` to verified survivors after configurable `disposeGraceMs`. Membership snapshots include process-start identity so PID reuse cannot redirect escalation. The top-level `node-pty` child is the ownership anchor. On close, the backend stops callbacks, snapshots that PID and its transitive descendants by parent PID in children-first order, sends `SIGTERM`, closes the PTY, waits for quiescence, then sends `SIGKILL` to verified survivors after configurable `disposeGraceMs` and waits for them to leave the process table. Every captured PID includes process-start identity so reuse cannot redirect escalation.
Teardown reports root exit and survivor cleanup independently. It does not claim success merely because the shell exited; disposal resolves only after no captured session member remains or returns a structured cleanup failure naming the survivors. Teardown reports root exit and survivor cleanup independently. It does not claim success merely because the shell exited; disposal resolves only after no captured tree member remains or returns a structured cleanup failure naming the survivors. It never broadens ownership to every member of the root PID's POSIX session.
### Composition and rollout ### Composition and rollout
@@ -99,10 +99,13 @@ The example composition remains opt-in and safe by default:
```yaml ```yaml
plugins: plugins:
'@deepseek-ai/dsh-sandbox-local': '@deepseek-ai/dsh-sandbox-local':
'@deepseek-ai/dsh-sandbox-policy':
config:
mode: workspace-write
workspaceRoot: .
'@deepseek-ai/dsh-pty': '@deepseek-ai/dsh-pty':
'@deepseek-ai/dsh-pty-local': '@deepseek-ai/dsh-pty-local':
config: config:
sandbox: required
scrollbackLines: 10000 scrollbackLines: 10000
scrollbackMaxBytes: 4194304 scrollbackMaxBytes: 4194304
maxReadBytes: 262144 maxReadBytes: 262144
@@ -114,7 +117,7 @@ plugins:
'@deepseek-ai/dsh-tool-pty': '@deepseek-ai/dsh-tool-pty':
``` ```
The package ships concise tool guidance explaining persistent state, owner isolation, uncertain idle results, cleanup, and the preference for existing one-shot tools when interaction is unnecessary. It does not add a global system-prompt recommendation or mount PTY in shipped defaults. The package ships concise tool guidance explaining persistent state, owner isolation, uncertain idle results, cleanup, and the preference for existing one-shot tools when interaction is unnecessary. It does not add a global system-prompt recommendation or mount PTY in shipped defaults; dedicated ACP and headless snapshot overlays exercise the opt-in composition.
### Deferred work ### Deferred work
@@ -130,39 +133,37 @@ The package ships concise tool guidance explaining persistent state, owner isola
**Add persistent mode to `bash`.** Rejected. Returning on readiness rather than process exit, retaining a process tree across calls, and exposing interactive stdin create a different ownership and failure contract. **Add persistent mode to `bash`.** Rejected. Returning on readiness rather than process exit, retaining a process tree across calls, and exposing interactive stdin create a different ownership and failure contract.
**Require native master-fd access from `node-pty`.** Rejected. Its public API exposes no master fd. The local backend instead derives foreground and session membership from supported OS process metadata and treats unreadable metadata as a detector miss. **Require native master-fd access from `node-pty`.** Rejected. Its public API exposes no master fd. The local backend instead derives foreground groups and descendants from supported OS process metadata and treats unreadable metadata as a detector miss.
**Signal every member of the root PID's POSIX session.** Rejected. `node-pty` may expose a helper PID whose session belongs to the launcher, so SID-wide teardown can signal unrelated harness or desktop processes. A PID-identity-fenced descendant tree is narrower and safe by construction.
**Publish `PtyIdleDetector` as a replaceable registry.** Rejected. Only the local backend needs these platform probes, while remote backends may receive readiness over their own protocol. Backend replacement already provides the necessary extension point. **Publish `PtyIdleDetector` as a replaceable registry.** Rejected. Only the local backend needs these platform probes, while remote backends may receive readiness over their own protocol. Backend replacement already provides the necessary extension point.
**Add a PTY-specific `sleep` tool.** Rejected. `ctx.tasks` already owns bounded waiting, cancellation, completion notices, and model-facing collection. A second general wake mechanism would cross the agent-loop boundary and duplicate that contract. **Add a PTY-specific `sleep` tool.** Rejected. `ctx.tasks` already owns bounded waiting, cancellation, completion notices, and model-facing collection. A second general wake mechanism would cross the agent-loop boundary and duplicate that contract.
**Include TUI sequences and BEL handling in the first delivery.** Rejected. The source prototype treats those paths as timing-sensitive and still records unresolved alternate-screen and interaction failures. Line-oriented PTY use proves the core value without making those unverified behaviors foundational. **Include TUI sequences and BEL handling.** Rejected. The source prototype treats those paths as timing-sensitive and still records unresolved alternate-screen and interaction failures. Line-oriented PTY use proves the core value without making those unverified behaviors foundational.
**Use an out-of-process daemon immediately.** Rejected for the initial in-process capability because current persistent front doors already keep a Cordis context alive. A daemon becomes justified by cross-process restoration or multi-client attachment, both deferred here. **Use an out-of-process daemon immediately.** Rejected for the initial in-process capability because current persistent front doors already keep a Cordis context alive. A daemon becomes justified by cross-process restoration or multi-client attachment, both deferred here.
## Acceptance criteria ## Verification
- `packages/pty/{pty,pty-local,tool-pty}` build as the interface, local implementation, and model consumer; backend registrations dispose cleanly. - Per-file coverage pins owner fencing, concurrent reservations, lifecycle cleanup, readiness tiers, sanitizer carry state, UTF-8 bounds, task integration, schemas, and render intents.
- Every live PTY has one service-minted `PtySessionId`, one exact `Agent` owner, owner-fenced operations, and awaited cleanup on agent disposal; concurrent agents may reuse display names without sharing state. - Linux process fixtures cover non-leader and non-main-thread stdin waits, unreadable process state, supported syscall tables, unsupported architectures, and false-positive rejection; macOS inspector logic is injected into the same unit suite.
- `dsh-pty-local` uses only public `node-pty` APIs and contains no master-fd or TypeScript `waitpid` assumption. - Real `node-pty` tests exercise shell state, shared sandbox policy, environment scrubbing, signals, a TERM-ignoring descendant, and immediate post-disposal quiescence on supported hosts.
- Environment tests prove credential-shaped ambient variables are absent. `sandbox: required` fails at load without a provider, and real composition proves the provider wraps the long-lived session process. - A Loader-driven `cordis.yml` test mounts the real three-package composition, while ACP and headless snapshots pin the six schemas, bounded results, error rendering, and terminal/generic cards through opt-in overlays.
- Linux fixtures cover pipelines, a stdin-reading non-leader process, a stdin-reading non-main thread, unreadable process memory, supported UAPI syscall tables, unsupported architectures, and false-positive rejection. macOS process-inspector logic reaches 100% coverage on Linux, and macOS CI drives a real bash and Python REPL. - Package contracts, the architecture map, core data structures, generated catalogs, and the website API describe the same shipped surface.
- Foreground tests exercise `stdin_read`, `inferred_idle`, `timeout`, and top-level session exit without treating a foreground command exit as directly observable. - The repository CI-equivalent sequence owns type, lint, coverage, snapshot, documentation, build, hygiene, demo, and built-entry verification.
- Background sends register `ctx.tasks` work, return before readiness, stream bounded output through `task_output`, honor task cancellation, and fail before writing when the task surface is absent.
- Scrollback and every model-facing result enforce final UTF-8 byte bounds, including a single oversized line and multibyte boundary cases.
- `pty_signal` resolves the live foreground group, rejects lookup failure and shell-targeted `SIGKILL`, and never falls back to a guessed PID.
- Disposal tests start foreground and background descendants, including a signal-ignoring child, then prove every captured process identity is gone immediately after awaited agent disposal.
- A test-only `cordis.yml` boots through the Loader on Linux and macOS, mounts the real local backend plus sandbox, and drives spawn/send/read/signal/kill/list through the real tool registry. ACP and headless snapshots pin the six schemas, bounded results, errors, and render intents.
- TUI, sequence, BEL, auto-start, Windows, and crash-restoration behavior are absent from the public schema and documented as deferred rather than simulated by fixtures.
- Package READMEs and JSDoc document configuration, ownership, failure, cancellation, bounds, sandboxing, model-visible effects, and limitations; `docs/architecture.md` and generated catalogs update with the implementation.
- The repository CI-equivalent sequence in root `AGENTS.md` passes, including `test:coverage`, snapshots, documentation, build, hygiene, and built-entry smokes.
## Risks ## Consequences
**Persistent terminal state is available without weakening one-shot tools.** Shell and REPL state can survive tool calls, while `bash`, `read`, `write`, and `edit` retain their narrower validation, approval, and replay contracts.
**Idle below Linux Tier 1 is heuristic.** Output silence cannot distinguish a prompt from sleep or network I/O. The typed result preserves uncertainty, and bounded timeout plus task waiting and signaling keep control with the model. **Idle below Linux Tier 1 is heuristic.** Output silence cannot distinguish a prompt from sleep or network I/O. The typed result preserves uncertainty, and bounded timeout plus task waiting and signaling keep control with the model.
**Persistent state can drift from the model's belief.** The model may forget its cwd or active REPL. Session summaries and retained output help recovery, but no prompt can make state persistence deterministic. **Persistent state can drift from the model's belief.** The model may forget its cwd or active REPL. Session summaries and retained output help recovery, but no prompt can make state persistence deterministic.
**A daemonized descendant can leave the captured tree.** A process that reparents before teardown is no longer discoverable from the `node-pty` root. The implementation accepts that cleanup gap instead of risking SID-wide signals to unrelated processes.
**A shell can cause external side effects.** Session sandboxing and environment scrubbing reduce local exposure but do not undo pushes, API calls, or messages. Deployments that cannot tolerate those effects must omit PTY or add network policy. **A shell can cause external side effects.** Session sandboxing and environment scrubbing reduce local exposure but do not undo pushes, API calls, or messages. Deployments that cannot tolerate those effects must omit PTY or add network policy.
**Process loss destroys terminal state.** In-process sessions do not survive a harness crash or restart, and raw scrollback is not durable. Important work must be committed to files or another durable system. **Process loss destroys terminal state.** In-process sessions do not survive a harness crash or restart, and raw scrollback is not durable. Important work must be committed to files or another durable system.
@@ -1,6 +1,6 @@
# Agent Note: 持久化 PTY 会话 # Agent Note: 持久化 PTY 会话
Status: proposed Status: implemented
[English](2026-07-16-persistent-pty-sessions.md) | 中文 [English](2026-07-16-persistent-pty-sessions.md) | 中文
@@ -12,11 +12,11 @@ harness 可以运行前台与后台命令、编辑文件和委派工作,但无
现有 `bash``read``write``edit` 工具仍是有界、可审计操作的可靠默认选项。PTY 是对确实需要终端状态的工作的补充功能,不说明这些工具有缺陷,更不意味着要移除它们。 现有 `bash``read``write``edit` 工具仍是有界、可审计操作的可靠默认选项。PTY 是对确实需要终端状态的工作的补充功能,不说明这些工具有缺陷,更不意味着要移除它们。
## 提案 ## 决策
新增可选的 `packages/pty/` 功能家族,向模型提供由 agent 拥有、持久化且面向行式交互的 PTY 会话。它遵循仓库的 [capability pattern](../../implemented/architecture/2026-06-13-capability-seams.md),与现有命令和文件系统工具并存,并且不修改 `agent-loop` 可选的 `packages/pty/` 功能家族提供由 agent 拥有、持久化且面向行式交互的 PTY 会话。它遵循仓库的 [capability pattern](../../implemented/architecture/2026-06-13-capability-seams.md),与现有命令和文件系统工具并存,并且不修改 `agent-loop`
首次交付在 Linux 和 macOS 上支持交互式 shell 与行式 REPL。全屏终端应用、按键序列、BEL 触发的控制流、进程丢失后的会话恢复以及跨 agent 共享会话都明确推迟,直到基础生命周期得到验证 当前实现在 Linux 和 macOS 上支持交互式 shell 与行式 REPL。全屏终端应用、按键序列、BEL 触发的控制流、进程丢失后的会话恢复以及跨 agent 共享会话都明确推迟。
### 包拓扑 ### 包拓扑
@@ -32,7 +32,7 @@ idle 检测属于后端行为,不是第二条公共 seam。远程或容器后
`PtyService` 在进程内保存活会话,但每个会话都由工具执行上下文传入的确切 `Agent` 拥有。服务铸造不透明的 `PtySessionId`;模型可选填的 `name` 只是显示元数据,仅在该 owner 内唯一。所有操作都以 `sessionId` 为目标,`list`/`read`/`signal`/`kill` 会拒绝 owner 之外的调用方。 `PtyService` 在进程内保存活会话,但每个会话都由工具执行上下文传入的确切 `Agent` 拥有。服务铸造不透明的 `PtySessionId`;模型可选填的 `name` 只是显示元数据,仅在该 owner 内唯一。所有操作都以 `sessionId` 为目标,`list`/`read`/`signal`/`kill` 会拒绝 owner 之外的调用方。
初始设计不提供插件加载期 auto-start 会话。`pty_spawn` 只在 agent 工具调用期间创建会话,此时所有权和所属的事件溯源会话都已确定。若部署后续需要声明式启动必须通过尚未发布的 agent setup 组合,而不能创建全局共享终端。 实现不提供插件加载期 auto-start 会话。`pty_spawn` 只在 agent 工具调用期间创建会话,此时所有权和所属的事件溯源会话都已确定。未来的声明式启动功能必须通过尚未发布的 agent setup 组合,而不能创建全局共享终端。
agent scope dispose 时先关闭注册,再等待全部所属 PTY 静默退出。后端或工具插件 reload 不会遗留会话:所有权持续存放在 `PtyService` 中,直到 agent 结束,与 [`ctx.tasks`](../../../../packages/tasks/tasks/README.md) 的服务持有记录模式一致。 agent scope dispose 时先关闭注册,再等待全部所属 PTY 静默退出。后端或工具插件 reload 不会遗留会话:所有权持续存放在 `PtyService` 中,直到 agent 结束,与 [`ctx.tasks`](../../../../packages/tasks/tasks/README.md) 的服务持有记录模式一致。
@@ -41,11 +41,11 @@ agent scope dispose 时先关闭注册,再等待全部所属 PTY 静默退出
注册的 `shell` 后端只约束终端如何启动,不约束启动后输入的命令。因此 `dsh-pty-local` 在 spawn 前应用两层保护: 注册的 `shell` 后端只约束终端如何启动,不约束启动后输入的命令。因此 `dsh-pty-local` 在 spawn 前应用两层保护:
- 它使用与 `bash-local` 相同的凭证形态名称策略构建清洗后的子进程环境,移除环境中的 `*KEY*``*SECRET*``*TOKEN*` 和 harness 管理的变量,除非显式的可信映射提供这些值。 - 它使用与 `bash-local` 相同的凭证形态名称策略构建清洗后的子进程环境,移除环境中的 `*KEY*``*SECRET*``*TOKEN*` 和 harness 管理的变量,除非显式的可信映射提供这些值。
- 它 `sandbox` 配置为 `required | optional | disabled`,默认 `required``required` 在缺少 `ctx.sandbox` 时于插件加载期失败;`optional` 在提供方存在时使用;`disabled` 是显式选择无约束模式。所选提供方只包装一次会话 argv,并在 PTY 的整个生命周期中充当进程边界 - 它要求 `ctx.sandbox` 和共享的 `ctx.sandboxPolicy`。后端在 spawn 时,以部署默认值为底折叠 owner 的有效 session mode,并只包装一次 shell argv;该 mode 与 workspace root 在 PTY 的整个生命周期中充当进程边界。`danger-full-access` 是现有的显式无约束选择,不另设 PTY 私有 bypass
沙箱限制本地进程副作用,但不会让任意 shell 输入自动安全:网络调用和其他外部副作用仍由部署策略治理。工具描述会说明 PTY 会话比一次性工具更难审计,只应在确实需要持久状态或交互式 stdin 时使用。 沙箱限制本地进程副作用,但不会让任意 shell 输入自动安全:网络调用和其他外部副作用仍由部署策略治理。工具描述会说明 PTY 会话比一次性工具更难审计,只应在确实需要持久状态或交互式 stdin 时使用。
实现只使用 `node-pty` 的公共功能:子进程 PID、`data``exit` 通知、`write``resize``kill`。它不假设能访问原生 master fd,也不从 TypeScript 调用 `waitpid`。平台进程检查器在 Linux 上通过 `/proc`、在 macOS 上通过 `ps` 推导进程组和会话成员关系 实现只使用 `node-pty` 的公共功能:子进程 PID、`data``exit` 通知、`write``resize``kill`。它不假设能访问原生 master fd,也不从 TypeScript 调用 `waitpid`。平台进程检查器在 Linux 上通过 `/proc`、在 macOS 上通过 `ps` 推导前台进程组和父子进程身份
### 6 个面向模型的工具 ### 6 个面向模型的工具
@@ -58,11 +58,11 @@ agent scope dispose 时先关闭注册,再等待全部所属 PTY 静默退出
| `pty_kill` | 关闭一个会话并等待进程树静默退出 | `{ killed }` | | `pty_kill` | 关闭一个会话并等待进程树静默退出 | `{ killed }` |
| `pty_list` | 列出调用方的活会话 | 按 owner 隔离的会话摘要 | | `pty_list` | 列出调用方的活会话 | 按 owner 隔离的会话摘要 |
`pty_send({ sessionId, text, submit?, background? })``text` 视为 UTF-8 字节,并由工具实现在解析阶段把 `submit` 默认成 `true``submit` 为 true 时先写入文本,再写入平台 Enter 序列;为 false 时只写文本,使控制字符和 REPL 片段无需隐藏的内容启发式即可发送。 `pty_send({ sessionId, text, submit?, run_in_background? })``text` 视为 UTF-8 字节,并由工具实现在解析阶段把 `submit` 默认成 `true``submit` 为 true 时先写入文本,再写入平台 Enter 序列;为 false 时只写文本,使控制字符和 REPL 片段无需隐藏的内容启发式即可发送。
前台发送返回有界的渲染增量和两个独立事实:`waitReason``stdin_read | inferred_idle | timeout | session_exit`)与 `sessionStatus``running`,或携带退出码或信号的 `exited`)。`session_exit` 指 PTY 顶层 shell 进程退出,不指由 shell 消费状态的任意前台命令。timeout 从不意味着进程已经退出。 前台发送返回有界的渲染增量和两个独立事实:`waitReason``stdin_read | inferred_idle | timeout | session_exit`)与 `sessionStatus``running`,或携带退出码或信号的 `exited`)。`session_exit` 指 PTY 顶层 shell 进程退出,不指由 shell 消费状态的任意前台命令。timeout 从不意味着进程已经退出。
`background: true` 时,`dsh-tool-pty``ctx.tasks` 上注册进行中的发送,并立即返回 `taskId``task_output(wait: true)` 负责等待、读取增量输出并记录最终结果;`task_kill` 将取消转发为 `SIGINT`,只有 PTY 后端拥有的 teardown 路径可以升级信号。若 task 对外接口不存在,后台模式必须在写入输入前失败。设计不新增 PTY 专用的 `sleep` 工具或通用唤醒 seam。 `run_in_background: true` 时,`dsh-tool-pty``ctx.tasks` 上注册进行中的发送,并立即返回 `taskId``task_output(wait: true)` 负责等待、读取增量输出并记录最终结果;`task_kill` 将取消转发为 `SIGINT`,只有 PTY 后端拥有的 teardown 路径可以升级信号。若 task 对外接口不存在,后台模式必须在写入输入前失败。设计不新增 PTY 专用的 `sleep` 工具或通用唤醒 seam。
`pty_read` 从最新保留行向后分页。后端同时对保留的 scrollback 和完整返回值执行行数与 UTF-8 字节上限,因此单个超长行无法绕过限制。`truncated` 用于区分保留数据丢失与普通 viewport 增量。 `pty_read` 从最新保留行向后分页。后端同时对保留的 scrollback 和完整返回值执行行数与 UTF-8 字节上限,因此单个超长行无法绕过限制。`truncated` 用于区分保留数据丢失与普通 viewport 增量。
@@ -70,7 +70,7 @@ agent scope dispose 时先关闭注册,再等待全部所属 PTY 静默退出
### 本地就绪检测 ### 本地就绪检测
本地后端执行 3 个有界层级。所有时间参数都是经校验的配置字段:`pollIntervalMs``exactProbeAfterMs``idleSilenceMs``timeoutMs` 本地后端先识别受控 bash 启动时发出的私有 OSC prompt marker,再执行 3 个有界 fallback 层级。marker 在输出到达模型前被移除,使两个平台上的普通 shell 命令都无需固定等待静默阈值。所有时间参数都是经校验的配置字段:`pollIntervalMs``exactProbeAfterMs``idleSilenceMs``timeoutMs`
在 Linux 上,检查器从 `/proc/<shellPid>/stat` 读取 shell 的终端前台 PGID,枚举该进程组中的每个进程与线程,并检查它们当前的 syscall。Tier 1 只有观察到 stdin 等待才返回正结果:直接 `read(0)`、获准读取且含 fd 0 的 `select`/`pselect6``poll`/`ppoll` 参数,或者含 fd 0 的 epoll interest list。无法读取的进程内存和未识别的 syscall 都是 miss,绝不作为正向猜测。架构表只包含对应 Linux UAPI 定义的 syscall number;不支持的架构跳过 Tier 1。 在 Linux 上,检查器从 `/proc/<shellPid>/stat` 读取 shell 的终端前台 PGID,枚举该进程组中的每个进程与线程,并检查它们当前的 syscall。Tier 1 只有观察到 stdin 等待才返回正结果:直接 `read(0)`、获准读取且含 fd 0 的 `select`/`pselect6``poll`/`ppoll` 参数,或者含 fd 0 的 epoll interest list。无法读取的进程内存和未识别的 syscall 都是 miss,绝不作为正向猜测。架构表只包含对应 Linux UAPI 定义的 syscall number;不支持的架构跳过 Tier 1。
@@ -78,7 +78,7 @@ macOS 没有精确 syscall 层。任何前台进程组输出静默都会返回 `
Tier 2 在持续 `idleSilenceMs` 没有输出后返回 `inferred_idle`,因此 sleep 或网络阻塞的命令可能看似 ready。Tier 3 在 `timeoutMs` 后返回 `timeout`,避免前台工具调用无限占住 agent。结果保留这些区别;调用方可以通过 `ctx.tasks` 等待、向前台组发信号,或从另一个会话排查。 Tier 2 在持续 `idleSilenceMs` 没有输出后返回 `inferred_idle`,因此 sleep 或网络阻塞的命令可能看似 ready。Tier 3 在 `timeoutMs` 后返回 `timeout`,避免前台工具调用无限占住 agent。结果保留这些区别;调用方可以通过 `ctx.tasks` 等待、向前台组发信号,或从另一个会话排查。
`node-pty` data 通知进入同一个流式 decoder 和终端 parser。parser 的 carry 状态处理跨 chunk 的 UTF-8 与终端查询序列。首次交付只规范化行式输出并检测 alternate-screen 进入,不承诺正确操作全屏应用。 `node-pty` data 通知进入同一个流式 decoder 和终端 parser。parser 的 carry 状态处理跨 chunk 的 UTF-8 与终端查询序列。当前实现只规范化行式输出并检测 alternate-screen 进入,不承诺正确操作全屏应用。
### 模型可见输出与持久性 ### 模型可见输出与持久性
@@ -88,9 +88,9 @@ Tier 2 在持续 `idleSilenceMs` 没有输出后返回 `inferred_idle`,因此
### 进程树 teardown ### 进程树 teardown
顶层 `node-pty` 子进程视为 POSIX 会话 leader,但所属资源是完整的 OS 进程会话,而不是一个 PID。关闭时,后端先停止 callback,再向仍匹配的会话成员发送 `SIGTERM`、关闭 PTY、等待 `node-pty` exit 与进程检查器确认静默,然后在可配置的 `disposeGraceMs` 后向已验证的存活者发送 `SIGKILL`。成员快照包含进程启动身份,避免 PID 复用把升级信号发给无关进程。 顶层 `node-pty` 子进程是所有权锚点。关闭时,后端先停止 callback,再按父 PID 以子进程优先顺序捕获该 PID 及其传递子进程、发送 `SIGTERM`、关闭 PTY 并等待静默,然后在可配置的 `disposeGraceMs` 后向已验证的存活者发送 `SIGKILL`,并等待它们离开进程表。每个捕获的 PID 都包含进程启动身份,避免 PID 复用把升级信号发给无关进程。
teardown 独立报告根进程退出与存活进程清理。它不会只因 shell 退出就声称成功;dispose 只有在已捕获的会话成员全部消失后才完成,否则返回结构化清理失败并列出存活者。 teardown 独立报告根进程退出与存活进程清理。它不会只因 shell 退出就声称成功;dispose 只有在已捕获的进程树成员全部消失后才完成,否则返回结构化清理失败并列出存活者。所有权绝不会扩大到根 PID 所属 POSIX 会话的全部成员。
### 组合与推行 ### 组合与推行
@@ -99,10 +99,13 @@ teardown 独立报告根进程退出与存活进程清理。它不会只因 shel
```yaml ```yaml
plugins: plugins:
'@deepseek-ai/dsh-sandbox-local': '@deepseek-ai/dsh-sandbox-local':
'@deepseek-ai/dsh-sandbox-policy':
config:
mode: workspace-write
workspaceRoot: .
'@deepseek-ai/dsh-pty': '@deepseek-ai/dsh-pty':
'@deepseek-ai/dsh-pty-local': '@deepseek-ai/dsh-pty-local':
config: config:
sandbox: required
scrollbackLines: 10000 scrollbackLines: 10000
scrollbackMaxBytes: 4194304 scrollbackMaxBytes: 4194304
maxReadBytes: 262144 maxReadBytes: 262144
@@ -114,7 +117,7 @@ plugins:
'@deepseek-ai/dsh-tool-pty': '@deepseek-ai/dsh-tool-pty':
``` ```
提供简洁的工具指引,说明持久状态、owner 隔离、不确定的 idle 结果、清理,以及无需交互时优先使用现有一次性工具。它不增加全局 system prompt 推荐,也不在已发布的默认配置中挂载 PTY。 包提供简洁的工具指引,说明持久状态、owner 隔离、不确定的 idle 结果、清理,以及无需交互时优先使用现有一次性工具。它不增加全局 system prompt 推荐,也不在已发布的默认配置中挂载 PTY;专用 ACP 与 headless 快照 overlay 覆盖 opt-in 组合
### 推迟的工作 ### 推迟的工作
@@ -130,39 +133,37 @@ plugins:
**给 `bash` 增加持久模式。**拒绝。按就绪而不是进程退出返回、跨调用保留进程树、暴露交互式 stdin 会形成不同的所有权和失败契约。 **给 `bash` 增加持久模式。**拒绝。按就绪而不是进程退出返回、跨调用保留进程树、暴露交互式 stdin 会形成不同的所有权和失败契约。
**要求从 `node-pty` 获取原生 master fd。**拒绝。它的公共 API 不暴露 master fd。本地后端改为从受支持的 OS 进程元数据推导前台组和 session 成员,并把不可读元数据视为 detector miss。 **要求从 `node-pty` 获取原生 master fd。**拒绝。它的公共 API 不暴露 master fd。本地后端改为从受支持的 OS 进程元数据推导前台组与子孙进程,并把不可读元数据视为 detector miss。
**向根 PID 所属 POSIX 会话的全部成员发送信号。**拒绝。`node-pty` 可能暴露属于启动器会话的 helper PID,因此按 SID 清理可能向无关的 harness 或桌面进程发送信号。带 PID 启动身份校验的子孙进程树范围更窄,其安全边界由结构保证。
**发布可替换注册表 `PtyIdleDetector`。**拒绝。只有本地后端需要这些平台 probe,远程后端可能通过自己的协议接收就绪状态。替换后端已经提供所需扩展点。 **发布可替换注册表 `PtyIdleDetector`。**拒绝。只有本地后端需要这些平台 probe,远程后端可能通过自己的协议接收就绪状态。替换后端已经提供所需扩展点。
**新增 PTY 专用 `sleep` 工具。**拒绝。`ctx.tasks` 已经拥有有界等待、取消、完成通知和面向模型的收集。第二套通用唤醒机制会跨越 agent loop(智能体循环)边界并重复该契约。 **新增 PTY 专用 `sleep` 工具。**拒绝。`ctx.tasks` 已经拥有有界等待、取消、完成通知和面向模型的收集。第二套通用唤醒机制会跨越 agent loop(智能体循环)边界并重复该契约。
**在首次交付包含 TUI sequence 与 BEL 处理。**拒绝。源 prototype 将这些路径视为 timing-sensitive,且仍记录未解决的 alternate-screen 和交互失败。行式 PTY 已能证明核心价值,无需把未经验证的行为放进基础层。 **包含 TUI sequence 与 BEL 处理。**拒绝。源 prototype 将这些路径视为 timing-sensitive,且仍记录未解决的 alternate-screen 和交互失败。行式 PTY 已能证明核心价值,无需把未经验证的行为放进基础层。
**立即采用进程外 daemon。**初始的进程内功能不采用,因为当前持久 front door 已能维持 Cordis context。跨进程恢复或多客户端 attach 会让 daemon 变得合理,但两者都已推迟。 **立即采用进程外 daemon。**初始的进程内功能不采用,因为当前持久 front door 已能维持 Cordis context。跨进程恢复或多客户端 attach 会让 daemon 变得合理,但两者都已推迟。
## 验收标准 ## 验
- `packages/pty/{pty,pty-local,tool-pty}` 分别作为接口、本地实现和模型消费方构建;后端注册可干净 dispose - 每文件覆盖率固定 owner 隔离、并发预留、生命周期清理、就绪层级、sanitizer carry state、UTF-8 上限、task 集成、schema 和 render intent
- 每个活 PTY 都有一个由服务铸造的 `PtySessionId`、一个确切的 `Agent` owner、按 owner 隔离的操作,并在 agent dispose 时等待清理;并发 agent 可以复用显示名称而不共享状态 - Linux 进程 fixture 覆盖非 leader 与非主线程的 stdin 等待、不可读进程状态、受支持的 syscall 表、不支持的架构和误报拒绝;同一单元测试套件通过注入覆盖 macOS 检查器逻辑
- `dsh-pty-local` 只使用 `node-pty` 公共 API,不包含 master-fd 或 TypeScript `waitpid` 假设 - 真实 `node-pty` 测试在受支持宿主上覆盖 shell 状态、共享沙箱策略、环境清洗、信号、忽略 `SIGTERM` 的子进程,以及 dispose 返回后立即静默
- 环境测试证明凭证形态的环境变量不存在。缺少提供方时 `sandbox: required` 在加载期失败,REAL-composition 测试证明提供方包装长活会话进程 - Loader 驱动的 `cordis.yml` 测试挂载真实三包组合;ACP 与 headless 快照通过 opt-in overlay 固定 6 个 schema、有界结果、错误渲染和 terminal/generic card
- Linux fixture(测试前置数据)覆盖 shell 管道、读取 stdin 的非 leader 进程、读取 stdin 的非主线程、不可读进程内存、受支持的 UAPI syscall 表、不支持的架构和误报拒绝。macOS 进程检查逻辑在 Linux 上达到 100% 覆盖率,macOS CI 驱动真实 bash 与 Python REPL - 包契约、架构图、核心数据结构、生成目录和 website API 描述同一个已发布接口
- 前台测试覆盖 `stdin_read``inferred_idle``timeout` 和顶层会话退出,不把前台命令退出当作可直接观察事件 - 仓库 CI 等价序列负责类型、lint、覆盖率、快照、文档、构建、hygiene、demo 和 built-entry 验证
- 后台发送注册 `ctx.tasks` work、在就绪前返回、通过 `task_output` 流式提供有界输出、遵守 task cancellation,并在 task 对外接口缺失时于写入前失败。
- scrollback 与每个面向模型的结果都对最终 UTF-8 字节执行上限,包括单个超长行和多字节边界情况。
- `pty_signal` 解析活跃前台组,拒绝查询失败和指向 shell 的 `SIGKILL`,且绝不回退到猜测的 PID。
- dispose 测试启动前台与后台子进程,包括忽略信号的子进程,然后证明等待 agent dispose 后每个捕获的进程身份立即消失。
- 测试专用 `cordis.yml` 在 Linux 与 macOS 上通过 Loader 启动,挂载真实本地后端与沙箱,并通过真实工具注册表驱动 spawn/send/read/signal/kill/list。ACP 与 headless 快照固定 6 个 schema、有界结果、错误和 render intent。
- TUI、sequence、BEL、auto-start、Windows 和 crash-restoration 行为不出现在公共 schema 中,并记录为推迟事项,而不是由 fixture 模拟。
- 包 README 与 JSDoc 记录配置、所有权、失败、取消、上限、沙箱、模型可见影响和限制;实现同时更新 `docs/architecture.md` 与生成目录。
- 根 `AGENTS.md` 中的仓库 CI 等价序列通过,包括 `test:coverage`、快照、文档、构建、hygiene 和 built-entry smoke。
## 风险 ## 后果
**无需削弱一次性工具即可获得持久终端状态。**Shell 与 REPL 状态可以跨工具调用保留,而 `bash``read``write``edit` 继续拥有更窄的校验、审批与回放契约。
**Linux Tier 1 之外的 idle 都是启发式结果。**输出静默无法区分 prompt、sleep 和网络 I/O。类型化结果保留不确定性,有界 timeout、task 等待与信号让模型仍能掌握控制权。 **Linux Tier 1 之外的 idle 都是启发式结果。**输出静默无法区分 prompt、sleep 和网络 I/O。类型化结果保留不确定性,有界 timeout、task 等待与信号让模型仍能掌握控制权。
**持久状态可能偏离模型认知。**模型可能忘记 cwd 或活跃 REPL。会话摘要和保留输出有助恢复,但任何 prompt 都无法让状态持久化变成确定行为。 **持久状态可能偏离模型认知。**模型可能忘记 cwd 或活跃 REPL。会话摘要和保留输出有助恢复,但任何 prompt 都无法让状态持久化变成确定行为。
**daemonized 子进程可能离开捕获树。**在 teardown 前 reparent 的进程无法再从 `node-pty` 根进程发现。实现接受这个清理缺口,不冒险按 SID 向无关进程发送信号。
**Shell 可以造成外部副作用。**会话沙箱和环境清洗降低本地暴露,但无法撤销 push、API 调用或消息发送。无法容忍这些副作用的部署必须省略 PTY 或增加网络策略。 **Shell 可以造成外部副作用。**会话沙箱和环境清洗降低本地暴露,但无法撤销 push、API 调用或消息发送。无法容忍这些副作用的部署必须省略 PTY 或增加网络策略。
**进程丢失会销毁终端状态。**进程内会话无法跨 harness crash 或 restart 存活,原始 scrollback 也不持久化。重要工作必须提交到文件或其他持久系统。 **进程丢失会销毁终端状态。**进程内会话无法跨 harness crash 或 restart 存活,原始 scrollback 也不持久化。重要工作必须提交到文件或其他持久系统。
+1
View File
@@ -15,6 +15,7 @@ packages/ @deepseek-ai/dsh-<pkg> workspaces at packages/<group>/<pkg>/
prompt/ workspace instructions prompt/ workspace instructions
llm/ LLM seam + the DeepSeek adapters (hand-rolled + pi-ai design twin) llm/ LLM seam + the DeepSeek adapters (hand-rolled + pi-ai design twin)
bash/ bash executor seam + local impl + model-facing bash tools bash/ bash executor seam + local impl + model-facing bash tools
pty/ persistent PTY seam + local impl + model-facing session tools
fs/ filesystem seam + local impl + policy gate + read/write/edit tools fs/ filesystem seam + local impl + policy gate + read/write/edit tools
skill/ skill provider registry + local impl + catalog/loader tool skill/ skill provider registry + local impl + catalog/loader tool
web/ web seam + search/fetch providers + model-facing web tools web/ web seam + search/fetch providers + model-facing web tools
+4 -2
View File
@@ -1,6 +1,6 @@
# DeepSeek Harness Architecture # DeepSeek Harness Architecture
The **DeepSeek Harness SDK** builds agent harnesses on Cordis. The principle is simple: **everything is a plugin**. The shipped loop is one plugin, not a privileged kernel. The **DeepSeek Harness SDK** builds agent harnesses on Cordis; **everything, including the shipped loop, is a plugin**.
## Overview ## Overview
@@ -26,6 +26,7 @@ A harness is one [Cordis](cordis-primer.md) context. Packages add services (`ctx
| `ctx.llm` | [`llm/`](../packages/llm/README.md) | adapter registry and streaming model calls | | `ctx.llm` | [`llm/`](../packages/llm/README.md) | adapter registry and streaming model calls |
| `ctx.tokenMeter` | [`llm/token-meter`](../packages/llm/token-meter/README.md) | singleton replay-aware request/surface pressure | | `ctx.tokenMeter` | [`llm/token-meter`](../packages/llm/token-meter/README.md) | singleton replay-aware request/surface pressure |
| `ctx.bash` | [`bash/`](../packages/bash/README.md) | foreground/background command execution | | `ctx.bash` | [`bash/`](../packages/bash/README.md) | foreground/background command execution |
| `ctx.pty` | [`pty/`](../packages/pty/README.md) | owner-scoped persistent terminal sessions |
| `ctx.sandbox` | [`sandbox/`](../packages/sandbox/README.md) | same-world process confinement (argv wrapping, per-call policy) | | `ctx.sandbox` | [`sandbox/`](../packages/sandbox/README.md) | same-world process confinement (argv wrapping, per-call policy) |
| `ctx.sandboxPolicy` | [`sandbox/`](../packages/sandbox/README.md) | shared sandbox policy home | | `ctx.sandboxPolicy` | [`sandbox/`](../packages/sandbox/README.md) | shared sandbox policy home |
| `ctx.codeRuntime` | [`code-runtime/`](../packages/code-runtime/README.md) | model-written program execution | | `ctx.codeRuntime` | [`code-runtime/`](../packages/code-runtime/README.md) | model-written program execution |
@@ -151,7 +152,7 @@ Streaming uses raw chunks (`block-start` through `finish`) and `BlockAssembler`.
A swappable capability usually splits into **interface / implementation / consumer**: the interface owns its `ctx` key and events, an implementation registers a backend, and a consumer exposes model behavior through tools or prompts. Bash is the reference; the [capability graph](capability-seams.md) shows every family. A swappable capability usually splits into **interface / implementation / consumer**: the interface owns its `ctx` key and events, an implementation registers a backend, and a consumer exposes model behavior through tools or prompts. Bash is the reference; the [capability graph](capability-seams.md) shows every family.
Some seams bend the template deliberately: LLM combines interface and consumer because adapters implement it; filesystem wraps provider primitives with policy; web keeps search/fetch provider registries behind one service; skills and subagents use named providers. Subagents spawn fresh, fork a completed-turn prefix, or use ACP children ([subagent.md](core-data-structures/subagent.md)). Exceptions combine layers deliberately: LLM joins interface and consumer; filesystem wraps providers with policy; web unifies search/fetch registries; skills and subagents use named providers. Subagents spawn fresh, fork a completed-turn prefix, or use ACP children ([subagent.md](core-data-structures/subagent.md)).
`dsh-workspace-context` composes baselines on `agent/session-prefix` and appends `ctx.fs`-discovered nested changes on `tools/post-execute`; its [decision](../.agents/notes/implemented/feature/2026-06-24-workspace-context.md) records isolation. `dsh-paths` owns shared paths. `dsh-workspace-context` composes baselines on `agent/session-prefix` and appends `ctx.fs`-discovered nested changes on `tools/post-execute`; its [decision](../.agents/notes/implemented/feature/2026-06-24-workspace-context.md) records isolation. `dsh-paths` owns shared paths.
@@ -168,6 +169,7 @@ New behavior should attach to a documented extension point; changing the shipped
| Add a model provider | register an adapter on `ctx.llm` | | Add a model provider | register an adapter on `ctx.llm` |
| Add a model-facing capability | register a tool on `ctx.tools`; schemas flow into prompt assembly | | Add a model-facing capability | register a tool on `ctx.tools`; schemas flow into prompt assembly |
| Add command execution | implement and register a `ctx.bash` backend | | Add command execution | implement and register a `ctx.bash` backend |
| Add persistent terminal execution | register a `ctx.pty` backend and `dsh-tool-pty` |
| Add a long-running/background capability | register the work on `ctx.tasks`; the generic `task_*` tools collect/stop it | | Add a long-running/background capability | register the work on `ctx.tasks`; the generic `task_*` tools collect/stop it |
| Add filesystem access or policy | implement a `ctx.fs` provider or listen on `fs/*` policy events | | Add filesystem access or policy | implement a `ctx.fs` provider or listen on `fs/*` policy events |
| Confine spawned processes | a `ctx.sandbox` backend; consumers wrap their argv before spawning | | Confine spawned processes | a `ctx.sandbox` backend; consumers wrap their argv before spawning |
+18 -5
View File
@@ -36,6 +36,7 @@ flowchart LR
svc_systemPrompt["ctx.systemPrompt<br/>System prompt assembly registry"] svc_systemPrompt["ctx.systemPrompt<br/>System prompt assembly registry"]
pkg_tools["tools"] pkg_tools["tools"]
pkg_tool_fs["tool-fs"] pkg_tool_fs["tool-fs"]
pkg_tool_pty["tool-pty"]
pkg_tool_web["tool-web"] pkg_tool_web["tool-web"]
svc_tools["ctx.tools<br/>Tool registry and guarded execution pipeline"] svc_tools["ctx.tools<br/>Tool registry and guarded execution pipeline"]
pkg_tool_ask_user["tool-ask-user"] pkg_tool_ask_user["tool-ask-user"]
@@ -57,6 +58,9 @@ flowchart LR
pkg_bash_local["bash-local"] pkg_bash_local["bash-local"]
pkg_bash_sandbox["bash-sandbox"] pkg_bash_sandbox["bash-sandbox"]
svc_bashEnv["ctx.bashEnv<br/>Managed bash environment registry"] svc_bashEnv["ctx.bashEnv<br/>Managed bash environment registry"]
pkg_pty["pty"]
svc_pty["ctx.pty<br/>Persistent PTY session registry"]
pkg_pty_local["pty-local"]
pkg_sandbox["sandbox"] pkg_sandbox["sandbox"]
svc_sandbox["ctx.sandbox<br/>Process-sandbox seam"] svc_sandbox["ctx.sandbox<br/>Process-sandbox seam"]
pkg_sandbox_local["sandbox-local"] pkg_sandbox_local["sandbox-local"]
@@ -118,6 +122,8 @@ flowchart LR
pkg_llm_pi_ai --> svc_llm pkg_llm_pi_ai --> svc_llm
pkg_llm_replay --> svc_llm pkg_llm_replay --> svc_llm
pkg_permission --> svc_permission pkg_permission --> svc_permission
pkg_pty --> svc_pty
pkg_pty_local --> svc_pty
pkg_sandbox --> svc_sandbox pkg_sandbox --> svc_sandbox
pkg_sandbox_local --> svc_sandbox pkg_sandbox_local --> svc_sandbox
pkg_sandbox_policy --> svc_sandboxPolicy pkg_sandbox_policy --> svc_sandboxPolicy
@@ -166,9 +172,12 @@ flowchart LR
svc_llm --> pkg_agent_loop svc_llm --> pkg_agent_loop
svc_llm --> pkg_compact_basic svc_llm --> pkg_compact_basic
svc_permission --> pkg_acp svc_permission --> pkg_acp
svc_pty --> pkg_tool_pty
svc_sandbox --> pkg_bash_sandbox svc_sandbox --> pkg_bash_sandbox
svc_sandbox --> pkg_pty_local
svc_sandboxPolicy --> pkg_bash_sandbox svc_sandboxPolicy --> pkg_bash_sandbox
svc_sandboxPolicy --> pkg_fs_sandbox svc_sandboxPolicy --> pkg_fs_sandbox
svc_sandboxPolicy --> pkg_pty_local
svc_sessionPersistence --> pkg_acp svc_sessionPersistence --> pkg_acp
svc_sessionPersistence --> pkg_agent_loop svc_sessionPersistence --> pkg_agent_loop
svc_sessionPersistence --> pkg_hooks_claude svc_sessionPersistence --> pkg_hooks_claude
@@ -187,9 +196,11 @@ flowchart LR
svc_subagents --> pkg_tool_subagent svc_subagents --> pkg_tool_subagent
svc_systemPrompt --> pkg_agent_loop svc_systemPrompt --> pkg_agent_loop
svc_systemPrompt --> pkg_tool_fs svc_systemPrompt --> pkg_tool_fs
svc_systemPrompt --> pkg_tool_pty
svc_systemPrompt --> pkg_tool_web svc_systemPrompt --> pkg_tool_web
svc_systemPrompt --> pkg_tools svc_systemPrompt --> pkg_tools
svc_tasks --> pkg_tool_bash svc_tasks --> pkg_tool_bash
svc_tasks --> pkg_tool_pty
svc_tasks --> pkg_tool_subagent svc_tasks --> pkg_tool_subagent
svc_tasks --> pkg_tool_tasks svc_tasks --> pkg_tool_tasks
svc_tokenMeter --> pkg_compact_basic svc_tokenMeter --> pkg_compact_basic
@@ -199,6 +210,7 @@ flowchart LR
svc_tools --> pkg_tool_bash svc_tools --> pkg_tool_bash
svc_tools --> pkg_tool_cordis svc_tools --> pkg_tool_cordis
svc_tools --> pkg_tool_fs svc_tools --> pkg_tool_fs
svc_tools --> pkg_tool_pty
svc_tools --> pkg_tool_skill svc_tools --> pkg_tool_skill
svc_tools --> pkg_tool_subagent svc_tools --> pkg_tool_subagent
svc_tools --> pkg_tool_todo svc_tools --> pkg_tool_todo
@@ -218,23 +230,24 @@ flowchart LR
| `ctx.sessions` | `core` | [`session`](../packages/core/session) | - | [`agent-loop`](../packages/core/agent-loop), [`agent`](../packages/core/agent), [`cli-demo`](../packages/examples/cli-demo), [`session-persistence`](../packages/session-persistence/session-persistence), [`session-query`](../packages/session-query/session-query), [`subagent-inprocess`](../packages/subagent/subagent-inprocess), [`invariants`](../packages/support/invariants) | - | Owns append-only Session instances and emits the durable session event feed. | | `ctx.sessions` | `core` | [`session`](../packages/core/session) | - | [`agent-loop`](../packages/core/agent-loop), [`agent`](../packages/core/agent), [`cli-demo`](../packages/examples/cli-demo), [`session-persistence`](../packages/session-persistence/session-persistence), [`session-query`](../packages/session-query/session-query), [`subagent-inprocess`](../packages/subagent/subagent-inprocess), [`invariants`](../packages/support/invariants) | - | Owns append-only Session instances and emits the durable session event feed. |
| `ctx.sessionPersistence` | `seam` | [`session-persistence`](../packages/session-persistence/session-persistence) | [`session-persistence-jsonl`](../packages/session-persistence/session-persistence-jsonl), [`session-persistence-sqlite`](../packages/session-persistence/session-persistence-sqlite) | [`agent-loop`](../packages/core/agent-loop), [`tool-bash`](../packages/bash/tool-bash), [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex), [`acp`](../packages/ui/acp), [`session-query`](../packages/session-query/session-query) | - | Backends persist the same SessionEvent vocabulary; apps choose a backend at composition time. | | `ctx.sessionPersistence` | `seam` | [`session-persistence`](../packages/session-persistence/session-persistence) | [`session-persistence-jsonl`](../packages/session-persistence/session-persistence-jsonl), [`session-persistence-sqlite`](../packages/session-persistence/session-persistence-sqlite) | [`agent-loop`](../packages/core/agent-loop), [`tool-bash`](../packages/bash/tool-bash), [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex), [`acp`](../packages/ui/acp), [`session-query`](../packages/session-query/session-query) | - | Backends persist the same SessionEvent vocabulary; apps choose a backend at composition time. |
| `ctx.sessionQuery` | `seam` | [`session-query`](../packages/session-query/session-query) | - | - | - | Resolves live and optional persisted logs into one logical corpus for exact reads and relationship traces. | | `ctx.sessionQuery` | `seam` | [`session-query`](../packages/session-query/session-query) | - | - | - | Resolves live and optional persisted logs into one logical corpus for exact reads and relationship traces. |
| `ctx.systemPrompt` | `core` | [`system-prompt`](../packages/core/system-prompt) | - | [`agent-loop`](../packages/core/agent-loop), [`tools`](../packages/core/tools), [`tool-fs`](../packages/fs/tool-fs), [`tool-web`](../packages/web/tool-web) | - | Collects prompt sections and model-facing tool schemas for each step. | | `ctx.systemPrompt` | `core` | [`system-prompt`](../packages/core/system-prompt) | - | [`agent-loop`](../packages/core/agent-loop), [`tools`](../packages/core/tools), [`tool-fs`](../packages/fs/tool-fs), [`tool-pty`](../packages/pty/tool-pty), [`tool-web`](../packages/web/tool-web) | - | Collects prompt sections and model-facing tool schemas for each step. |
| `ctx.tools` | `core` | [`tools`](../packages/core/tools) | - | [`agent-loop`](../packages/core/agent-loop), [`tool-ask-user`](../packages/ui/tool-ask-user), [`tool-bash`](../packages/bash/tool-bash), [`tool-cordis`](../packages/cordis/tool-cordis), [`tool-fs`](../packages/fs/tool-fs), [`tool-skill`](../packages/skill/tool-skill), [`tool-subagent`](../packages/subagent/tool-subagent), [`tool-todo`](../packages/todo/tool-todo), [`tool-web`](../packages/web/tool-web), [`acp`](../packages/ui/acp) | - | Registers capabilities, owns Code Mode transport, and routes calls through pre-policy, monotonic guards, around dispatch, post-policy, and final-result observation. | | `ctx.tools` | `core` | [`tools`](../packages/core/tools) | - | [`agent-loop`](../packages/core/agent-loop), [`tool-ask-user`](../packages/ui/tool-ask-user), [`tool-bash`](../packages/bash/tool-bash), [`tool-cordis`](../packages/cordis/tool-cordis), [`tool-fs`](../packages/fs/tool-fs), [`tool-pty`](../packages/pty/tool-pty), [`tool-skill`](../packages/skill/tool-skill), [`tool-subagent`](../packages/subagent/tool-subagent), [`tool-todo`](../packages/todo/tool-todo), [`tool-web`](../packages/web/tool-web), [`acp`](../packages/ui/acp) | - | Registers capabilities, owns Code Mode transport, and routes calls through pre-policy, monotonic guards, around dispatch, post-policy, and final-result observation. |
| `ctx.userInteraction` | `seam` | [`user-interaction`](../packages/ui/user-interaction) | [`stdio-demo`](../packages/examples/stdio-demo), [`acp`](../packages/ui/acp) | [`tool-ask-user`](../packages/ui/tool-ask-user), [`stdio-demo`](../packages/examples/stdio-demo), [`acp`](../packages/ui/acp) | - | UI front doors provide the active human-answer provider; tool-ask-user pauses a tool call on the provider-neutral ask() promise. | | `ctx.userInteraction` | `seam` | [`user-interaction`](../packages/ui/user-interaction) | [`stdio-demo`](../packages/examples/stdio-demo), [`acp`](../packages/ui/acp) | [`tool-ask-user`](../packages/ui/tool-ask-user), [`stdio-demo`](../packages/examples/stdio-demo), [`acp`](../packages/ui/acp) | - | UI front doors provide the active human-answer provider; tool-ask-user pauses a tool call on the provider-neutral ask() promise. |
| `ctx.skills` | `seam` | [`skill`](../packages/skill/skill) | [`skill-local`](../packages/skill/skill-local) | [`tool-skill`](../packages/skill/tool-skill) | - | Merges provider skill catalogs; tool-skill renders the session-prefix catalog and loads complete skill bodies. | | `ctx.skills` | `seam` | [`skill`](../packages/skill/skill) | [`skill-local`](../packages/skill/skill-local) | [`tool-skill`](../packages/skill/tool-skill) | - | Merges provider skill catalogs; tool-skill renders the session-prefix catalog and loads complete skill bodies. |
| `ctx.agents` | `core` | [`agent`](../packages/core/agent) | - | [`agent-loop`](../packages/core/agent-loop), [`acp`](../packages/ui/acp), [`cli-demo`](../packages/examples/cli-demo), [`subagent-inprocess`](../packages/subagent/subagent-inprocess), [`stdio-demo`](../packages/examples/stdio-demo), [`invariants`](../packages/support/invariants) | - | Owns live Agent handles, the create/resume factory seam, and process-local initiator propagation. | | `ctx.agents` | `core` | [`agent`](../packages/core/agent) | - | [`agent-loop`](../packages/core/agent-loop), [`acp`](../packages/ui/acp), [`cli-demo`](../packages/examples/cli-demo), [`subagent-inprocess`](../packages/subagent/subagent-inprocess), [`stdio-demo`](../packages/examples/stdio-demo), [`invariants`](../packages/support/invariants) | - | Owns live Agent handles, the create/resume factory seam, and process-local initiator propagation. |
| `ctx.agentLoop` | `bundle` | [`agent-loop`](../packages/core/agent-loop) | - | [`agent-spine-demo`](../packages/examples/agent-spine-demo) | - | The one concrete loop plugin; extension packages depend on dsh-agent events and services, not on this package. | | `ctx.agentLoop` | `bundle` | [`agent-loop`](../packages/core/agent-loop) | - | [`agent-spine-demo`](../packages/examples/agent-spine-demo) | - | The one concrete loop plugin; extension packages depend on dsh-agent events and services, not on this package. |
| `ctx.bash` | `seam` | [`bash`](../packages/bash/bash) | [`bash-local`](../packages/bash/bash-local), [`bash-sandbox`](../packages/bash/bash-sandbox) | [`tool-bash`](../packages/bash/tool-bash), [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex) | - | The model-facing bash tools and hook bridges consume this seam; sandboxed or remote executors replace bash-local without touching them. | | `ctx.bash` | `seam` | [`bash`](../packages/bash/bash) | [`bash-local`](../packages/bash/bash-local), [`bash-sandbox`](../packages/bash/bash-sandbox) | [`tool-bash`](../packages/bash/tool-bash), [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex) | - | The model-facing bash tools and hook bridges consume this seam; sandboxed or remote executors replace bash-local without touching them. |
| `ctx.bashEnv` | `core` | [`tool-bash`](../packages/bash/tool-bash) | - | - | - | Plugins declare effect-scoped DSH_* facts; tool-bash collects one trusted snapshot per execution and the executor rebuilds the namespace. | | `ctx.bashEnv` | `core` | [`tool-bash`](../packages/bash/tool-bash) | - | - | - | Plugins declare effect-scoped DSH_* facts; tool-bash collects one trusted snapshot per execution and the executor rebuilds the namespace. |
| `ctx.sandbox` | `seam` | [`sandbox`](../packages/sandbox/sandbox) | [`sandbox-local`](../packages/sandbox/sandbox-local) | [`bash-sandbox`](../packages/bash/bash-sandbox) | - | Consumers hand over the exact argv they are about to spawn; same-world backends wrap it under a per-call policy and report enforcement. | | `ctx.pty` | `seam` | [`pty`](../packages/pty/pty) | [`pty-local`](../packages/pty/pty-local) | [`tool-pty`](../packages/pty/tool-pty) | - | The registry owns exact-Agent session identity and cleanup; backends own terminal mechanics, while tool-pty exposes the owner-scoped model surface. |
| `ctx.sandboxPolicy` | `core` | [`sandbox-policy`](../packages/sandbox/sandbox-policy) | - | [`bash-sandbox`](../packages/bash/bash-sandbox), [`fs-sandbox`](../packages/fs/fs-sandbox) | - | The one home for the deployment default mode + workspace root; only the sandboxed executor and provider read the service (the tool layers use the pure `sandbox/mode` fold it also exports). Both enforcing families read it so bash and fs cannot confine to different roots. | | `ctx.sandbox` | `seam` | [`sandbox`](../packages/sandbox/sandbox) | [`sandbox-local`](../packages/sandbox/sandbox-local) | [`bash-sandbox`](../packages/bash/bash-sandbox), [`pty-local`](../packages/pty/pty-local) | - | Consumers hand over the exact argv they are about to spawn; same-world backends wrap it under a per-call policy and report enforcement. |
| `ctx.sandboxPolicy` | `core` | [`sandbox-policy`](../packages/sandbox/sandbox-policy) | - | [`bash-sandbox`](../packages/bash/bash-sandbox), [`fs-sandbox`](../packages/fs/fs-sandbox), [`pty-local`](../packages/pty/pty-local) | - | The one home for the deployment default mode + workspace root; only the sandboxed executor and provider read the service (the tool layers use the pure `sandbox/mode` fold it also exports). Both enforcing families read it so bash and fs cannot confine to different roots. |
| `ctx.approval` | `seam` | `approval` | [`acp`](../packages/ui/acp) | [`tools`](../packages/core/tools), [`tool-bash`](../packages/bash/tool-bash) | - | One-shot permission decisions dispatched over the `approval/request` waterfall; answerers are listeners (the ACP bridge for its own agents), absence fails closed to `unavailable`. | | `ctx.approval` | `seam` | `approval` | [`acp`](../packages/ui/acp) | [`tools`](../packages/core/tools), [`tool-bash`](../packages/bash/tool-bash) | - | One-shot permission decisions dispatched over the `approval/request` waterfall; answerers are listeners (the ACP bridge for its own agents), absence fails closed to `unavailable`. |
| `ctx.permission` | `core` | [`permission`](../packages/ui/permission) | - | [`acp`](../packages/ui/acp) | - | User-facing preset table (`workspace-write`/`danger-full-access`) bundling the sandbox-mode and approval-policy knobs; a switch writes one `permission/preset` event through to both knob events. | | `ctx.permission` | `core` | [`permission`](../packages/ui/permission) | - | [`acp`](../packages/ui/acp) | - | User-facing preset table (`workspace-write`/`danger-full-access`) bundling the sandbox-mode and approval-policy knobs; a switch writes one `permission/preset` event through to both knob events. |
| `ctx.codeRuntime` | `seam` | [`code-runtime`](../packages/code-runtime/code-runtime) | [`code-runtime-worker`](../packages/code-runtime/code-runtime-worker) | [`tools`](../packages/core/tools) | - | Runs one model-written program against host-provided async bindings; backends differ by substrate and language (the tool registry consumes it for Code Mode). | | `ctx.codeRuntime` | `seam` | [`code-runtime`](../packages/code-runtime/code-runtime) | [`code-runtime-worker`](../packages/code-runtime/code-runtime-worker) | [`tools`](../packages/core/tools) | - | Runs one model-written program against host-provided async bindings; backends differ by substrate and language (the tool registry consumes it for Code Mode). |
| `ctx.fs` | `seam` | [`fs`](../packages/fs/fs) | [`fs-local`](../packages/fs/fs-local), [`fs-sandbox`](../packages/fs/fs-sandbox) | [`tool-fs`](../packages/fs/tool-fs) | [`fs-policy`](../packages/fs/fs-policy) | tool-fs executes read/write/edit through ctx.fs; fs-sandbox fences mutations by the shared sandbox mode; fs-policy contributes observed-state checks through the fs/* event gate. | | `ctx.fs` | `seam` | [`fs`](../packages/fs/fs) | [`fs-local`](../packages/fs/fs-local), [`fs-sandbox`](../packages/fs/fs-sandbox) | [`tool-fs`](../packages/fs/tool-fs) | [`fs-policy`](../packages/fs/fs-policy) | tool-fs executes read/write/edit through ctx.fs; fs-sandbox fences mutations by the shared sandbox mode; fs-policy contributes observed-state checks through the fs/* event gate. |
| `ctx.compact` | `seam` | [`compact`](../packages/compact/compact) | [`compact-basic`](../packages/compact/compact-basic) | [`compact-basic`](../packages/compact/compact-basic) | - | The basic backend consumes post-step pressure and request-error recovery events; a model-facing compact tool remains deferred. | | `ctx.compact` | `seam` | [`compact`](../packages/compact/compact) | [`compact-basic`](../packages/compact/compact-basic) | [`compact-basic`](../packages/compact/compact-basic) | - | The basic backend consumes post-step pressure and request-error recovery events; a model-facing compact tool remains deferred. |
| `ctx.subagents` | `seam` | [`subagent`](../packages/subagent/subagent) | [`subagent-spawn`](../packages/subagent/subagent-spawn), [`subagent-fork`](../packages/subagent/subagent-fork), [`subagent-acp`](../packages/subagent/subagent-acp) | [`tool-subagent`](../packages/subagent/tool-subagent) | - | Providers implement transports; tool-subagent exposes one configured provider as a model-facing tool name. | | `ctx.subagents` | `seam` | [`subagent`](../packages/subagent/subagent) | [`subagent-spawn`](../packages/subagent/subagent-spawn), [`subagent-fork`](../packages/subagent/subagent-fork), [`subagent-acp`](../packages/subagent/subagent-acp) | [`tool-subagent`](../packages/subagent/tool-subagent) | - | Providers implement transports; tool-subagent exposes one configured provider as a model-facing tool name. |
| `ctx.tasks` | `core` | [`tasks`](../packages/tasks/tasks) | - | [`tool-bash`](../packages/bash/tool-bash), [`tool-subagent`](../packages/subagent/tool-subagent), [`tool-tasks`](../packages/tasks/tool-tasks) | - | Producers (tool-bash background commands, tool-subagent background delegations) register running work; tool-tasks is the model-facing control surface that reads, lists, and kills it. | | `ctx.tasks` | `core` | [`tasks`](../packages/tasks/tasks) | - | [`tool-bash`](../packages/bash/tool-bash), [`tool-pty`](../packages/pty/tool-pty), [`tool-subagent`](../packages/subagent/tool-subagent), [`tool-tasks`](../packages/tasks/tool-tasks) | - | Producers (background bash, PTY sends, and subagent delegations) register running work; tool-tasks is the model-facing control surface that reads, lists, and kills it. |
| `ctx.web` | `seam` | [`web`](../packages/web/web) | [`web-search-exa`](../packages/web/web-search-exa), [`web-search-perplexity`](../packages/web/web-search-perplexity), [`web-search-deepseek`](../packages/web/web-search-deepseek), [`web-fetch-local`](../packages/web/web-fetch-local) | [`tool-web`](../packages/web/tool-web) | - | Search and fetch providers register into one ctx.web seam; tool-web owns the stable model-facing names. | | `ctx.web` | `seam` | [`web`](../packages/web/web) | [`web-search-exa`](../packages/web/web-search-exa), [`web-search-perplexity`](../packages/web/web-search-perplexity), [`web-search-deepseek`](../packages/web/web-search-deepseek), [`web-fetch-local`](../packages/web/web-fetch-local) | [`tool-web`](../packages/web/tool-web) | - | Search and fetch providers register into one ctx.web seam; tool-web owns the stable model-facing names. |
| `ctx.spillStore` | `seam` | [`spill`](../packages/spill/spill) | [`spill-local`](../packages/spill/spill-local) | [`spill-policy`](../packages/spill/spill-policy) | - | The backend saves oversized tool text and returns a model-facing locator plus retrieval hint; spill-policy is the tools/post-execute consumer that decides when to spill. | | `ctx.spillStore` | `seam` | [`spill`](../packages/spill/spill) | [`spill-local`](../packages/spill/spill-local) | [`spill-policy`](../packages/spill/spill-policy) | - | The backend saves oversized tool text and returns a model-facing locator plus retrieval hint; spill-policy is the tools/post-execute consumer that decides when to spill. |
| `ctx.workflows` | `seam` | [`workflow`](../packages/workflow/workflow) | [`workflow-workerthread`](../packages/workflow/workflow-workerthread) | [`tool-workflow`](../packages/workflow/tool-workflow) | - | One engine per context (bash shape, no named-provider registry); the worker-thread engine fans agent() calls out through ctx.subagents. | | `ctx.workflows` | `seam` | [`workflow`](../packages/workflow/workflow) | [`workflow-workerthread`](../packages/workflow/workflow-workerthread) | [`tool-workflow`](../packages/workflow/tool-workflow) | - | One engine per context (bash shape, no named-provider registry); the worker-thread engine fans agent() calls out through ctx.subagents. |
+40
View File
@@ -624,6 +624,44 @@ Depends on: [`ApprovalPolicy`](core-data-structures/approval.md) · [`SandboxMod
Source: [`packages/ui/permission/src/index.ts:83`](../packages/ui/permission/src/index.ts) Source: [`packages/ui/permission/src/index.ts:83`](../packages/ui/permission/src/index.ts)
## `@deepseek-ai/dsh-pty-local`
Requires: `pty` · `sandbox` · `sandboxPolicy`
```ts config-catalog
/** Public plugin configuration. */
export interface Config {
/** Backend registry type (default: `shell`). */
backendType?: string
/** Interactive shell executable (default: `/bin/bash`). */
shellPath?: string
/** Shell arguments (default: `--noprofile --norc -i`). */
shellArgs?: string[]
/** Terminal rows. */
rows?: number
/** Terminal columns. */
cols?: number
/** Maximum retained logical lines. */
scrollbackLines?: number
/** Maximum retained UTF-8 bytes. */
scrollbackMaxBytes?: number
/** Maximum bytes returned by one read or settled viewport. */
maxReadBytes?: number
/** Readiness polling interval. */
pollIntervalMs?: number
/** Delay before Linux exact syscall probes. */
exactProbeAfterMs?: number
/** Silence duration that yields `inferred_idle`. */
idleSilenceMs?: number
/** Absolute send wait bound. */
timeoutMs?: number
/** Grace before teardown escalates to `SIGKILL`. */
disposeGraceMs?: number
}
```
Source: [`packages/pty/pty-local/src/config.ts:6`](../packages/pty/pty-local/src/config.ts)
## `@deepseek-ai/dsh-repeat-tool-guard` ## `@deepseek-ai/dsh-repeat-tool-guard`
```ts config-catalog ```ts config-catalog
@@ -1502,11 +1540,13 @@ These load from a `cordis.yml` entry with no `config:` block; they declare no co
- `@deepseek-ai/dsh-fs-policy` ([`packages/fs/fs-policy/src/index.ts`](../packages/fs/fs-policy/src/index.ts)) - `@deepseek-ai/dsh-fs-policy` ([`packages/fs/fs-policy/src/index.ts`](../packages/fs/fs-policy/src/index.ts))
- `@deepseek-ai/dsh-invariants` — requires `sessions` ([`packages/support/invariants/src/index.ts`](../packages/support/invariants/src/index.ts)) - `@deepseek-ai/dsh-invariants` — requires `sessions` ([`packages/support/invariants/src/index.ts`](../packages/support/invariants/src/index.ts))
- `@deepseek-ai/dsh-llm` ([`packages/llm/llm/src/index.ts`](../packages/llm/llm/src/index.ts)) - `@deepseek-ai/dsh-llm` ([`packages/llm/llm/src/index.ts`](../packages/llm/llm/src/index.ts))
- `@deepseek-ai/dsh-pty` ([`packages/pty/pty/src/index.ts`](../packages/pty/pty/src/index.ts))
- `@deepseek-ai/dsh-session` ([`packages/core/session/src/index.ts`](../packages/core/session/src/index.ts)) - `@deepseek-ai/dsh-session` ([`packages/core/session/src/index.ts`](../packages/core/session/src/index.ts))
- `@deepseek-ai/dsh-subagent` ([`packages/subagent/subagent/src/index.ts`](../packages/subagent/subagent/src/index.ts)) - `@deepseek-ai/dsh-subagent` ([`packages/subagent/subagent/src/index.ts`](../packages/subagent/subagent/src/index.ts))
- `@deepseek-ai/dsh-tasks` ([`packages/tasks/tasks/src/index.ts`](../packages/tasks/tasks/src/index.ts)) - `@deepseek-ai/dsh-tasks` ([`packages/tasks/tasks/src/index.ts`](../packages/tasks/tasks/src/index.ts))
- `@deepseek-ai/dsh-timeout-policy` — requires `tools` ([`packages/timeout/timeout-policy/src/index.ts`](../packages/timeout/timeout-policy/src/index.ts)) - `@deepseek-ai/dsh-timeout-policy` — requires `tools` ([`packages/timeout/timeout-policy/src/index.ts`](../packages/timeout/timeout-policy/src/index.ts))
- `@deepseek-ai/dsh-tool-ask-user` — requires `tools` · `userInteraction` ([`packages/ui/tool-ask-user/src/index.ts`](../packages/ui/tool-ask-user/src/index.ts)) - `@deepseek-ai/dsh-tool-ask-user` — requires `tools` · `userInteraction` ([`packages/ui/tool-ask-user/src/index.ts`](../packages/ui/tool-ask-user/src/index.ts))
- `@deepseek-ai/dsh-tool-pty` — requires `pty` · `tools` · `systemPrompt` ([`packages/pty/tool-pty/src/index.ts`](../packages/pty/tool-pty/src/index.ts))
- `@deepseek-ai/dsh-tool-todo` — requires `tools` ([`packages/todo/tool-todo/src/index.ts`](../packages/todo/tool-todo/src/index.ts)) - `@deepseek-ai/dsh-tool-todo` — requires `tools` ([`packages/todo/tool-todo/src/index.ts`](../packages/todo/tool-todo/src/index.ts))
- `@deepseek-ai/dsh-user-interaction` ([`packages/ui/user-interaction/src/index.ts`](../packages/ui/user-interaction/src/index.ts)) - `@deepseek-ai/dsh-user-interaction` ([`packages/ui/user-interaction/src/index.ts`](../packages/ui/user-interaction/src/index.ts))
+75
View File
@@ -577,6 +577,81 @@ Types: [Session](../core-data-structures/session.md) · [SessionEvent](../core-d
Source: [`packages/ui/permission/src/index.ts:97`](../../packages/ui/permission/src/index.ts) Source: [`packages/ui/permission/src/index.ts:97`](../../packages/ui/permission/src/index.ts)
## `ctx.pty` — `PtyService`
In-process registry for replaceable PTY backends and exact-Agent sessions.
```ts cordis-catalog
/**
* Register one backend type for this effect scope.
* @param backend - provider with a non-empty unique type.
* @returns disposer that removes exactly this contribution.
*/
registerBackend(backend: PtyBackend): () => void
/**
* List registered backend types in registration order.
* @returns fresh backend type names.
*/
listBackends(): string[]
/**
* Create and publish one owner-scoped session after backend setup succeeds.
* @param owner - exact registered Agent that owns access and cleanup.
* @param request - backend type plus optional owner-local name and cwd.
* @param signal - cancellation of unpublished setup.
* @returns published identity, metadata, status, and MOTD.
*/
async spawn(owner: Agent, request: PtySpawnRequest, signal?: AbortSignal): Promise<PtySpawnResult>
/**
* Start one exclusive interactive send.
* @param owner - exact session owner.
* @param id - target PTY identity.
* @param request - explicit text, submit behavior, and cancellation.
* @returns live operation handle for foreground await or task registration.
*/
startSend(owner: Agent, id: PtySessionId, request: PtySendRequest): PtySendOperation
/**
* Read one bounded scrollback page from an owned session.
* @param owner - exact session owner.
* @param id - target PTY identity.
* @param request - optional newest-relative offset and line count.
* @returns bounded retained text and pagination metadata.
*/
read(owner: Agent, id: PtySessionId, request: PtyReadRequest = {}): PtyReadResult
/**
* Deliver an allowed signal through an owned backend session.
* @param owner - exact session owner.
* @param id - target PTY identity.
* @param signal - allowed POSIX signal name.
* @returns delivered foreground process-group identity.
*/
signal(owner: Agent, id: PtySessionId, signal: PtySignal): Promise<PtySignalResult>
/**
* Close one owned session and remove it only after quiescent backend cleanup.
* @param owner - exact session owner.
* @param id - target PTY identity.
* @param reason - diagnostic cleanup reason.
* @returns true for a newly closed session, false when the same close is already in flight.
*/
async kill(owner: Agent, id: PtySessionId, reason = 'model request'): Promise<boolean>
/**
* List fresh snapshots for exactly one owner.
* @param owner - exact owner whose sessions are visible.
* @returns owner-visible snapshots in publication order.
*/
list(owner: Agent): PtySessionSnapshot[]
```
Types: [Agent](../core-data-structures/core.md) · [PtyBackend](../core-data-structures/pty.md) · [PtyReadRequest](../core-data-structures/pty.md) · [PtyReadResult](../core-data-structures/pty.md) · [PtySendOperation](../core-data-structures/pty.md) · [PtySendRequest](../core-data-structures/pty.md) · [PtySessionId](../core-data-structures/pty.md) · [PtySessionSnapshot](../core-data-structures/pty.md) · [PtySignal](../core-data-structures/pty.md) · [PtySignalResult](../core-data-structures/pty.md) · [PtySpawnRequest](../core-data-structures/pty.md) · [PtySpawnResult](../core-data-structures/pty.md)
Source: [`packages/pty/pty/src/index.ts:95`](../../packages/pty/pty/src/index.ts)
## `ctx.sandbox` — `SandboxProvider` (abstract seam) ## `ctx.sandbox` — `SandboxProvider` (abstract seam)
Abstract process-sandbox service. confine must return enforcing argv or fail closed at wrap or runner-execution time; silent unconfined passthrough is forbidden. Functional probes arbitrate multi-runner chains and may be skipped for a sole candidate, whose own refusal remains the fail-closed end. Abstract process-sandbox service. confine must return enforcing argv or fail closed at wrap or runner-execution time; silent unconfined passthrough is forbidden. Functional probes arbitrate multi-runner chains and may be skipped for a sole candidate, whose own refusal remains the fail-closed end.
+1
View File
@@ -26,6 +26,7 @@ Everything else is documented on a **sub-page**, not here. The rule that draws t
| [user-interaction.md](user-interaction.md) | the UI-backed human question/answer seam: `AskUserQuestionRequest`, answer/options vocabulary, provider API, error taxonomy | | [user-interaction.md](user-interaction.md) | the UI-backed human question/answer seam: `AskUserQuestionRequest`, answer/options vocabulary, provider API, error taxonomy |
| [approval.md](approval.md) | the one-shot user-approval seam: `ApprovalRequest`, `ApprovalOutcome`, per-session policy, audit and answerer contracts | | [approval.md](approval.md) | the one-shot user-approval seam: `ApprovalRequest`, `ApprovalOutcome`, per-session policy, audit and answerer contracts |
| [bash.md](bash.md) | the bash executor seam: `BashExecRequest`/`Spec`, `BashRunResult`, background `BashProcess` handles | | [bash.md](bash.md) | the bash executor seam: `BashExecRequest`/`Spec`, `BashRunResult`, background `BashProcess` handles |
| [pty.md](pty.md) | persistent terminal ids, backend/session contracts, send readiness, bounded reads, and owner-visible snapshots |
| [sandbox.md](sandbox.md) | the process-confinement seam: file-effect modes, `SandboxPolicy`, `ConfinedArgv`, enforcement and fail-closed errors | | [sandbox.md](sandbox.md) | the process-confinement seam: file-effect modes, `SandboxPolicy`, `ConfinedArgv`, enforcement and fail-closed errors |
| [code-runtime.md](code-runtime.md) | the code-execution seam: `CodeRunRequest`/`Result`, binding namespaces, captured logs, the `CodeRunFailure` taxonomy | | [code-runtime.md](code-runtime.md) | the code-execution seam: `CodeRunRequest`/`Result`, binding namespaces, captured logs, the `CodeRunFailure` taxonomy |
| [filesystem.md](filesystem.md) | the filesystem seam: `FsTarget`, read/write/edit outcomes, observed-file state, `FsErrorCode` | | [filesystem.md](filesystem.md) | the filesystem seam: `FsTarget`, read/write/edit outcomes, observed-file state, `FsErrorCode` |
+89
View File
@@ -0,0 +1,89 @@
# Persistent PTY Sessions
Types shared by PTY backends, `ctx.pty`, and the model-facing consumer. The [persistent PTY Agent Note](../../.agents/notes/implemented/feature/2026-07-16-persistent-pty-sessions.md) owns the rationale; this page records the cross-package vocabulary from [`packages/pty/pty/src/types.ts`](../../packages/pty/pty/src/types.ts).
## Identity and readiness
`PtySessionId` is a service-minted branded id. Optional names are owner-local display metadata; authorization compares the exact owning `Agent`, not a name or guessed id.
`PtyWaitReason` says why one send returned. It is independent from `PtySessionStatus`: silence or timeout may return while the top-level shell remains alive, while `session_exit` means that shell exited rather than an arbitrary foreground child.
```ts type-equiv
/** Why one interactive send returned control to its caller. */
type PtyWaitReason = 'stdin_read' | 'inferred_idle' | 'timeout' | 'session_exit'
```
```ts type-equiv
/** Top-level PTY process status, independent of a send's wait reason. */
type PtySessionStatus =
| { kind: 'running' }
| { kind: 'exited'; exitCode: number | null; signal: NodeJS.Signals | null }
```
## Backend and live session
A backend owns how one registered type starts and detects readiness. `PtyService` publishes the returned session only after setup succeeds, then owns id authorization and cleanup. A backend session owns terminal state and captured-resource quiescence.
```ts type-equiv
/** Replaceable provider for one PTY session type. */
interface PtyBackend {
/** Stable type selected by {@link PtySpawnRequest.type}. */
readonly type: string
/** Create an unpublished session or reject after cleaning partial resources. */
spawn(spec: PtyBackendSpawnSpec): Promise<PtyBackendSession>
}
```
```ts type-equiv
/** Backend-owned live session retained by {@link PtyService}. */
interface PtyBackendSession {
/** Initial bounded terminal output returned from `pty_spawn`. */
readonly motd: string
/** Top-level process id when one exists. */
readonly pid?: number
/** Start one exclusive send operation. */
startSend(request: PtySendRequest): PtySendOperation
/** Read one bounded page from retained scrollback. */
read(request: PtyReadRequest): PtyReadResult
/** Signal the verified foreground process group. */
signal(signal: PtySignal): Promise<PtySignalResult>
/** Observe top-level process status. */
status(): PtySessionStatus
/** Idempotently close the captured owned process tree and await quiescence. */
close(reason: string): Promise<void>
}
```
## Send and retained output
One live session accepts one active send. Its operation exposes a consuming output cursor for generic background tasks and one terminal result for a foreground caller. `PtyReadResult` separately pages the bounded session scrollback.
```ts type-equiv
/** Live backend-owned send; exactly one may be active per PTY session. */
interface PtySendOperation {
/** Resolves after readiness, timeout, cancellation, or top-level process exit. */
done: Promise<PtySendResult>
/** Consume output produced since the prior call. */
readOutput(): PtySendRead
/** Request `SIGINT`; returns false after the operation settled. */
cancel(): boolean
}
```
```ts type-equiv
/** Settled result for one foreground or background send. */
interface PtySendResult {
/** Bounded rendered terminal delta remaining at settlement. */
viewport: string
/** Why the wait returned; this does not imply arbitrary child-process exit. */
waitReason: PtyWaitReason
/** Top-level session status observed at settlement. */
sessionStatus: PtySessionStatus
/** Whether output was dropped from the operation or retained scrollback. */
truncated: boolean
}
```
## Ownership and durability
`PtyService` attaches one awaited cleanup to the exact owner scope, rejects foreign operations, and keeps sessions alive across backend or tool-plugin reload. PTY state and raw bytes remain process-local. Model input and bounded returned output are durable through the existing `tool/call`, `tool/result`, and task-result paths rather than duplicate PTY session events.
+19
View File
@@ -134,6 +134,11 @@ flowchart TD
subgraph group_mcp["packages/mcp"] subgraph group_mcp["packages/mcp"]
pkg_mcp_client["mcp-client"] pkg_mcp_client["mcp-client"]
end end
subgraph group_pty["packages/pty"]
pkg_pty["pty"]
pkg_pty_local["pty-local"]
pkg_tool_pty["tool-pty"]
end
subgraph group_sandbox["packages/sandbox"] subgraph group_sandbox["packages/sandbox"]
pkg_sandbox["sandbox"] pkg_sandbox["sandbox"]
pkg_sandbox_local["sandbox-local"] pkg_sandbox_local["sandbox-local"]
@@ -230,6 +235,8 @@ flowchart TD
pkg_user_interaction --> pkg_agent pkg_user_interaction --> pkg_agent
pkg_user_interaction --> pkg_llm pkg_user_interaction --> pkg_llm
pkg_time_context --> pkg_agent pkg_time_context --> pkg_agent
pkg_pty --> pkg_agent
pkg_pty --> pkg_brand
pkg_tasks --> pkg_agent pkg_tasks --> pkg_agent
pkg_tasks --> pkg_brand pkg_tasks --> pkg_brand
pkg_tasks --> pkg_session pkg_tasks --> pkg_session
@@ -258,6 +265,9 @@ flowchart TD
pkg_permission --> pkg_sandbox_policy pkg_permission --> pkg_sandbox_policy
pkg_permission --> pkg_session pkg_permission --> pkg_session
pkg_permission --> pkg_user_approval pkg_permission --> pkg_user_approval
pkg_pty_local --> pkg_pty
pkg_pty_local --> pkg_sandbox
pkg_pty_local --> pkg_sandbox_policy
pkg_agent_loop --> pkg_agent pkg_agent_loop --> pkg_agent
pkg_agent_loop --> pkg_llm pkg_agent_loop --> pkg_llm
pkg_agent_loop --> pkg_scope pkg_agent_loop --> pkg_scope
@@ -353,6 +363,12 @@ flowchart TD
pkg_repeat_tool_guard --> pkg_tools pkg_repeat_tool_guard --> pkg_tools
pkg_mcp_client --> pkg_llm pkg_mcp_client --> pkg_llm
pkg_mcp_client --> pkg_tools pkg_mcp_client --> pkg_tools
pkg_tool_pty --> pkg_agent
pkg_tool_pty --> pkg_llm
pkg_tool_pty --> pkg_pty
pkg_tool_pty --> pkg_system_prompt
pkg_tool_pty --> pkg_tasks
pkg_tool_pty --> pkg_tools
pkg_tool_tasks --> pkg_agent pkg_tool_tasks --> pkg_agent
pkg_tool_tasks --> pkg_system_prompt pkg_tool_tasks --> pkg_system_prompt
pkg_tool_tasks --> pkg_tasks pkg_tool_tasks --> pkg_tasks
@@ -514,12 +530,14 @@ flowchart TD
| [`user-approval`](../packages/ui/user-approval) | `ui` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt) | | [`user-approval`](../packages/ui/user-approval) | `ui` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt) |
| [`user-interaction`](../packages/ui/user-interaction) | `ui` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm) | | [`user-interaction`](../packages/ui/user-interaction) | `ui` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm) |
| [`time-context`](../packages/context/time-context) | `context` | [`agent`](../packages/core/agent) | | [`time-context`](../packages/context/time-context) | `context` | [`agent`](../packages/core/agent) |
| [`pty`](../packages/pty/pty) | `pty` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand) |
| [`tasks`](../packages/tasks/tasks) | `tasks` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`session`](../packages/core/session), [`timeout`](../packages/util/timeout) | | [`tasks`](../packages/tasks/tasks) | `tasks` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`session`](../packages/core/session), [`timeout`](../packages/util/timeout) |
| [`workflow`](../packages/workflow/workflow) | `workflow` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | | [`workflow`](../packages/workflow/workflow) | `workflow` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) |
| [`tools`](../packages/core/tools) | `core` | [`agent`](../packages/core/agent), [`code-runtime`](../packages/code-runtime/code-runtime), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`user-approval`](../packages/ui/user-approval) | | [`tools`](../packages/core/tools) | `core` | [`agent`](../packages/core/agent), [`code-runtime`](../packages/code-runtime/code-runtime), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`user-approval`](../packages/ui/user-approval) |
| [`bash-sandbox`](../packages/bash/bash-sandbox) | `bash` | [`bash`](../packages/bash/bash), [`bash-local`](../packages/bash/bash-local), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy) | | [`bash-sandbox`](../packages/bash/bash-sandbox) | `bash` | [`bash`](../packages/bash/bash), [`bash-local`](../packages/bash/bash-local), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy) |
| [`fs-sandbox`](../packages/fs/fs-sandbox) | `fs` | [`fs`](../packages/fs/fs), [`fs-local`](../packages/fs/fs-local), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy) | | [`fs-sandbox`](../packages/fs/fs-sandbox) | `fs` | [`fs`](../packages/fs/fs), [`fs-local`](../packages/fs/fs-local), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy) |
| [`permission`](../packages/ui/permission) | `ui` | [`bash`](../packages/bash/bash), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`session`](../packages/core/session), [`user-approval`](../packages/ui/user-approval) | | [`permission`](../packages/ui/permission) | `ui` | [`bash`](../packages/bash/bash), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`session`](../packages/core/session), [`user-approval`](../packages/ui/user-approval) |
| [`pty-local`](../packages/pty/pty-local) | `pty` | [`pty`](../packages/pty/pty), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy) |
| [`agent-loop`](../packages/core/agent-loop) | `core` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | | [`agent-loop`](../packages/core/agent-loop) | `core` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) |
| [`tool-bash`](../packages/bash/tool-bash) | `bash` | [`agent`](../packages/core/agent), [`bash`](../packages/bash/bash), [`home`](../packages/util/home), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`session-persistence`](../packages/session-persistence/session-persistence), [`system-prompt`](../packages/core/system-prompt), [`tasks`](../packages/tasks/tasks), [`tools`](../packages/core/tools), [`user-approval`](../packages/ui/user-approval) | | [`tool-bash`](../packages/bash/tool-bash) | `bash` | [`agent`](../packages/core/agent), [`bash`](../packages/bash/bash), [`home`](../packages/util/home), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`session-persistence`](../packages/session-persistence/session-persistence), [`system-prompt`](../packages/core/system-prompt), [`tasks`](../packages/tasks/tasks), [`tools`](../packages/core/tools), [`user-approval`](../packages/ui/user-approval) |
| [`tool-fs`](../packages/fs/tool-fs) | `fs` | [`fs`](../packages/fs/fs), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`user-approval`](../packages/ui/user-approval) | | [`tool-fs`](../packages/fs/tool-fs) | `fs` | [`fs`](../packages/fs/fs), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`user-approval`](../packages/ui/user-approval) |
@@ -538,6 +556,7 @@ flowchart TD
| [`workspace-context`](../packages/context/workspace-context) | `context` | [`agent`](../packages/core/agent), [`fs`](../packages/fs/fs), [`llm`](../packages/llm/llm), [`paths`](../packages/util/paths), [`session`](../packages/core/session), [`tools`](../packages/core/tools) | | [`workspace-context`](../packages/context/workspace-context) | `context` | [`agent`](../packages/core/agent), [`fs`](../packages/fs/fs), [`llm`](../packages/llm/llm), [`paths`](../packages/util/paths), [`session`](../packages/core/session), [`tools`](../packages/core/tools) |
| [`repeat-tool-guard`](../packages/guard/repeat-tool-guard) | `guard` | [`agent`](../packages/core/agent), [`tools`](../packages/core/tools) | | [`repeat-tool-guard`](../packages/guard/repeat-tool-guard) | `guard` | [`agent`](../packages/core/agent), [`tools`](../packages/core/tools) |
| [`mcp-client`](../packages/mcp/mcp-client) | `mcp` | [`llm`](../packages/llm/llm), [`tools`](../packages/core/tools) | | [`mcp-client`](../packages/mcp/mcp-client) | `mcp` | [`llm`](../packages/llm/llm), [`tools`](../packages/core/tools) |
| [`tool-pty`](../packages/pty/tool-pty) | `pty` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`pty`](../packages/pty/pty), [`system-prompt`](../packages/core/system-prompt), [`tasks`](../packages/tasks/tasks), [`tools`](../packages/core/tools) |
| [`tool-tasks`](../packages/tasks/tool-tasks) | `tasks` | [`agent`](../packages/core/agent), [`system-prompt`](../packages/core/system-prompt), [`tasks`](../packages/tasks/tasks), [`tools`](../packages/core/tools) | | [`tool-tasks`](../packages/tasks/tool-tasks) | `tasks` | [`agent`](../packages/core/agent), [`system-prompt`](../packages/core/system-prompt), [`tasks`](../packages/tasks/tasks), [`tools`](../packages/core/tools) |
| [`tool-workflow`](../packages/workflow/tool-workflow) | `workflow` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`workflow`](../packages/workflow/workflow) | | [`tool-workflow`](../packages/workflow/tool-workflow) | `workflow` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`workflow`](../packages/workflow/workflow) |
| [`subagent-acp`](../packages/subagent/subagent-acp) | `subagent` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`subagent-subprocess`](../packages/subagent/subagent-subprocess) | | [`subagent-acp`](../packages/subagent/subagent-acp) | `subagent` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`subagent-subprocess`](../packages/subagent/subagent-subprocess) |
+164
View File
@@ -21,6 +21,7 @@ This table connects model-visible tool names to the plugin package and service s
| `@deepseek-ai/dsh-tool-cordis` | `cordis_inspect`, `cordis_mount`, `cordis_unmount` | `ctx.tools` | `tool/call`, `tool/result`, `live plugin-tree mutations (mount/unmount)` | - | Ships in examples/cordis-agent only (a deliberate opt-in — mounted code gets the real ctx, see .agents/notes/implemented/feature/2026-07-08-self-referential-cordis-toolset.md). Plugins the model mounts may register ADDITIONAL model-visible tools at runtime; a full changed request header logs those tool-set changes. | | `@deepseek-ai/dsh-tool-cordis` | `cordis_inspect`, `cordis_mount`, `cordis_unmount` | `ctx.tools` | `tool/call`, `tool/result`, `live plugin-tree mutations (mount/unmount)` | - | Ships in examples/cordis-agent only (a deliberate opt-in — mounted code gets the real ctx, see .agents/notes/implemented/feature/2026-07-08-self-referential-cordis-toolset.md). Plugins the model mounts may register ADDITIONAL model-visible tools at runtime; a full changed request header logs those tool-set changes. |
| `@deepseek-ai/dsh-tool-fs` | `edit`, `read`, `write` | `ctx.tools`, `ctx.fs`, `ctx.systemPrompt` | `tool/call`, `fs/write-intent or fs/edit-intent for mutations`, `fs/observed after successful file operations`, `tool/result` | - | The read-before-write/edit policy is added by `@deepseek-ai/dsh-fs-policy` (an `fs/*` event-gate plugin, no schema change); a deployment that loads these tools is expected to also load it. The tool schemas above are identical with or without the policy plugin. | | `@deepseek-ai/dsh-tool-fs` | `edit`, `read`, `write` | `ctx.tools`, `ctx.fs`, `ctx.systemPrompt` | `tool/call`, `fs/write-intent or fs/edit-intent for mutations`, `fs/observed after successful file operations`, `tool/result` | - | The read-before-write/edit policy is added by `@deepseek-ai/dsh-fs-policy` (an `fs/*` event-gate plugin, no schema change); a deployment that loads these tools is expected to also load it. The tool schemas above are identical with or without the policy plugin. |
| `@deepseek-ai/dsh-tool-fs-search` | `glob`, `grep` | `ctx.tools`, `ctx.bash`, `ctx.systemPrompt` | `tool/call`, `tool/result` | - | glob and grep are conditional bash-backed discovery tools: they register only when ctx.bash can find `rg`, then run fixed ripgrep commands through ctx.bash as ordinary foreground calls (never background tasks). Capped results save the complete formatted list through the optional ctx.spillStore backend; returned locators are follow-up-readable/searchable when the backend exposes local paths in co-located deployments. | | `@deepseek-ai/dsh-tool-fs-search` | `glob`, `grep` | `ctx.tools`, `ctx.bash`, `ctx.systemPrompt` | `tool/call`, `tool/result` | - | glob and grep are conditional bash-backed discovery tools: they register only when ctx.bash can find `rg`, then run fixed ripgrep commands through ctx.bash as ordinary foreground calls (never background tasks). Capped results save the complete formatted list through the optional ctx.spillStore backend; returned locators are follow-up-readable/searchable when the backend exposes local paths in co-located deployments. |
| `@deepseek-ai/dsh-tool-pty` | `pty_kill`, `pty_list`, `pty_read`, `pty_send`, `pty_signal`, `pty_spawn` | `ctx.tools`, `ctx.pty`, `ctx.systemPrompt`, `ctx.tasks at call time for run_in_background` | `tool/call`, `tool/result` | - | The six PTY tools are opt-in and complement one-shot bash/filesystem tools. `pty_send(run_in_background: true)` registers with `ctx.tasks`; TUI, named key sequences, BEL, resize, auto-start, and cross-agent sharing are absent from the schema. |
| `@deepseek-ai/dsh-tool-skill` | `skill` | `ctx.tools`, `ctx.skills` | `tool/call`, `tool/result` | - | - | | `@deepseek-ai/dsh-tool-skill` | `skill` | `ctx.tools`, `ctx.skills` | `tool/call`, `tool/result` | - | - |
| `@deepseek-ai/dsh-tool-subagent` | `subagent` | `ctx.tools`, `ctx.subagents` | `tool/call`, `tool/result`, `child session events through the chosen provider` | `subagent`, `subagent_fork` | The registered tool name is the load-time `toolName` config (default `subagent`); the schema above is that default. The shipped example agents load this package once per subagent backend, so the model additionally sees `subagent_fork` (bound to the fork backend) with an identical schema — see `examples/repl-agent/cordis.yml` and `examples/acp-agent/cordis.yml`. | | `@deepseek-ai/dsh-tool-subagent` | `subagent` | `ctx.tools`, `ctx.subagents` | `tool/call`, `tool/result`, `child session events through the chosen provider` | `subagent`, `subagent_fork` | The registered tool name is the load-time `toolName` config (default `subagent`); the schema above is that default. The shipped example agents load this package once per subagent backend, so the model additionally sees `subagent_fork` (bound to the fork backend) with an identical schema — see `examples/repl-agent/cordis.yml` and `examples/acp-agent/cordis.yml`. |
| `@deepseek-ai/dsh-tool-tasks` | `task_kill`, `task_list`, `task_output` | `ctx.tools`, `ctx.tasks`, `ctx.systemPrompt` | `tool/call`, `tool/result`, `context/message via agent.inject() for background completion notices` | - | The kind-agnostic background-task control surface: a background bash command and a background subagent are read, listed, and killed through the same three tools. Loading the plugin attaches the control surface that arms producers' `ctx.tasks.start()`. | | `@deepseek-ai/dsh-tool-tasks` | `task_kill`, `task_list`, `task_output` | `ctx.tools`, `ctx.tasks`, `ctx.systemPrompt` | `tool/call`, `tool/result`, `context/message via agent.inject() for background completion notices` | - | The kind-agnostic background-task control surface: a background bash command and a background subagent are read, listed, and killed through the same three tools. Loading the plugin attaches the control surface that arms producers' `ctx.tasks.start()`. |
@@ -393,6 +394,169 @@ Source: [`packages/fs/tool-fs-search/src/index.ts`](../packages/fs/tool-fs-searc
glob and grep are conditional bash-backed discovery tools: they register only when ctx.bash can find `rg`, then run fixed ripgrep commands through ctx.bash as ordinary foreground calls (never background tasks). Capped results save the complete formatted list through the optional ctx.spillStore backend; returned locators are follow-up-readable/searchable when the backend exposes local paths in co-located deployments. glob and grep are conditional bash-backed discovery tools: they register only when ctx.bash can find `rg`, then run fixed ripgrep commands through ctx.bash as ordinary foreground calls (never background tasks). Capped results save the complete formatted list through the optional ctx.spillStore backend; returned locators are follow-up-readable/searchable when the backend exposes local paths in co-located deployments.
## `@deepseek-ai/dsh-tool-pty`
### `pty_kill`
Close one persistent PTY and wait until its captured owned process tree is gone.
```json
{
"type": "object",
"properties": {
"sessionId": {
"type": "string",
"description": "PTY session id."
}
},
"required": [
"sessionId"
]
}
```
Source: [`packages/pty/tool-pty/src/index.ts`](../packages/pty/tool-pty/src/index.ts)
### `pty_list`
List persistent PTY sessions owned by the current agent.
```json
{
"type": "object",
"properties": {}
}
```
Source: [`packages/pty/tool-pty/src/index.ts`](../packages/pty/tool-pty/src/index.ts)
### `pty_read`
Read a bounded page of retained output from a persistent PTY without sending input.
```json
{
"type": "object",
"properties": {
"sessionId": {
"type": "string",
"description": "PTY session id."
},
"offset": {
"type": "number",
"description": "Newest-relative line offset (default 0)."
},
"count": {
"type": "number",
"description": "Requested line count (default 500; backend caps apply)."
}
},
"required": [
"sessionId"
]
}
```
Source: [`packages/pty/tool-pty/src/index.ts`](../packages/pty/tool-pty/src/index.ts)
### `pty_send`
Send text to a persistent PTY. By default Enter is submitted and the call waits for a prompt, stdin wait, output silence, timeout, or session exit. Background mode returns a task id for task_output/task_kill.
```json
{
"type": "object",
"properties": {
"sessionId": {
"type": "string",
"description": "PTY session id returned by pty_spawn or pty_list."
},
"text": {
"type": "string",
"description": "UTF-8 text to write to the terminal."
},
"submit": {
"type": "boolean",
"description": "Submit Enter after text (default true). Set false for control characters or incomplete REPL input."
},
"run_in_background": {
"type": "boolean",
"description": "Return a task id immediately; collect with task_output or stop with task_kill."
}
},
"required": [
"sessionId",
"text"
]
}
```
Source: [`packages/pty/tool-pty/src/index.ts`](../packages/pty/tool-pty/src/index.ts)
### `pty_signal`
Send an allowed signal to the current foreground process group of a persistent PTY.
```json
{
"type": "object",
"properties": {
"sessionId": {
"type": "string",
"description": "PTY session id."
},
"signal": {
"type": "string",
"description": "Signal to deliver. Shell-targeted SIGKILL is rejected; use pty_kill.",
"enum": [
"SIGINT",
"SIGTERM",
"SIGKILL",
"SIGTSTP",
"SIGHUP"
]
}
},
"required": [
"sessionId",
"signal"
]
}
```
Source: [`packages/pty/tool-pty/src/index.ts`](../packages/pty/tool-pty/src/index.ts)
### `pty_spawn`
Create a persistent, owner-isolated PTY session from a registered backend type. Use this for shell or REPL state that must survive across tool calls.
```json
{
"type": "object",
"properties": {
"type": {
"type": "string",
"description": "Registered PTY 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."
}
},
"required": [
"type"
]
}
```
Source: [`packages/pty/tool-pty/src/index.ts`](../packages/pty/tool-pty/src/index.ts)
The six PTY tools are opt-in and complement one-shot bash/filesystem tools. `pty_send(run_in_background: true)` registers with `ctx.tasks`; TUI, named key sequences, BEL, resize, auto-start, and cross-agent sharing are absent from the schema.
## `@deepseek-ai/dsh-tool-skill` ## `@deepseek-ai/dsh-tool-skill`
### `skill` ### `skill`
@@ -0,0 +1,64 @@
/** Deterministic in-memory PTY backend for transcript snapshots. */
class SnapshotSession {
motd = 'dsh> '
statusValue = { kind: 'running' }
scrollback = 'dsh> '
startSend(request) {
const viewport = `${request.text}\nPTY_OK\ndsh> `
this.scrollback += viewport
const result = {
viewport,
waitReason: 'stdin_read',
sessionStatus: this.statusValue,
truncated: false,
}
let consumed = false
return {
done: Promise.resolve(result),
readOutput: () => {
if (consumed) return { delta: '', truncated: false }
consumed = true
return { delta: viewport, truncated: false }
},
cancel: () => false,
}
}
read(request) {
const lines = this.scrollback.split('\n')
const offset = request.offset ?? 0
const count = request.count ?? 500
const end = lines.length - offset
const start = Math.max(0, end - count)
const text = lines.slice(start, end).join('\n')
return { text, totalLines: lines.length, lineBegin: offset, lineEnd: offset + text.split('\n').length, truncated: false }
}
signal() {
return Promise.resolve({ delivered: true, targetPgid: 1 })
}
status() {
return this.statusValue
}
close() {
this.statusValue = { kind: 'exited', exitCode: 0, signal: null }
return Promise.resolve()
}
}
/** Cordis plugin name. */
export const name = 'pty-snapshot-backend'
/** Required PTY service. */
export const inject = ['pty']
/** Register the deterministic snapshot backend. */
export function apply(ctx) {
ctx.pty.registerBackend({
type: 'shell',
spawn: () => Promise.resolve(new SnapshotSession()),
})
}
@@ -0,0 +1,25 @@
# Keyless replay counterpart to pty.cordis.yml.
- id: base
name: '@cordisjs/plugin-include'
config:
path: ./cordis.yml
patches:
- id: llm-deepseek
name: '@deepseek-ai/dsh-llm-deepseek'
disabled: true
- insert:
- id: pty
name: '@deepseek-ai/dsh-pty'
- id: pty-snapshot-backend
name: './pty-snapshot-backend.mjs'
- id: tool-pty
name: '@deepseek-ai/dsh-tool-pty'
- id: llm-replay
name: '@deepseek-ai/dsh-llm-replay'
config:
providers:
- id: deepseek
name: DeepSeek
models:
- id: deepseek-v4-flash
- id: deepseek-v4-pro
+20
View File
@@ -0,0 +1,20 @@
# Opt-in persistent PTY composition for the PTY snapshot scenario. The base
# deployment already owns the shared sandbox provider and policy.
- id: base
name: '@cordisjs/plugin-include'
config:
path: ./cordis.yml
patches:
- insert:
- id: pty
name: '@deepseek-ai/dsh-pty'
- id: pty-local
name: '@deepseek-ai/dsh-pty-local'
config:
pollIntervalMs: 10
exactProbeAfterMs: 20
idleSilenceMs: 250
timeoutMs: 2000
disposeGraceMs: 500
- id: tool-pty
name: '@deepseek-ai/dsh-tool-pty'
+9
View File
@@ -30,6 +30,7 @@ const BOTH_MODE_CONFIG = fileURLToPath(new URL('../both-mode.cordis.yml', import
const WORKSPACE_CONTEXT_CONFIG = fileURLToPath(new URL('../workspace-context.cordis.yml', import.meta.url)) const WORKSPACE_CONTEXT_CONFIG = fileURLToPath(new URL('../workspace-context.cordis.yml', import.meta.url))
const ADVANCED_CONFIG = fileURLToPath(new URL('../advanced.cordis.yml', import.meta.url)) const ADVANCED_CONFIG = fileURLToPath(new URL('../advanced.cordis.yml', import.meta.url))
const FS_CONFIG = fileURLToPath(new URL('../fs.cordis.yml', import.meta.url)) const FS_CONFIG = fileURLToPath(new URL('../fs.cordis.yml', import.meta.url))
const PTY_CONFIG = fileURLToPath(new URL('../pty.cordis.yml', import.meta.url))
function snapshotModeFromEnv(value: string | undefined): SnapshotSuiteOptions['mode'] { function snapshotModeFromEnv(value: string | undefined): SnapshotSuiteOptions['mode'] {
switch (value) { switch (value) {
@@ -62,6 +63,14 @@ const SCENARIOS: Scenario[] = [
configPath: FS_CONFIG, configPath: FS_CONFIG,
}, },
{ name: 'bash-spill', hasModelTurn: true, recorded: false, configPath: FS_CONFIG }, { name: 'bash-spill', hasModelTurn: true, recorded: false, configPath: FS_CONFIG },
{
name: 'pty-tools',
hasModelTurn: true,
recorded: false,
pinsHeader: true,
headerClass: 'pty',
configPath: PTY_CONFIG,
},
{ name: 'fs-terminal-card', hasModelTurn: true, recorded: true }, { name: 'fs-terminal-card', hasModelTurn: true, recorded: true },
{ name: 'todo-plan', hasModelTurn: true, recorded: true }, { name: 'todo-plan', hasModelTurn: true, recorded: true },
{ name: 'skill-load', hasModelTurn: true, recorded: false, pinsHeader: true, headerClass: 'skill' }, { name: 'skill-load', hasModelTurn: true, recorded: false, pinsHeader: true, headerClass: 'skill' },
@@ -0,0 +1,7 @@
{
"steps": [
{ "op": "initialize", "terminalOutput": true },
{ "op": "newSession" },
{ "op": "prompt", "text": "Exercise the six PTY tools in order, including one missing-session signal error, then reply DONE." }
]
}
@@ -0,0 +1,73 @@
{"type":"session","version":0,"id":"{{sessionId}}","createdAt":0,"cwd":"{{cwd}}"}
{"type":"turn/start","seq":0,"time":0,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}}
{"type":"user/message","seq":1,"time":0,"data":{"content":[{"type":"text","text":"Exercise the six PTY tools in order, including one missing-session signal error, then reply DONE."}],"source":{"kind":"user"}},"surfaceOp":"append"}
{"type":"step/start","seq":2,"time":0,"data":{"turn":1,"step":1}}
{"type":"request/header","seq":3,"time":0,"data":{"header":{"config":{"provider":"deepseek","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}}
{"type":"assistant/chunk","seq":4,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}}
{"type":"assistant/chunk","seq":5,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":0,"id":"pty-spawn","name":"pty_spawn","argumentsDelta":"{\"type\":\"shell\",\"name\":\"main\"}"}}}
{"type":"assistant/chunk","seq":6,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"pty-spawn","name":"pty_spawn","arguments":"{\"type\":\"shell\",\"name\":\"main\"}"}}}}
{"type":"assistant/chunk","seq":7,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}}}
{"type":"assistant/chunk","seq":8,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}}
{"type":"assistant/message","seq":9,"time":0,"data":{"turn":1,"step":1,"content":[{"type":"tool-call","id":"pty-spawn","name":"pty_spawn","arguments":"{\"type\":\"shell\",\"name\":\"main\"}"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[4,5,6,7,8],"surfaceOp":"append"}
{"type":"tool/call","seq":10,"time":0,"data":{"turn":1,"step":1,"callId":"pty-spawn","name":"pty_spawn","arguments":"{\"type\":\"shell\",\"name\":\"main\"}"}}
{"type":"tool/result","seq":11,"time":0,"data":{"turn":1,"step":1,"callId":"pty-spawn","content":[{"type":"text","text":"started PTY session pty-1 (main) [type: shell]\ndsh> "}],"isError":false},"sourceEventSeqs":[10],"surfaceOp":"append"}
{"type":"step/end","seq":12,"time":0,"data":{"turn":1,"step":1}}
{"type":"step/start","seq":13,"time":0,"data":{"turn":1,"step":2}}
{"type":"assistant/chunk","seq":14,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}}
{"type":"assistant/chunk","seq":15,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":0,"id":"pty-send","name":"pty_send","argumentsDelta":"{\"sessionId\":\"pty-1\",\"text\":\"printf 'PTY_OK\\\\n'\"}"}}}
{"type":"assistant/chunk","seq":16,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"pty-send","name":"pty_send","arguments":"{\"sessionId\":\"pty-1\",\"text\":\"printf 'PTY_OK\\\\n'\"}"}}}}
{"type":"assistant/chunk","seq":17,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}}}
{"type":"assistant/chunk","seq":18,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}}
{"type":"assistant/message","seq":19,"time":0,"data":{"turn":1,"step":2,"content":[{"type":"tool-call","id":"pty-send","name":"pty_send","arguments":"{\"sessionId\":\"pty-1\",\"text\":\"printf 'PTY_OK\\\\n'\"}"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[14,15,16,17,18],"surfaceOp":"append"}
{"type":"tool/call","seq":20,"time":0,"data":{"turn":1,"step":2,"callId":"pty-send","name":"pty_send","arguments":"{\"sessionId\":\"pty-1\",\"text\":\"printf 'PTY_OK\\\\n'\"}"}}
{"type":"tool/result","seq":21,"time":0,"data":{"turn":1,"step":2,"callId":"pty-send","content":[{"type":"text","text":"printf 'PTY_OK\\n'\nPTY_OK\ndsh> \n[wait: stdin_read]\n[session: running]"}],"isError":false,"meta":{"viewport":"printf 'PTY_OK\\n'\nPTY_OK\ndsh> ","waitReason":"stdin_read","sessionStatus":{"kind":"running"},"truncated":false}},"sourceEventSeqs":[20],"surfaceOp":"append"}
{"type":"step/end","seq":22,"time":0,"data":{"turn":1,"step":2}}
{"type":"step/start","seq":23,"time":0,"data":{"turn":1,"step":3}}
{"type":"assistant/chunk","seq":24,"time":0,"data":{"turn":1,"step":3,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}}
{"type":"assistant/chunk","seq":25,"time":0,"data":{"turn":1,"step":3,"chunk":{"type":"tool-call-delta","index":0,"id":"pty-read","name":"pty_read","argumentsDelta":"{\"sessionId\":\"pty-1\",\"offset\":0,\"count\":20}"}}}
{"type":"assistant/chunk","seq":26,"time":0,"data":{"turn":1,"step":3,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"pty-read","name":"pty_read","arguments":"{\"sessionId\":\"pty-1\",\"offset\":0,\"count\":20}"}}}}
{"type":"assistant/chunk","seq":27,"time":0,"data":{"turn":1,"step":3,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}}}
{"type":"assistant/chunk","seq":28,"time":0,"data":{"turn":1,"step":3,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}}
{"type":"assistant/message","seq":29,"time":0,"data":{"turn":1,"step":3,"content":[{"type":"tool-call","id":"pty-read","name":"pty_read","arguments":"{\"sessionId\":\"pty-1\",\"offset\":0,\"count\":20}"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[24,25,26,27,28],"surfaceOp":"append"}
{"type":"tool/call","seq":30,"time":0,"data":{"turn":1,"step":3,"callId":"pty-read","name":"pty_read","arguments":"{\"sessionId\":\"pty-1\",\"offset\":0,\"count\":20}"}}
{"type":"tool/result","seq":31,"time":0,"data":{"turn":1,"step":3,"callId":"pty-read","content":[{"type":"text","text":"dsh> printf 'PTY_OK\\n'\nPTY_OK\ndsh> \n[lines: 0-3 of 3]"}],"isError":false},"sourceEventSeqs":[30],"surfaceOp":"append"}
{"type":"step/end","seq":32,"time":0,"data":{"turn":1,"step":3}}
{"type":"step/start","seq":33,"time":0,"data":{"turn":1,"step":4}}
{"type":"assistant/chunk","seq":34,"time":0,"data":{"turn":1,"step":4,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}}
{"type":"assistant/chunk","seq":35,"time":0,"data":{"turn":1,"step":4,"chunk":{"type":"tool-call-delta","index":0,"id":"pty-signal","name":"pty_signal","argumentsDelta":"{\"sessionId\":\"pty-missing\",\"signal\":\"SIGINT\"}"}}}
{"type":"assistant/chunk","seq":36,"time":0,"data":{"turn":1,"step":4,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"pty-signal","name":"pty_signal","arguments":"{\"sessionId\":\"pty-missing\",\"signal\":\"SIGINT\"}"}}}}
{"type":"assistant/chunk","seq":37,"time":0,"data":{"turn":1,"step":4,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}}}
{"type":"assistant/chunk","seq":38,"time":0,"data":{"turn":1,"step":4,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}}
{"type":"assistant/message","seq":39,"time":0,"data":{"turn":1,"step":4,"content":[{"type":"tool-call","id":"pty-signal","name":"pty_signal","arguments":"{\"sessionId\":\"pty-missing\",\"signal\":\"SIGINT\"}"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[34,35,36,37,38],"surfaceOp":"append"}
{"type":"tool/call","seq":40,"time":0,"data":{"turn":1,"step":4,"callId":"pty-signal","name":"pty_signal","arguments":"{\"sessionId\":\"pty-missing\",\"signal\":\"SIGINT\"}"}}
{"type":"tool/result","seq":41,"time":0,"data":{"turn":1,"step":4,"callId":"pty-signal","content":[{"type":"text","text":"Error: unknown PTY session pty-missing"}],"isError":true},"sourceEventSeqs":[40],"surfaceOp":"append"}
{"type":"step/end","seq":42,"time":0,"data":{"turn":1,"step":4}}
{"type":"step/start","seq":43,"time":0,"data":{"turn":1,"step":5}}
{"type":"assistant/chunk","seq":44,"time":0,"data":{"turn":1,"step":5,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}}
{"type":"assistant/chunk","seq":45,"time":0,"data":{"turn":1,"step":5,"chunk":{"type":"tool-call-delta","index":0,"id":"pty-kill","name":"pty_kill","argumentsDelta":"{\"sessionId\":\"pty-1\"}"}}}
{"type":"assistant/chunk","seq":46,"time":0,"data":{"turn":1,"step":5,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"pty-kill","name":"pty_kill","arguments":"{\"sessionId\":\"pty-1\"}"}}}}
{"type":"assistant/chunk","seq":47,"time":0,"data":{"turn":1,"step":5,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}}}
{"type":"assistant/chunk","seq":48,"time":0,"data":{"turn":1,"step":5,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}}
{"type":"assistant/message","seq":49,"time":0,"data":{"turn":1,"step":5,"content":[{"type":"tool-call","id":"pty-kill","name":"pty_kill","arguments":"{\"sessionId\":\"pty-1\"}"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[44,45,46,47,48],"surfaceOp":"append"}
{"type":"tool/call","seq":50,"time":0,"data":{"turn":1,"step":5,"callId":"pty-kill","name":"pty_kill","arguments":"{\"sessionId\":\"pty-1\"}"}}
{"type":"tool/result","seq":51,"time":0,"data":{"turn":1,"step":5,"callId":"pty-kill","content":[{"type":"text","text":"killed PTY session pty-1"}],"isError":false},"sourceEventSeqs":[50],"surfaceOp":"append"}
{"type":"step/end","seq":52,"time":0,"data":{"turn":1,"step":5}}
{"type":"step/start","seq":53,"time":0,"data":{"turn":1,"step":6}}
{"type":"assistant/chunk","seq":54,"time":0,"data":{"turn":1,"step":6,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}}
{"type":"assistant/chunk","seq":55,"time":0,"data":{"turn":1,"step":6,"chunk":{"type":"tool-call-delta","index":0,"id":"pty-list","name":"pty_list","argumentsDelta":"{}"}}}
{"type":"assistant/chunk","seq":56,"time":0,"data":{"turn":1,"step":6,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"pty-list","name":"pty_list","arguments":"{}"}}}}
{"type":"assistant/chunk","seq":57,"time":0,"data":{"turn":1,"step":6,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}}}
{"type":"assistant/chunk","seq":58,"time":0,"data":{"turn":1,"step":6,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}}
{"type":"assistant/message","seq":59,"time":0,"data":{"turn":1,"step":6,"content":[{"type":"tool-call","id":"pty-list","name":"pty_list","arguments":"{}"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[54,55,56,57,58],"surfaceOp":"append"}
{"type":"tool/call","seq":60,"time":0,"data":{"turn":1,"step":6,"callId":"pty-list","name":"pty_list","arguments":"{}"}}
{"type":"tool/result","seq":61,"time":0,"data":{"turn":1,"step":6,"callId":"pty-list","content":[{"type":"text","text":"(no PTY sessions)"}],"isError":false},"sourceEventSeqs":[60],"surfaceOp":"append"}
{"type":"step/end","seq":62,"time":0,"data":{"turn":1,"step":6}}
{"type":"step/start","seq":63,"time":0,"data":{"turn":1,"step":7}}
{"type":"assistant/chunk","seq":64,"time":0,"data":{"turn":1,"step":7,"chunk":{"type":"block-start","index":0,"blockType":"text"}}}
{"type":"assistant/chunk","seq":65,"time":0,"data":{"turn":1,"step":7,"chunk":{"type":"text-delta","index":0,"text":"DONE"}}}
{"type":"assistant/chunk","seq":66,"time":0,"data":{"turn":1,"step":7,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"DONE"}}}}
{"type":"assistant/chunk","seq":67,"time":0,"data":{"turn":1,"step":7,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":3}}}}
{"type":"assistant/chunk","seq":68,"time":0,"data":{"turn":1,"step":7,"chunk":{"type":"finish","reason":{"kind":"stop"}}}}
{"type":"assistant/message","seq":69,"time":0,"data":{"turn":1,"step":7,"content":[{"type":"text","text":"DONE"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":10,"outputTokens":3}},"sourceEventSeqs":[64,65,66,67,68],"surfaceOp":"append"}
{"type":"step/end","seq":70,"time":0,"data":{"turn":1,"step":7}}
{"type":"turn/end","seq":71,"time":0,"data":{"turn":1,"reason":{"kind":"completed"}}}
@@ -0,0 +1,16 @@
{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}}
{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call","toolCallId":"pty-spawn","title":"Start PTY main","kind":"execute","status":"in_progress"}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call_update","toolCallId":"pty-spawn","status":"completed","content":[{"type":"content","content":{"type":"text","text":"started PTY session pty-1 (main) [type: shell]\ndsh> "}}]}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call","toolCallId":"pty-send","title":"printf 'PTY_OK\\n'","kind":"execute","status":"in_progress","rawInput":"printf 'PTY_OK\\n'","content":[{"type":"content","content":{"type":"text","text":"PTY pty-1"}},{"type":"terminal","terminalId":"pty-send"}],"_meta":{"terminal_info":{"terminal_id":"pty-send","cwd":"{{cwd}}"}}}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call_update","toolCallId":"pty-send","status":"completed","_meta":{"terminal_output":{"terminal_id":"pty-send","data":"printf 'PTY_OK\\n'\nPTY_OK\ndsh> \n[wait: stdin_read]\n[session: running]"}}}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call","toolCallId":"pty-read","title":"Read PTY pty-1","kind":"read","status":"in_progress","rawInput":{"sessionId":"pty-1","offset":0,"count":20}}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call_update","toolCallId":"pty-read","status":"completed","content":[{"type":"content","content":{"type":"text","text":"dsh> printf 'PTY_OK\\n'\nPTY_OK\ndsh> \n[lines: 0-3 of 3]"}}]}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call","toolCallId":"pty-signal","title":"Signal PTY pty-missing","kind":"execute","status":"in_progress","rawInput":{"sessionId":"pty-missing","signal":"SIGINT"}}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call_update","toolCallId":"pty-signal","status":"failed","content":[{"type":"content","content":{"type":"text","text":"Error: unknown PTY session pty-missing"}}]}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call","toolCallId":"pty-kill","title":"Kill PTY pty-1","kind":"delete","status":"in_progress"}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call_update","toolCallId":"pty-kill","status":"completed","content":[{"type":"content","content":{"type":"text","text":"killed PTY session pty-1"}}]}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call","toolCallId":"pty-list","title":"List PTY sessions","kind":"read","status":"in_progress"}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call_update","toolCallId":"pty-list","status":"completed","content":[{"type":"content","content":{"type":"text","text":"(no PTY sessions)"}}]}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"DONE"}}}}
{"jsonrpc":"2.0","id":3,"result":{"stopReason":"end_turn"}}
@@ -0,0 +1,23 @@
You are an AI agent powered by the DeepSeek Harness SDK.
You are a coding assistant powered by the deepseek-v4-flash model. Your working directory is {{cwd}}. Your bash tool runs under a file sandbox — a `[sandbox: file access denied …]` result is policy, not a command bug.
Verify your work by running the code or tests. Keep answers brief and factual.
Use the read tool — not shell commands like cat — to inspect text files. Results include line numbers. Use offset and limit to continue reading large files.
Use the write tool to create files or completely replace file contents. Existing files are overwritten, so read an existing file first (the default fs-policy requires it) and prefer edit for targeted changes.
Use the edit tool for targeted changes to existing UTF-8 text files. It replaces literal old_string with new_string; by default old_string must appear exactly once. If old_string appears multiple times, provide a more specific old_string or set replace_all to true. Read the file first (the default fs-policy requires it), unless you just created or edited it in this session.
Check the [exit code: N] marker on every bash result; investigate failures before moving on.
Use PTY only when work needs persistent terminal state or interactive stdin; prefer bash/read/write/edit for bounded one-shot operations. Track every PTY session id and kill sessions that no longer matter. An inferred_idle or timeout result does not prove the foreground command exited.
Track every background task id you start. You are notified in-session when a task finishes — do not busy-poll or sleep on one; keep working on independent steps and do not duplicate a running task's work. Before giving a final answer, collect every still-relevant task with task_output (set wait: true only when you are genuinely blocked on it), and task_kill tasks that stopped mattering.
Approval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`).
<!-- dsh-user-approval-policy:never -->
Use the workflow tool ONLY when the user explicitly asks for a workflow or for large multi-agent orchestration: you write a JavaScript script (the tool description documents the exact format) that fans work out across many subagents with phases and structured results. For one or two delegations, prefer plain subagent calls.
@@ -0,0 +1,505 @@
{
"initial": [
{
"name": "bash",
"description": "Execute a bash command (`bash -c`) and return its stdout/stderr. Each call runs in a fresh shell: no state (cwd, variables, functions) persists between calls — pass `workdir` instead of using `cd`. Non-zero exits are reported as `[exit code: N]`. Current harness environment facts are exposed through managed `$DSH_*` variables; inspect them when needed. Commands may run under a file sandbox; a blocked file operation is reported as `[sandbox: file access denied under <mode> mode]` — a policy denial, not a bug in the command; do not retry another way. Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. Set `run_in_background: true` for long-running commands: the call returns a task id immediately; read its output with `task_output` and stop it with `task_kill`. Attempting a command the sandbox may deny is safe and expected: run it and read the marker rather than assuming the denial. When a command is denied and a wider mode would let it succeed, escalate immediately in the same turn — the one sanctioned exception to a denial: retry the exact same command once with `sandbox_permissions` (the narrowest wider mode that suffices) plus a one-sentence `justification`. Do not detour through chat to ask permission first — the approval prompt raised by that retry is how the user consents. If the session states approval prompts are disabled, there is no exception: a denial is final — do not set `sandbox_permissions`. Never escalate speculatively: ground the request in a real denial — normally the one this command just hit; escalating up front is fine only when this session already denied the same access. A rejected escalation is final for that command — stop and explain, never work around it — but it does not forbid attempting or escalating other commands later.",
"parameters": {
"type": "object",
"properties": {
"command": {
"type": "string",
"description": "The bash command to execute."
},
"description": {
"type": "string",
"description": "Clear, concise description of what this command does in active voice, 5-10 words (shown in the UI). Examples: \"ls\" → \"List files in current directory\"; \"git status\" → \"Show working tree status\"; \"npm install\" → \"Install package dependencies\"."
},
"timeoutMs": {
"type": "number",
"description": "Timeout in milliseconds. The executor applies its configured default and cap, and kills the command on expiry."
},
"workdir": {
"type": "string",
"description": "Working directory for this command. Defaults to the session workspace; a relative path is resolved against it."
},
"run_in_background": {
"type": "boolean",
"description": "Run in the background and return a task id immediately (collect with task_output, stop with task_kill). No timeout applies."
},
"sandbox_permissions": {
"type": "string",
"description": "The wider sandbox mode this command needs. Only valid as a one-shot retry of a command the sandbox just denied; requires justification and user approval.",
"enum": [
"workspace-write",
"danger-full-access"
]
},
"justification": {
"type": "string",
"description": "Required with sandbox_permissions: one sentence for the user explaining why this exact command needs the wider access."
}
},
"required": [
"command",
"description"
]
}
},
{
"name": "edit",
"description": "Edit an existing UTF-8 text file by replacing literal text.",
"parameters": {
"type": "object",
"properties": {
"file_path": {
"type": "string",
"description": "Path to edit, resolved by the filesystem backend."
},
"old_string": {
"type": "string",
"description": "Literal text to replace. Must match exactly."
},
"new_string": {
"type": "string",
"description": "Literal replacement text. Use an empty string to delete the match."
},
"replace_all": {
"type": "boolean",
"description": "Replace all matches. Defaults to false; when false, old_string must appear exactly once."
},
"sandbox_permissions": {
"type": "string",
"description": "The wider sandbox mode this file operation needs. Only valid as a one-shot retry of an operation the sandbox just denied; requires justification and user approval.",
"enum": [
"workspace-write",
"danger-full-access"
]
},
"justification": {
"type": "string",
"description": "Required with sandbox_permissions: one sentence for the user explaining why this exact file operation needs the wider access."
}
},
"required": [
"file_path",
"old_string",
"new_string"
]
}
},
{
"name": "pty_kill",
"description": "Close one persistent PTY and wait until its captured owned process tree is gone.",
"parameters": {
"type": "object",
"properties": {
"sessionId": {
"type": "string",
"description": "PTY session id."
}
},
"required": [
"sessionId"
]
}
},
{
"name": "pty_list",
"description": "List persistent PTY sessions owned by the current agent.",
"parameters": {
"type": "object",
"properties": {}
}
},
{
"name": "pty_read",
"description": "Read a bounded page of retained output from a persistent PTY without sending input.",
"parameters": {
"type": "object",
"properties": {
"sessionId": {
"type": "string",
"description": "PTY session id."
},
"offset": {
"type": "number",
"description": "Newest-relative line offset (default 0)."
},
"count": {
"type": "number",
"description": "Requested line count (default 500; backend caps apply)."
}
},
"required": [
"sessionId"
]
}
},
{
"name": "pty_send",
"description": "Send text to a persistent PTY. By default Enter is submitted and the call waits for a prompt, stdin wait, output silence, timeout, or session exit. Background mode returns a task id for task_output/task_kill.",
"parameters": {
"type": "object",
"properties": {
"sessionId": {
"type": "string",
"description": "PTY session id returned by pty_spawn or pty_list."
},
"text": {
"type": "string",
"description": "UTF-8 text to write to the terminal."
},
"submit": {
"type": "boolean",
"description": "Submit Enter after text (default true). Set false for control characters or incomplete REPL input."
},
"run_in_background": {
"type": "boolean",
"description": "Return a task id immediately; collect with task_output or stop with task_kill."
}
},
"required": [
"sessionId",
"text"
]
}
},
{
"name": "pty_signal",
"description": "Send an allowed signal to the current foreground process group of a persistent PTY.",
"parameters": {
"type": "object",
"properties": {
"sessionId": {
"type": "string",
"description": "PTY session id."
},
"signal": {
"type": "string",
"description": "Signal to deliver. Shell-targeted SIGKILL is rejected; use pty_kill.",
"enum": [
"SIGINT",
"SIGTERM",
"SIGKILL",
"SIGTSTP",
"SIGHUP"
]
}
},
"required": [
"sessionId",
"signal"
]
}
},
{
"name": "pty_spawn",
"description": "Create a persistent, owner-isolated PTY session from a registered backend type. Use this for shell or REPL state that must survive across tool calls.",
"parameters": {
"type": "object",
"properties": {
"type": {
"type": "string",
"description": "Registered PTY 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."
}
},
"required": [
"type"
]
}
},
{
"name": "read",
"description": "Read a UTF-8 text file and return line-numbered content.",
"parameters": {
"type": "object",
"properties": {
"file_path": {
"type": "string",
"description": "Path to read, resolved by the filesystem backend."
},
"offset": {
"type": "number",
"description": "1-based first line to return. Defaults to 1."
},
"limit": {
"type": "number",
"description": "Maximum number of lines to return. Defaults to 2000."
}
},
"required": [
"file_path"
]
}
},
{
"name": "skill",
"description": "Load the full instructions for an available skill. Call this with the exact skill name from the session skill catalog before acting on a task that names or clearly matches that skill.",
"parameters": {
"type": "object",
"properties": {
"name": {
"type": "string",
"description": "The exact skill name from the available skills list."
}
},
"required": [
"name"
]
}
},
{
"name": "subagent",
"description": "Delegate a self-contained task to a subagent (a separate agent that works in its own context) and return its final result. Use this to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent runs to completion and you receive only its final answer, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. Set `run_in_background: true` to return a task id; collect with `task_output` and stop with `task_kill`.",
"parameters": {
"type": "object",
"properties": {
"description": {
"type": "string",
"description": "A short (3-5 word) description of the delegated task, for display."
},
"prompt": {
"type": "string",
"description": "The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs."
},
"run_in_background": {
"type": "boolean",
"description": "Run as a background task and return its id; collect with task_output or stop with task_kill."
}
},
"required": [
"description",
"prompt"
]
}
},
{
"name": "subagent_fork",
"description": "Delegate a task to a subagent that inherits this conversation: a child agent seeded with all completed turns so far (it does not see the current in-flight turn), returning only its final result. Use this when the subtask builds on this conversation's context — a follow-up analysis, a review, a continuation — without consuming this conversation's context for the work itself. You receive only its final answer, not its intermediate steps. Set `run_in_background: true` to return a task id; collect with `task_output` and stop with `task_kill`.",
"parameters": {
"type": "object",
"properties": {
"description": {
"type": "string",
"description": "A short (3-5 word) description of the delegated task, for display."
},
"prompt": {
"type": "string",
"description": "The task for the subagent. It already sees this conversation's completed turns, so build on them freely and state only what is new."
},
"run_in_background": {
"type": "boolean",
"description": "Run as a background task and return its id; collect with task_output or stop with task_kill."
}
},
"required": [
"description",
"prompt"
]
}
},
{
"name": "task_kill",
"description": "Request cancellation of a running background task by task id. Returns immediately; the task settles as killed once its work actually stops.",
"parameters": {
"type": "object",
"properties": {
"task_id": {
"type": "string",
"description": "Task id returned by the tool that started the background work."
},
"reason": {
"type": "string",
"description": "Optional short reason, recorded in the log and forwarded to the task."
}
},
"required": [
"task_id"
]
}
},
{
"name": "task_list",
"description": "List your background tasks (running and finished) with their ids, kinds, and statuses.",
"parameters": {
"type": "object",
"properties": {}
}
},
{
"name": "task_output",
"description": "Read a background task. Stream tasks return only output since the previous read; final-output tasks return their result after settlement. Every response ends with `[status: ...]`. Reads are non-blocking unless `wait: true`, which waits up to the configured cap.",
"parameters": {
"type": "object",
"properties": {
"task_id": {
"type": "string",
"description": "Task id returned by the tool that started the background work."
},
"wait": {
"type": "boolean",
"description": "Block until the task reaches a terminal status or the timeout expires. A timed-out wait returns [status: running] and leaves the task alive."
},
"timeout_ms": {
"type": "number",
"description": "Max wait in milliseconds (only meaningful with wait: true). Defaults to the configured wait timeout; capped by the configured maximum."
}
},
"required": [
"task_id"
]
}
},
{
"name": "todo_write",
"description": "Record and update a structured task list for the current work. Send the ENTIRE list every call — it REPLACES the previous list (there are no partial updates, no per-item edits). Use it to plan multi-step work and show progress: add one todo per concrete step before you start. Keep AT MOST ONE todo `in_progress` at a time; while work remains, exactly one active task should be `in_progress`. Mark a todo `completed` the moment it is done (do not batch completions), and allow no `in_progress` item only once all work is complete. Skip the list for trivial single-step tasks. Statuses: `pending` (not started), `in_progress` (being worked on now), `completed` (finished).",
"parameters": {
"type": "object",
"properties": {
"todos": {
"type": "array",
"description": "The COMPLETE task list, replacing any previous list.",
"items": {
"type": "object",
"properties": {
"content": {
"type": "string",
"description": "What the task is — a short imperative line."
},
"status": {
"type": "string",
"description": "pending (not started) | in_progress (now) | completed (done).",
"enum": [
"pending",
"in_progress",
"completed"
]
}
},
"required": [
"content",
"status"
]
}
}
},
"required": [
"todos"
]
}
},
{
"name": "workflow",
"description": "Run a JavaScript workflow script that orchestrates subagents at scale. Use this for work that fans out across many independent pieces — an audit over many files, a migration, multi-angle research, adversarial verification of findings — where you write the orchestration as a script instead of delegating turn by turn.\n\nThe workflow's identity rides the `meta` parameter as JSON: required `name` (short kebab-case) and `description` strings, optional `whenToUse` string and `phases` array (`{title, detail?, provider?, model?}`). The `script` parameter is the plain JavaScript body ONLY (NOT TypeScript, and NO `export const meta` statement — meta is a parameter, not code), running with top-level await; end with `return <value>` — the value must be JSON-serializable and is this tool's result.\n\nScript-body hooks:\n- `agent(prompt, opts?): Promise<any>` — run one subagent to completion. Without `opts.schema` it resolves to the child's final text; with `opts.schema` (an object-rooted JSON Schema using ONLY type/properties/required/additionalProperties/items/enum/const — no oneOf/pattern/format/numeric bounds) it resolves to the validated object. Resolves `null` when the child fails (filter with `.filter(Boolean)`). Other opts: `label` (display), `phase` (progress group), and independent `provider`/`model` LLM target overrides (either may be provided alone). Anything else (`effort`/`isolation`/`agentType`) is rejected loudly.\n- `pipeline(items, ...stages): Promise<any[]>` — run each item through the stages independently with NO barrier between stages (prefer this for multi-stage work). Each stage receives `(prev, item, index)`. An ordinary stage throw drops that ITEM to `null` and skips its remaining stages.\n- `parallel(thunks): Promise<any[]>` — run zero-argument functions concurrently and await ALL of them (a barrier; use only when a stage genuinely needs every prior result together). A throwing thunk resolves to `null`.\n- `phase(title)` — start a progress phase; `log(message)` — narrate progress; `args` — the tool call's `args` input, verbatim.\n\nMisused hooks (bad arguments, unknown options, unsupported schemas, tripped caps) throw errors that ALWAYS kill the script — they never dissolve into a per-item `null`.\n\nConstraints: concurrency and total-agent caps apply; no filesystem, network, timers, or Node.js APIs are provided — the agents do the work, the script only coordinates them. The run executes in the foreground: this call returns when the whole script finishes.",
"parameters": {
"type": "object",
"properties": {
"script": {
"type": "string",
"description": "The plain-JS workflow script body (top-level await allowed; NO `export const meta` statement; end with `return <json-value>`)."
},
"meta": {
"type": "object",
"description": "The workflow identity block (plain JSON — never code).",
"properties": {
"name": {
"type": "string",
"description": "Short kebab-case workflow name."
},
"description": {
"type": "string",
"description": "One-line description of what the workflow does."
},
"whenToUse": {
"type": "string",
"description": "Optional guidance on when this workflow applies."
},
"phases": {
"type": "array",
"description": "Optional phase declarations matched by phase() calls.",
"items": {
"type": "object",
"properties": {
"title": {
"type": "string",
"description": "The phase title phase() calls match by exact string."
},
"detail": {
"type": "string",
"description": "Optional one-line description of the phase."
},
"provider": {
"type": "string",
"description": "Optional provider override this phase is expected to use."
},
"model": {
"type": "string",
"description": "Optional model override this phase is expected to use."
}
},
"required": [
"title"
]
}
}
},
"required": [
"name",
"description"
]
},
"args": {
"type": "object",
"description": "Optional JSON input exposed to the script as the `args` global (wrap a bare list as a field, e.g. {\"files\": [...]})."
}
},
"required": [
"script",
"meta"
]
}
},
{
"name": "write",
"description": "Create or fully replace a UTF-8 text file.",
"parameters": {
"type": "object",
"properties": {
"file_path": {
"type": "string",
"description": "Path to write, resolved by the filesystem backend."
},
"content": {
"type": "string",
"description": "Full UTF-8 text content to write."
},
"sandbox_permissions": {
"type": "string",
"description": "The wider sandbox mode this file operation needs. Only valid as a one-shot retry of an operation the sandbox just denied; requires justification and user approval.",
"enum": [
"workspace-write",
"danger-full-access"
]
},
"justification": {
"type": "string",
"description": "Required with sandbox_permissions: one sentence for the user explaining why this exact file operation needs the wider access."
}
},
"required": [
"file_path",
"content"
]
}
}
],
"changes": []
}
@@ -0,0 +1,18 @@
# Keyless opt-in PTY composition for the headless stream-json snapshot.
- id: base
name: '@cordisjs/plugin-include'
config:
path: ./cordis.yml
patches:
- id: llm-deepseek
name: '@deepseek-ai/dsh-llm-deepseek'
disabled: true
- insert:
- id: pty
name: '@deepseek-ai/dsh-pty'
- id: pty-snapshot-backend
name: '../acp-agent/pty-snapshot-backend.mjs'
- id: tool-pty
name: '@deepseek-ai/dsh-tool-pty'
- id: llm-replay
name: '@deepseek-ai/dsh-llm-replay'
@@ -15,6 +15,10 @@ const scenarioDir = join(snapshotsDir, 'advanced-toolchain')
const sessionFixture = join(scenarioDir, 'session.jsonl') const sessionFixture = join(scenarioDir, 'session.jsonl')
const streamExpected = join(scenarioDir, 'stream-json.expected.jsonl') const streamExpected = join(scenarioDir, 'stream-json.expected.jsonl')
const configPath = fileURLToPath(new URL('../advanced.cordis.snapshot.yml', import.meta.url)) const configPath = fileURLToPath(new URL('../advanced.cordis.snapshot.yml', import.meta.url))
const ptyScenarioDir = join(snapshotsDir, 'pty-tools')
const ptySessionFixture = join(ptyScenarioDir, 'session.jsonl')
const ptyStreamExpected = join(ptyScenarioDir, 'stream-json.expected.jsonl')
const ptyConfigPath = fileURLToPath(new URL('../pty.cordis.snapshot.yml', import.meta.url))
const binScript = fileURLToPath(new URL('../../../packages/examples/cli-demo/src/bin.ts', import.meta.url)) const binScript = fileURLToPath(new URL('../../../packages/examples/cli-demo/src/bin.ts', import.meta.url))
const tsconfigPath = fileURLToPath(new URL('../../../tsconfig.json', import.meta.url)) const tsconfigPath = fileURLToPath(new URL('../../../tsconfig.json', import.meta.url))
const refreshing = process.env.DSH_SNAPSHOT === 'refresh' const refreshing = process.env.DSH_SNAPSHOT === 'refresh'
@@ -137,4 +141,43 @@ describe('headless stream-json snapshots', () => {
if (refreshing) await writeFile(streamExpected, normalized) if (refreshing) await writeFile(streamExpected, normalized)
expect(normalized).toBe(await readFile(streamExpected, 'utf8')) expect(normalized).toBe(await readFile(streamExpected, 'utf8'))
}, LOADER_SMOKE_TEST_TIMEOUT_MS) }, LOADER_SMOKE_TEST_TIMEOUT_MS)
it('replays persistent PTY tools through the one-shot app', async () => {
const input = JSON.parse(await readFile(join(ptyScenarioDir, 'input.json'), 'utf8')) as {
steps?: { op?: unknown; text?: unknown }[]
}
const prompt = input.steps?.find(step => step.op === 'prompt')?.text
if (typeof prompt !== 'string') throw new Error('pty-tools input has no prompt step')
const expectedSession = await readFile(ptySessionFixture, 'utf8')
let runCwd = ''
const result = await runLoaderSmoke({
label: 'headless persistent PTY snapshot',
tempDirPrefix: 'headless-snapshot-pty-',
binScript,
configPath: ptyConfigPath,
binArgs: ['--config', ptyConfigPath, '--output-format', 'stream-json', prompt],
tsconfigPath,
env: {
DSH_SNAPSHOT: 'replay',
DSH_SNAPSHOT_FILE: ptySessionFixture,
NODE_OPTIONS: [process.env.NODE_OPTIONS, '--disable-warning=ExperimentalWarning'].filter(Boolean).join(' '),
},
prepare: (cwd) => { runCwd = cwd },
inspect: async (cwd) => {
const logs = await persistedLogs(cwd)
expect(logs).toHaveLength(1)
const actual = logs[0]
if (actual === undefined) throw new Error('headless PTY snapshot did not persist its session')
const actualContext = contextFromLogs([actual.content])
const expectedContext = contextFromLogs([expectedSession])
expect(scrubRequestHeaders(normalizeSessionLog(actual.content, actualContext)))
.toBe(scrubRequestHeaders(normalizeSessionLog(expectedSession, expectedContext)))
},
})
expect(result.stderr).toBe('')
const normalized = normalizeHeadlessStream(result.stdout, runCwd)
if (refreshing) await writeFile(ptyStreamExpected, normalized)
expect(normalized).toBe(await readFile(ptyStreamExpected, 'utf8'))
}, LOADER_SMOKE_TEST_TIMEOUT_MS)
}) })
@@ -0,0 +1,7 @@
{
"steps": [
{ "op": "initialize", "terminalOutput": true },
{ "op": "newSession" },
{ "op": "prompt", "text": "Exercise the six PTY tools in order, including one missing-session signal error, then reply DONE." }
]
}
@@ -0,0 +1,73 @@
{"type":"session","version":0,"id":"{{sessionId}}","createdAt":0,"cwd":"{{cwd}}"}
{"type":"turn/start","seq":0,"time":0,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}}
{"type":"user/message","seq":1,"time":0,"data":{"content":[{"type":"text","text":"Exercise the six PTY tools in order, including one missing-session signal error, then reply DONE."}],"source":{"kind":"user"}},"surfaceOp":"append"}
{"type":"step/start","seq":2,"time":0,"data":{"turn":1,"step":1}}
{"type":"request/header","seq":3,"time":0,"data":{"header":{"config":{"provider":"deepseek","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}}
{"type":"assistant/chunk","seq":4,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}}
{"type":"assistant/chunk","seq":5,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":0,"id":"pty-spawn","name":"pty_spawn","argumentsDelta":"{\"type\":\"shell\",\"name\":\"main\"}"}}}
{"type":"assistant/chunk","seq":6,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"pty-spawn","name":"pty_spawn","arguments":"{\"type\":\"shell\",\"name\":\"main\"}"}}}}
{"type":"assistant/chunk","seq":7,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}}}
{"type":"assistant/chunk","seq":8,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}}
{"type":"assistant/message","seq":9,"time":0,"data":{"turn":1,"step":1,"content":[{"type":"tool-call","id":"pty-spawn","name":"pty_spawn","arguments":"{\"type\":\"shell\",\"name\":\"main\"}"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[4,5,6,7,8],"surfaceOp":"append"}
{"type":"tool/call","seq":10,"time":0,"data":{"turn":1,"step":1,"callId":"pty-spawn","name":"pty_spawn","arguments":"{\"type\":\"shell\",\"name\":\"main\"}"}}
{"type":"tool/result","seq":11,"time":0,"data":{"turn":1,"step":1,"callId":"pty-spawn","content":[{"type":"text","text":"started PTY session pty-1 (main) [type: shell]\ndsh> "}],"isError":false},"sourceEventSeqs":[10],"surfaceOp":"append"}
{"type":"step/end","seq":12,"time":0,"data":{"turn":1,"step":1}}
{"type":"step/start","seq":13,"time":0,"data":{"turn":1,"step":2}}
{"type":"assistant/chunk","seq":14,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}}
{"type":"assistant/chunk","seq":15,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":0,"id":"pty-send","name":"pty_send","argumentsDelta":"{\"sessionId\":\"pty-1\",\"text\":\"printf 'PTY_OK\\\\n'\"}"}}}
{"type":"assistant/chunk","seq":16,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"pty-send","name":"pty_send","arguments":"{\"sessionId\":\"pty-1\",\"text\":\"printf 'PTY_OK\\\\n'\"}"}}}}
{"type":"assistant/chunk","seq":17,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}}}
{"type":"assistant/chunk","seq":18,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}}
{"type":"assistant/message","seq":19,"time":0,"data":{"turn":1,"step":2,"content":[{"type":"tool-call","id":"pty-send","name":"pty_send","arguments":"{\"sessionId\":\"pty-1\",\"text\":\"printf 'PTY_OK\\\\n'\"}"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[14,15,16,17,18],"surfaceOp":"append"}
{"type":"tool/call","seq":20,"time":0,"data":{"turn":1,"step":2,"callId":"pty-send","name":"pty_send","arguments":"{\"sessionId\":\"pty-1\",\"text\":\"printf 'PTY_OK\\\\n'\"}"}}
{"type":"tool/result","seq":21,"time":0,"data":{"turn":1,"step":2,"callId":"pty-send","content":[{"type":"text","text":"printf 'PTY_OK\\n'\nPTY_OK\ndsh> \n[wait: stdin_read]\n[session: running]"}],"isError":false,"meta":{"viewport":"printf 'PTY_OK\\n'\nPTY_OK\ndsh> ","waitReason":"stdin_read","sessionStatus":{"kind":"running"},"truncated":false}},"sourceEventSeqs":[20],"surfaceOp":"append"}
{"type":"step/end","seq":22,"time":0,"data":{"turn":1,"step":2}}
{"type":"step/start","seq":23,"time":0,"data":{"turn":1,"step":3}}
{"type":"assistant/chunk","seq":24,"time":0,"data":{"turn":1,"step":3,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}}
{"type":"assistant/chunk","seq":25,"time":0,"data":{"turn":1,"step":3,"chunk":{"type":"tool-call-delta","index":0,"id":"pty-read","name":"pty_read","argumentsDelta":"{\"sessionId\":\"pty-1\",\"offset\":0,\"count\":20}"}}}
{"type":"assistant/chunk","seq":26,"time":0,"data":{"turn":1,"step":3,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"pty-read","name":"pty_read","arguments":"{\"sessionId\":\"pty-1\",\"offset\":0,\"count\":20}"}}}}
{"type":"assistant/chunk","seq":27,"time":0,"data":{"turn":1,"step":3,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}}}
{"type":"assistant/chunk","seq":28,"time":0,"data":{"turn":1,"step":3,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}}
{"type":"assistant/message","seq":29,"time":0,"data":{"turn":1,"step":3,"content":[{"type":"tool-call","id":"pty-read","name":"pty_read","arguments":"{\"sessionId\":\"pty-1\",\"offset\":0,\"count\":20}"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[24,25,26,27,28],"surfaceOp":"append"}
{"type":"tool/call","seq":30,"time":0,"data":{"turn":1,"step":3,"callId":"pty-read","name":"pty_read","arguments":"{\"sessionId\":\"pty-1\",\"offset\":0,\"count\":20}"}}
{"type":"tool/result","seq":31,"time":0,"data":{"turn":1,"step":3,"callId":"pty-read","content":[{"type":"text","text":"dsh> printf 'PTY_OK\\n'\nPTY_OK\ndsh> \n[lines: 0-3 of 3]"}],"isError":false},"sourceEventSeqs":[30],"surfaceOp":"append"}
{"type":"step/end","seq":32,"time":0,"data":{"turn":1,"step":3}}
{"type":"step/start","seq":33,"time":0,"data":{"turn":1,"step":4}}
{"type":"assistant/chunk","seq":34,"time":0,"data":{"turn":1,"step":4,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}}
{"type":"assistant/chunk","seq":35,"time":0,"data":{"turn":1,"step":4,"chunk":{"type":"tool-call-delta","index":0,"id":"pty-signal","name":"pty_signal","argumentsDelta":"{\"sessionId\":\"pty-missing\",\"signal\":\"SIGINT\"}"}}}
{"type":"assistant/chunk","seq":36,"time":0,"data":{"turn":1,"step":4,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"pty-signal","name":"pty_signal","arguments":"{\"sessionId\":\"pty-missing\",\"signal\":\"SIGINT\"}"}}}}
{"type":"assistant/chunk","seq":37,"time":0,"data":{"turn":1,"step":4,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}}}
{"type":"assistant/chunk","seq":38,"time":0,"data":{"turn":1,"step":4,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}}
{"type":"assistant/message","seq":39,"time":0,"data":{"turn":1,"step":4,"content":[{"type":"tool-call","id":"pty-signal","name":"pty_signal","arguments":"{\"sessionId\":\"pty-missing\",\"signal\":\"SIGINT\"}"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[34,35,36,37,38],"surfaceOp":"append"}
{"type":"tool/call","seq":40,"time":0,"data":{"turn":1,"step":4,"callId":"pty-signal","name":"pty_signal","arguments":"{\"sessionId\":\"pty-missing\",\"signal\":\"SIGINT\"}"}}
{"type":"tool/result","seq":41,"time":0,"data":{"turn":1,"step":4,"callId":"pty-signal","content":[{"type":"text","text":"Error: unknown PTY session pty-missing"}],"isError":true},"sourceEventSeqs":[40],"surfaceOp":"append"}
{"type":"step/end","seq":42,"time":0,"data":{"turn":1,"step":4}}
{"type":"step/start","seq":43,"time":0,"data":{"turn":1,"step":5}}
{"type":"assistant/chunk","seq":44,"time":0,"data":{"turn":1,"step":5,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}}
{"type":"assistant/chunk","seq":45,"time":0,"data":{"turn":1,"step":5,"chunk":{"type":"tool-call-delta","index":0,"id":"pty-kill","name":"pty_kill","argumentsDelta":"{\"sessionId\":\"pty-1\"}"}}}
{"type":"assistant/chunk","seq":46,"time":0,"data":{"turn":1,"step":5,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"pty-kill","name":"pty_kill","arguments":"{\"sessionId\":\"pty-1\"}"}}}}
{"type":"assistant/chunk","seq":47,"time":0,"data":{"turn":1,"step":5,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}}}
{"type":"assistant/chunk","seq":48,"time":0,"data":{"turn":1,"step":5,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}}
{"type":"assistant/message","seq":49,"time":0,"data":{"turn":1,"step":5,"content":[{"type":"tool-call","id":"pty-kill","name":"pty_kill","arguments":"{\"sessionId\":\"pty-1\"}"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[44,45,46,47,48],"surfaceOp":"append"}
{"type":"tool/call","seq":50,"time":0,"data":{"turn":1,"step":5,"callId":"pty-kill","name":"pty_kill","arguments":"{\"sessionId\":\"pty-1\"}"}}
{"type":"tool/result","seq":51,"time":0,"data":{"turn":1,"step":5,"callId":"pty-kill","content":[{"type":"text","text":"killed PTY session pty-1"}],"isError":false},"sourceEventSeqs":[50],"surfaceOp":"append"}
{"type":"step/end","seq":52,"time":0,"data":{"turn":1,"step":5}}
{"type":"step/start","seq":53,"time":0,"data":{"turn":1,"step":6}}
{"type":"assistant/chunk","seq":54,"time":0,"data":{"turn":1,"step":6,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}}
{"type":"assistant/chunk","seq":55,"time":0,"data":{"turn":1,"step":6,"chunk":{"type":"tool-call-delta","index":0,"id":"pty-list","name":"pty_list","argumentsDelta":"{}"}}}
{"type":"assistant/chunk","seq":56,"time":0,"data":{"turn":1,"step":6,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"pty-list","name":"pty_list","arguments":"{}"}}}}
{"type":"assistant/chunk","seq":57,"time":0,"data":{"turn":1,"step":6,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}}}
{"type":"assistant/chunk","seq":58,"time":0,"data":{"turn":1,"step":6,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}}
{"type":"assistant/message","seq":59,"time":0,"data":{"turn":1,"step":6,"content":[{"type":"tool-call","id":"pty-list","name":"pty_list","arguments":"{}"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[54,55,56,57,58],"surfaceOp":"append"}
{"type":"tool/call","seq":60,"time":0,"data":{"turn":1,"step":6,"callId":"pty-list","name":"pty_list","arguments":"{}"}}
{"type":"tool/result","seq":61,"time":0,"data":{"turn":1,"step":6,"callId":"pty-list","content":[{"type":"text","text":"(no PTY sessions)"}],"isError":false},"sourceEventSeqs":[60],"surfaceOp":"append"}
{"type":"step/end","seq":62,"time":0,"data":{"turn":1,"step":6}}
{"type":"step/start","seq":63,"time":0,"data":{"turn":1,"step":7}}
{"type":"assistant/chunk","seq":64,"time":0,"data":{"turn":1,"step":7,"chunk":{"type":"block-start","index":0,"blockType":"text"}}}
{"type":"assistant/chunk","seq":65,"time":0,"data":{"turn":1,"step":7,"chunk":{"type":"text-delta","index":0,"text":"DONE"}}}
{"type":"assistant/chunk","seq":66,"time":0,"data":{"turn":1,"step":7,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"DONE"}}}}
{"type":"assistant/chunk","seq":67,"time":0,"data":{"turn":1,"step":7,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":3}}}}
{"type":"assistant/chunk","seq":68,"time":0,"data":{"turn":1,"step":7,"chunk":{"type":"finish","reason":{"kind":"stop"}}}}
{"type":"assistant/message","seq":69,"time":0,"data":{"turn":1,"step":7,"content":[{"type":"text","text":"DONE"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":10,"outputTokens":3}},"sourceEventSeqs":[64,65,66,67,68],"surfaceOp":"append"}
{"type":"step/end","seq":70,"time":0,"data":{"turn":1,"step":7}}
{"type":"turn/end","seq":71,"time":0,"data":{"turn":1,"reason":{"kind":"completed"}}}
@@ -0,0 +1,73 @@
{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"turn/start","seq":0,"time":0,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}}}
{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"user/message","seq":1,"time":0,"data":{"content":[{"type":"text","text":"Exercise the six PTY tools in order, including one missing-session signal error, then reply DONE."}],"source":{"kind":"user"}},"surfaceOp":"append"}}
{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"step/start","seq":2,"time":0,"data":{"turn":1,"step":1}}}
{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"request/header","seq":3,"time":0,"data":{"header":{"config":{"provider":"deepseek","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}}}
{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":4,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}}}
{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":5,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":0,"id":"pty-spawn","name":"pty_spawn","argumentsDelta":"{\"type\":\"shell\",\"name\":\"main\"}"}}}}
{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":6,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"pty-spawn","name":"pty_spawn","arguments":"{\"type\":\"shell\",\"name\":\"main\"}"}}}}}
{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":7,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}}}}
{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":8,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}}}
{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/message","seq":9,"time":0,"data":{"turn":1,"step":1,"content":[{"type":"tool-call","id":"pty-spawn","name":"pty_spawn","arguments":"{\"type\":\"shell\",\"name\":\"main\"}"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[4,5,6,7,8],"surfaceOp":"append"}}
{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"tool/call","seq":10,"time":0,"data":{"turn":1,"step":1,"callId":"pty-spawn","name":"pty_spawn","arguments":"{\"type\":\"shell\",\"name\":\"main\"}"}}}
{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"tool/result","seq":11,"time":0,"data":{"turn":1,"step":1,"callId":"pty-spawn","content":[{"type":"text","text":"started PTY session pty-1 (main) [type: shell]\ndsh> "}],"isError":false},"sourceEventSeqs":[10],"surfaceOp":"append"}}
{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"step/end","seq":12,"time":0,"data":{"turn":1,"step":1}}}
{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"step/start","seq":13,"time":0,"data":{"turn":1,"step":2}}}
{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":14,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}}}
{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":15,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":0,"id":"pty-send","name":"pty_send","argumentsDelta":"{\"sessionId\":\"pty-1\",\"text\":\"printf 'PTY_OK\\\\n'\"}"}}}}
{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":16,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"pty-send","name":"pty_send","arguments":"{\"sessionId\":\"pty-1\",\"text\":\"printf 'PTY_OK\\\\n'\"}"}}}}}
{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":17,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}}}}
{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":18,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}}}
{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/message","seq":19,"time":0,"data":{"turn":1,"step":2,"content":[{"type":"tool-call","id":"pty-send","name":"pty_send","arguments":"{\"sessionId\":\"pty-1\",\"text\":\"printf 'PTY_OK\\\\n'\"}"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[14,15,16,17,18],"surfaceOp":"append"}}
{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"tool/call","seq":20,"time":0,"data":{"turn":1,"step":2,"callId":"pty-send","name":"pty_send","arguments":"{\"sessionId\":\"pty-1\",\"text\":\"printf 'PTY_OK\\\\n'\"}"}}}
{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"tool/result","seq":21,"time":0,"data":{"turn":1,"step":2,"callId":"pty-send","content":[{"type":"text","text":"printf 'PTY_OK\\n'\nPTY_OK\ndsh> \n[wait: stdin_read]\n[session: running]"}],"isError":false,"meta":{"viewport":"printf 'PTY_OK\\n'\nPTY_OK\ndsh> ","waitReason":"stdin_read","sessionStatus":{"kind":"running"},"truncated":false}},"sourceEventSeqs":[20],"surfaceOp":"append"}}
{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"step/end","seq":22,"time":0,"data":{"turn":1,"step":2}}}
{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"step/start","seq":23,"time":0,"data":{"turn":1,"step":3}}}
{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":24,"time":0,"data":{"turn":1,"step":3,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}}}
{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":25,"time":0,"data":{"turn":1,"step":3,"chunk":{"type":"tool-call-delta","index":0,"id":"pty-read","name":"pty_read","argumentsDelta":"{\"sessionId\":\"pty-1\",\"offset\":0,\"count\":20}"}}}}
{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":26,"time":0,"data":{"turn":1,"step":3,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"pty-read","name":"pty_read","arguments":"{\"sessionId\":\"pty-1\",\"offset\":0,\"count\":20}"}}}}}
{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":27,"time":0,"data":{"turn":1,"step":3,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}}}}
{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":28,"time":0,"data":{"turn":1,"step":3,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}}}
{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/message","seq":29,"time":0,"data":{"turn":1,"step":3,"content":[{"type":"tool-call","id":"pty-read","name":"pty_read","arguments":"{\"sessionId\":\"pty-1\",\"offset\":0,\"count\":20}"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[24,25,26,27,28],"surfaceOp":"append"}}
{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"tool/call","seq":30,"time":0,"data":{"turn":1,"step":3,"callId":"pty-read","name":"pty_read","arguments":"{\"sessionId\":\"pty-1\",\"offset\":0,\"count\":20}"}}}
{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"tool/result","seq":31,"time":0,"data":{"turn":1,"step":3,"callId":"pty-read","content":[{"type":"text","text":"dsh> printf 'PTY_OK\\n'\nPTY_OK\ndsh> \n[lines: 0-3 of 3]"}],"isError":false},"sourceEventSeqs":[30],"surfaceOp":"append"}}
{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"step/end","seq":32,"time":0,"data":{"turn":1,"step":3}}}
{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"step/start","seq":33,"time":0,"data":{"turn":1,"step":4}}}
{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":34,"time":0,"data":{"turn":1,"step":4,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}}}
{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":35,"time":0,"data":{"turn":1,"step":4,"chunk":{"type":"tool-call-delta","index":0,"id":"pty-signal","name":"pty_signal","argumentsDelta":"{\"sessionId\":\"pty-missing\",\"signal\":\"SIGINT\"}"}}}}
{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":36,"time":0,"data":{"turn":1,"step":4,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"pty-signal","name":"pty_signal","arguments":"{\"sessionId\":\"pty-missing\",\"signal\":\"SIGINT\"}"}}}}}
{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":37,"time":0,"data":{"turn":1,"step":4,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}}}}
{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":38,"time":0,"data":{"turn":1,"step":4,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}}}
{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/message","seq":39,"time":0,"data":{"turn":1,"step":4,"content":[{"type":"tool-call","id":"pty-signal","name":"pty_signal","arguments":"{\"sessionId\":\"pty-missing\",\"signal\":\"SIGINT\"}"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[34,35,36,37,38],"surfaceOp":"append"}}
{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"tool/call","seq":40,"time":0,"data":{"turn":1,"step":4,"callId":"pty-signal","name":"pty_signal","arguments":"{\"sessionId\":\"pty-missing\",\"signal\":\"SIGINT\"}"}}}
{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"tool/result","seq":41,"time":0,"data":{"turn":1,"step":4,"callId":"pty-signal","content":[{"type":"text","text":"Error: unknown PTY session pty-missing"}],"isError":true},"sourceEventSeqs":[40],"surfaceOp":"append"}}
{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"step/end","seq":42,"time":0,"data":{"turn":1,"step":4}}}
{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"step/start","seq":43,"time":0,"data":{"turn":1,"step":5}}}
{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":44,"time":0,"data":{"turn":1,"step":5,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}}}
{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":45,"time":0,"data":{"turn":1,"step":5,"chunk":{"type":"tool-call-delta","index":0,"id":"pty-kill","name":"pty_kill","argumentsDelta":"{\"sessionId\":\"pty-1\"}"}}}}
{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":46,"time":0,"data":{"turn":1,"step":5,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"pty-kill","name":"pty_kill","arguments":"{\"sessionId\":\"pty-1\"}"}}}}}
{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":47,"time":0,"data":{"turn":1,"step":5,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}}}}
{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":48,"time":0,"data":{"turn":1,"step":5,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}}}
{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/message","seq":49,"time":0,"data":{"turn":1,"step":5,"content":[{"type":"tool-call","id":"pty-kill","name":"pty_kill","arguments":"{\"sessionId\":\"pty-1\"}"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[44,45,46,47,48],"surfaceOp":"append"}}
{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"tool/call","seq":50,"time":0,"data":{"turn":1,"step":5,"callId":"pty-kill","name":"pty_kill","arguments":"{\"sessionId\":\"pty-1\"}"}}}
{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"tool/result","seq":51,"time":0,"data":{"turn":1,"step":5,"callId":"pty-kill","content":[{"type":"text","text":"killed PTY session pty-1"}],"isError":false},"sourceEventSeqs":[50],"surfaceOp":"append"}}
{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"step/end","seq":52,"time":0,"data":{"turn":1,"step":5}}}
{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"step/start","seq":53,"time":0,"data":{"turn":1,"step":6}}}
{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":54,"time":0,"data":{"turn":1,"step":6,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}}}
{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":55,"time":0,"data":{"turn":1,"step":6,"chunk":{"type":"tool-call-delta","index":0,"id":"pty-list","name":"pty_list","argumentsDelta":"{}"}}}}
{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":56,"time":0,"data":{"turn":1,"step":6,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"pty-list","name":"pty_list","arguments":"{}"}}}}}
{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":57,"time":0,"data":{"turn":1,"step":6,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}}}}
{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":58,"time":0,"data":{"turn":1,"step":6,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}}}
{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/message","seq":59,"time":0,"data":{"turn":1,"step":6,"content":[{"type":"tool-call","id":"pty-list","name":"pty_list","arguments":"{}"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[54,55,56,57,58],"surfaceOp":"append"}}
{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"tool/call","seq":60,"time":0,"data":{"turn":1,"step":6,"callId":"pty-list","name":"pty_list","arguments":"{}"}}}
{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"tool/result","seq":61,"time":0,"data":{"turn":1,"step":6,"callId":"pty-list","content":[{"type":"text","text":"(no PTY sessions)"}],"isError":false},"sourceEventSeqs":[60],"surfaceOp":"append"}}
{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"step/end","seq":62,"time":0,"data":{"turn":1,"step":6}}}
{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"step/start","seq":63,"time":0,"data":{"turn":1,"step":7}}}
{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":64,"time":0,"data":{"turn":1,"step":7,"chunk":{"type":"block-start","index":0,"blockType":"text"}}}}
{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":65,"time":0,"data":{"turn":1,"step":7,"chunk":{"type":"text-delta","index":0,"text":"DONE"}}}}
{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":66,"time":0,"data":{"turn":1,"step":7,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"DONE"}}}}}
{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":67,"time":0,"data":{"turn":1,"step":7,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":3}}}}}
{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":68,"time":0,"data":{"turn":1,"step":7,"chunk":{"type":"finish","reason":{"kind":"stop"}}}}}
{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/message","seq":69,"time":0,"data":{"turn":1,"step":7,"content":[{"type":"text","text":"DONE"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":10,"outputTokens":3}},"sourceEventSeqs":[64,65,66,67,68],"surfaceOp":"append"}}
{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"step/end","seq":70,"time":0,"data":{"turn":1,"step":7}}}
{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"turn/end","seq":71,"time":0,"data":{"turn":1,"reason":{"kind":"completed"}}}}
{"type":"result","success":true,"sessionId":"{{sessionId}}","turn":1,"result":"DONE","reason":{"kind":"completed"},"usage":{"inputTokens":70,"outputTokens":33}}
+3
View File
@@ -24,6 +24,8 @@
"@deepseek-ai/dsh-llm-deepseek": "workspace:*", "@deepseek-ai/dsh-llm-deepseek": "workspace:*",
"@deepseek-ai/dsh-llm-replay": "workspace:*", "@deepseek-ai/dsh-llm-replay": "workspace:*",
"@deepseek-ai/dsh-permission": "workspace:*", "@deepseek-ai/dsh-permission": "workspace:*",
"@deepseek-ai/dsh-pty": "workspace:*",
"@deepseek-ai/dsh-pty-local": "workspace:*",
"@deepseek-ai/dsh-repeat-tool-guard": "workspace:*", "@deepseek-ai/dsh-repeat-tool-guard": "workspace:*",
"@deepseek-ai/dsh-sandbox-local": "workspace:*", "@deepseek-ai/dsh-sandbox-local": "workspace:*",
"@deepseek-ai/dsh-sandbox-policy": "workspace:^", "@deepseek-ai/dsh-sandbox-policy": "workspace:^",
@@ -40,6 +42,7 @@
"@deepseek-ai/dsh-tool-cordis": "workspace:*", "@deepseek-ai/dsh-tool-cordis": "workspace:*",
"@deepseek-ai/dsh-tool-fs": "workspace:*", "@deepseek-ai/dsh-tool-fs": "workspace:*",
"@deepseek-ai/dsh-tool-fs-search": "workspace:*", "@deepseek-ai/dsh-tool-fs-search": "workspace:*",
"@deepseek-ai/dsh-tool-pty": "workspace:*",
"@deepseek-ai/dsh-tool-subagent": "workspace:*", "@deepseek-ai/dsh-tool-subagent": "workspace:*",
"@deepseek-ai/dsh-tool-todo": "workspace:*", "@deepseek-ai/dsh-tool-todo": "workspace:*",
"@deepseek-ai/dsh-tool-workflow": "workspace:*", "@deepseek-ai/dsh-tool-workflow": "workspace:*",
+1
View File
@@ -11,6 +11,7 @@ Packages live at `packages/<group>/<pkg>/`; groups are containers, while names r
| [`core/`](core/README.md) | Product API spine: sessions, prompts, tools, agent services, and the concrete loop | Product — stable surface | | [`core/`](core/README.md) | Product API spine: sessions, prompts, tools, agent services, and the concrete loop | Product — stable surface |
| [`llm/`](llm/README.md) | LLM capability family: the abstract service + provider adapters | Product — stable surface | | [`llm/`](llm/README.md) | LLM capability family: the abstract service + provider adapters | Product — stable surface |
| [`bash/`](bash/README.md) | Bash capability family: the executor seam, a local impl, and the model-facing tool | Product — stable surface | | [`bash/`](bash/README.md) | Bash capability family: the executor seam, a local impl, and the model-facing tool | Product — stable surface |
| [`pty/`](pty/README.md) | Persistent PTY capability family: owner-scoped sessions, local implementation, and model-facing tools | Product — stable surface |
| [`code-runtime/`](code-runtime/README.md) | Code-execution capability family: the abstract runtime seam for model-written programs + a worker-thread backend | Product — stable surface | | [`code-runtime/`](code-runtime/README.md) | Code-execution capability family: the abstract runtime seam for model-written programs + a worker-thread backend | Product — stable surface |
| [`sandbox/`](sandbox/README.md) | Process-confinement seam; bwrap/Landlock/Seatbelt backends | Product — stable surface | | [`sandbox/`](sandbox/README.md) | Process-confinement seam; bwrap/Landlock/Seatbelt backends | Product — stable surface |
| [`fs/`](fs/README.md) | Filesystem capability family: the abstract seam, a local impl, the model-facing file tools, and the bash-backed discovery tools | Product — stable surface | | [`fs/`](fs/README.md) | Filesystem capability family: the abstract seam, a local impl, the model-facing file tools, and the bash-backed discovery tools | Product — stable surface |
@@ -294,6 +294,44 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [
}, },
], ],
}, },
{
key: 'pty',
summary: 'In-process registry for replaceable PTY backends and exact-Agent sessions.',
methods: [
{
signature: 'registerBackend(backend: PtyBackend): () => void',
jsDoc: '/**\n * Register one backend type for this effect scope.\n * @param backend - provider with a non-empty unique type.\n * @returns disposer that removes exactly this contribution.\n */',
},
{
signature: 'listBackends(): string[]',
jsDoc: '/**\n * List registered backend types in registration order.\n * @returns fresh backend type names.\n */',
},
{
signature: 'async spawn(owner: Agent, request: PtySpawnRequest, signal?: AbortSignal): Promise<PtySpawnResult>',
jsDoc: '/**\n * Create and publish one owner-scoped session after backend setup succeeds.\n * @param owner - exact registered Agent that owns access and cleanup.\n * @param request - backend type plus optional owner-local name and cwd.\n * @param signal - cancellation of unpublished setup.\n * @returns published identity, metadata, status, and MOTD.\n */',
},
{
signature: 'startSend(owner: Agent, id: PtySessionId, request: PtySendRequest): PtySendOperation',
jsDoc: '/**\n * Start one exclusive interactive send.\n * @param owner - exact session owner.\n * @param id - target PTY identity.\n * @param request - explicit text, submit behavior, and cancellation.\n * @returns live operation handle for foreground await or task registration.\n */',
},
{
signature: 'read(owner: Agent, id: PtySessionId, request: PtyReadRequest = {}): PtyReadResult',
jsDoc: '/**\n * Read one bounded scrollback page from an owned session.\n * @param owner - exact session owner.\n * @param id - target PTY identity.\n * @param request - optional newest-relative offset and line count.\n * @returns bounded retained text and pagination metadata.\n */',
},
{
signature: 'signal(owner: Agent, id: PtySessionId, signal: PtySignal): Promise<PtySignalResult>',
jsDoc: '/**\n * Deliver an allowed signal through an owned backend session.\n * @param owner - exact session owner.\n * @param id - target PTY identity.\n * @param signal - allowed POSIX signal name.\n * @returns delivered foreground process-group identity.\n */',
},
{
signature: 'async kill(owner: Agent, id: PtySessionId, reason = \'model request\'): Promise<boolean>',
jsDoc: '/**\n * Close one owned session and remove it only after quiescent backend cleanup.\n * @param owner - exact session owner.\n * @param id - target PTY identity.\n * @param reason - diagnostic cleanup reason.\n * @returns true for a newly closed session, false when the same close is already in flight.\n */',
},
{
signature: 'list(owner: Agent): PtySessionSnapshot[]',
jsDoc: '/**\n * List fresh snapshots for exactly one owner.\n * @param owner - exact owner whose sessions are visible.\n * @returns owner-visible snapshots in publication order.\n */',
},
],
},
{ {
key: 'sandbox', key: 'sandbox',
summary: 'Abstract process-sandbox service.', summary: 'Abstract process-sandbox service.',
@@ -1221,6 +1259,78 @@ export const TYPE_API: readonly TypeApiEntry[] = [
name: 'PromptSection', name: 'PromptSection',
declaration: 'export interface PromptSection {\n readonly name: string;\n readonly order: number;\n readonly text: string | ((context: AssembleContext) => string);\n}', declaration: 'export interface PromptSection {\n readonly name: string;\n readonly order: number;\n readonly text: string | ((context: AssembleContext) => string);\n}',
}, },
{
name: 'PtyBackend',
declaration: 'export interface PtyBackend {\n readonly type: string;\n spawn(spec: PtyBackendSpawnSpec): Promise<PtyBackendSession>;\n}',
},
{
name: 'PtyBackendSession',
declaration: 'export interface PtyBackendSession {\n readonly motd: string;\n readonly pid?: number;\n startSend(request: PtySendRequest): PtySendOperation;\n read(request: PtyReadRequest): PtyReadResult;\n signal(signal: PtySignal): Promise<PtySignalResult>;\n status(): PtySessionStatus;\n close(reason: string): Promise<void>;\n}',
},
{
name: 'PtyBackendSpawnSpec',
declaration: 'export interface PtyBackendSpawnSpec extends PtySpawnRequest {\n sessionId: PtySessionIdValue;\n owner: Agent;\n signal?: AbortSignal;\n}',
},
{
name: 'PtyReadRequest',
declaration: 'export interface PtyReadRequest {\n offset?: number;\n count?: number;\n}',
},
{
name: 'PtyReadResult',
declaration: 'export interface PtyReadResult {\n text: string;\n totalLines: number;\n lineBegin: number;\n lineEnd: number;\n truncated: boolean;\n}',
},
{
name: 'PtySendOperation',
declaration: 'export interface PtySendOperation {\n done: Promise<PtySendResult>;\n readOutput(): PtySendRead;\n cancel(): boolean;\n}',
},
{
name: 'PtySendRead',
declaration: 'export interface PtySendRead {\n delta: string;\n truncated: boolean;\n}',
},
{
name: 'PtySendRequest',
declaration: 'export interface PtySendRequest {\n text: string;\n submit: boolean;\n signal?: AbortSignal;\n}',
},
{
name: 'PtySendResult',
declaration: 'export interface PtySendResult {\n viewport: string;\n waitReason: PtyWaitReason;\n sessionStatus: PtySessionStatus;\n truncated: boolean;\n}',
},
{
name: 'PtySessionId',
declaration: 'export type PtySessionId = PtySessionIdValue;',
},
{
name: 'PtySessionIdValue',
declaration: 'export type PtySessionIdValue = Branded<\'PtySessionId\'>;',
},
{
name: 'PtySessionSnapshot',
declaration: 'export interface PtySessionSnapshot {\n sessionId: PtySessionIdValue;\n name?: string;\n type: string;\n pid?: number;\n status: PtySessionStatus;\n}',
},
{
name: 'PtySessionStatus',
declaration: 'export type PtySessionStatus = {\n kind: \'running\';\n} | {\n kind: \'exited\';\n exitCode: number | null;\n signal: NodeJS.Signals | null;\n};',
},
{
name: 'PtySignal',
declaration: 'export type PtySignal = \'SIGINT\' | \'SIGTERM\' | \'SIGKILL\' | \'SIGTSTP\' | \'SIGHUP\';',
},
{
name: 'PtySignalResult',
declaration: 'export interface PtySignalResult {\n delivered: true;\n targetPgid: number;\n}',
},
{
name: 'PtySpawnRequest',
declaration: 'export interface PtySpawnRequest {\n type: string;\n name?: string;\n cwd?: string;\n}',
},
{
name: 'PtySpawnResult',
declaration: 'export interface PtySpawnResult extends PtySessionSnapshot {\n motd: string;\n}',
},
{
name: 'PtyWaitReason',
declaration: 'export type PtyWaitReason = \'stdin_read\' | \'inferred_idle\' | \'timeout\' | \'session_exit\';',
},
{ {
name: 'ReasoningBlock', name: 'ReasoningBlock',
declaration: 'export interface ReasoningBlock {\n type: \'reasoning\';\n text: string;\n}', declaration: 'export interface ReasoningBlock {\n type: \'reasoning\';\n text: string;\n}',
@@ -23,7 +23,7 @@ describe('gen-tool-catalog collectToolCatalog', () => {
it('boots every shipped tool package and harvests its model-facing schemas', async () => { it('boots every shipped tool package and harvests its model-facing schemas', async () => {
const catalog = await collectToolCatalog() const catalog = await collectToolCatalog()
const names = catalog.flatMap(entry => entry.schemas.map(s => s.name)).sort() const names = catalog.flatMap(entry => entry.schemas.map(s => s.name)).sort()
expect(names).toEqual(['ask_user_question', 'bash', 'cordis_inspect', 'cordis_mount', 'cordis_unmount', 'edit', 'glob', 'grep', 'read', 'run_code', 'skill', 'subagent', 'task_kill', 'task_list', 'task_output', 'todo_write', 'web_fetch', 'web_search', 'workflow', 'write']) expect(names).toEqual(['ask_user_question', 'bash', 'cordis_inspect', 'cordis_mount', 'cordis_unmount', 'edit', 'glob', 'grep', 'pty_kill', 'pty_list', 'pty_read', 'pty_send', 'pty_signal', 'pty_spawn', 'read', 'run_code', 'skill', 'subagent', 'task_kill', 'task_list', 'task_output', 'todo_write', 'web_fetch', 'web_search', 'workflow', 'write'])
// Every tool carries a JSON-Schema `parameters` object (what the model sees). // Every tool carries a JSON-Schema `parameters` object (what the model sees).
for (const entry of catalog) { for (const entry of catalog) {
for (const schema of entry.schemas) { for (const schema of entry.schemas) {
+11
View File
@@ -0,0 +1,11 @@
# pty/ — persistent PTY capability family
Persistent, owner-scoped terminal sessions for workflows that require state across tool calls or interactive stdin. PTY complements the one-shot bash and filesystem tools; it does not replace their stronger per-operation contracts.
| Package | Role | ctx key |
|---|---|---|
| [`pty`](pty/README.md) (`@deepseek-ai/dsh-pty`) | Backend registry, branded ids, exact-Agent ownership, session operations, and awaited cleanup | `ctx.pty` |
| `pty-local` (`@deepseek-ai/dsh-pty-local`) | Local `node-pty` backend, readiness detection, bounded terminal state, sandboxing, and process-session supervision | registers on `ctx.pty` |
| `tool-pty` (`@deepseek-ai/dsh-tool-pty`) | Six model-facing tools and generic task integration for background sends | registers on `ctx.tools` |
The design and deferred boundaries live in the [persistent PTY Agent Note](../../.agents/notes/implemented/feature/2026-07-16-persistent-pty-sessions.md).
+32
View File
@@ -0,0 +1,32 @@
# @deepseek-ai/dsh-pty-local
Local `node-pty` backend for `ctx.pty`. It starts an interactive shell under the shared `ctx.sandboxPolicy`, strips credential-shaped ambient environment variables, retains bounded line-oriented output, detects readiness, and tears down the captured process tree rooted at the `node-pty` child.
## Plugin (`pty-local`)
The plugin injects `pty`, `sandbox`, and `sandboxPolicy`, then registers the configured backend type (`shell`). `danger-full-access` starts the shell directly; confined modes wrap the exact shell argv through `ctx.sandbox`. The current session-level sandbox override is resolved at spawn and remains fixed for the PTY lifetime.
Linux readiness combines a private bash prompt marker, foreground-process-group syscall inspection, silence fallback, and absolute timeout. macOS uses the prompt marker plus silence/timeout because it has no `/proc` syscall surface. Unrecognized or unreadable process state is never a positive exact-idle signal.
## Model Experience
### Indirect consumer
#### What the model sees
Nothing directly. Through `@deepseek-ai/dsh-tool-pty`, the model may receive bounded MOTD, send deltas, scrollback pages, readiness reasons, and cleanup errors.
#### Token effect
None until a consumer returns bounded backend output. Retained PTY scrollback is not placed in model history by this package.
#### KV Cache effect
No direct invalidation; the consumer owns prompts, schemas, and appended results.
## Known Limitations and Deferred Work
- Line-oriented output is normalized; full-screen alternate-buffer interaction is unsupported.
- Linux exact probes support x64 and arm64 UAPI tables; other architectures use prompt-marker and silence/timeout readiness.
- A descendant that daemonizes and reparents before teardown leaves the captured tree; cleanup never broadens to the launcher PID's POSIX session because that can include unrelated processes.
- Sessions do not survive harness process exit.
+45
View File
@@ -0,0 +1,45 @@
{
"name": "@deepseek-ai/dsh-pty-local",
"description": "Local node-pty backend for persistent DeepSeek Harness PTY sessions",
"version": "0.0.1",
"private": true,
"type": "module",
"main": "lib/index.js",
"types": "lib/types/index.d.ts",
"exports": {
".": {
"types": "./lib/types/index.d.ts",
"default": "./lib/index.js"
},
"./src/*": "./src/*",
"./package.json": "./package.json"
},
"files": [
"lib/index.js",
"lib/types/**/*.d.ts",
"lib/types/**/*.d.ts.map",
"src"
],
"scripts": {
"postinstall": "node src/ensure-spawn-helper.mjs"
},
"license": "BSD-3-Clause",
"peerDependencies": {
"@deepseek-ai/dsh-pty": "^0.0.1",
"@deepseek-ai/dsh-sandbox": "^0.0.1",
"@deepseek-ai/dsh-sandbox-policy": "^0.0.1",
"cordis": "^4.0.0-rc.7"
},
"dependencies": {
"node-pty": "^1.1.0",
"schemastery": "^3.18.0"
},
"devDependencies": {
"@deepseek-ai/dsh-agent": "workspace:^",
"@deepseek-ai/dsh-pty": "workspace:^",
"@deepseek-ai/dsh-sandbox": "workspace:^",
"@deepseek-ai/dsh-sandbox-policy": "workspace:^",
"@deepseek-ai/dsh-session": "workspace:^",
"cordis": "^4.0.0-rc.7"
}
}
+72
View File
@@ -0,0 +1,72 @@
/** Validated configuration for the local PTY backend. */
import z from 'schemastery'
/** Public plugin configuration. */
export interface Config {
/** Backend registry type (default: `shell`). */
backendType?: string
/** Interactive shell executable (default: `/bin/bash`). */
shellPath?: string
/** Shell arguments (default: `--noprofile --norc -i`). */
shellArgs?: string[]
/** Terminal rows. */
rows?: number
/** Terminal columns. */
cols?: number
/** Maximum retained logical lines. */
scrollbackLines?: number
/** Maximum retained UTF-8 bytes. */
scrollbackMaxBytes?: number
/** Maximum bytes returned by one read or settled viewport. */
maxReadBytes?: number
/** Readiness polling interval. */
pollIntervalMs?: number
/** Delay before Linux exact syscall probes. */
exactProbeAfterMs?: number
/** Silence duration that yields `inferred_idle`. */
idleSilenceMs?: number
/** Absolute send wait bound. */
timeoutMs?: number
/** Grace before teardown escalates to `SIGKILL`. */
disposeGraceMs?: number
}
/** Configuration after Schemastery defaults. */
export type ResolvedConfig = Required<Config>
/** Schemastery config exposed by the plugin. */
export const Config: z<Config> = z.object({
backendType: z.string().default('shell'),
shellPath: z.string().default('/bin/bash'),
shellArgs: z.array(z.string()).default(['--noprofile', '--norc', '-i']),
rows: z.number().default(40),
cols: z.number().default(160),
scrollbackLines: z.number().default(10_000),
scrollbackMaxBytes: z.number().default(4 * 1024 * 1024),
maxReadBytes: z.number().default(256 * 1024),
pollIntervalMs: z.number().default(50),
exactProbeAfterMs: z.number().default(150),
idleSilenceMs: z.number().default(3_000),
timeoutMs: z.number().default(30_000),
disposeGraceMs: z.number().default(3_000),
})
/**
* Assert every numeric config field is a positive safe integer and bounds compose.
* @param config - Schemastery-resolved plugin configuration.
* @returns Narrows the input to the fully resolved configuration.
*/
export function validateConfig(config: Config): asserts config is ResolvedConfig {
const resolved = config as ResolvedConfig
if (resolved.backendType.length === 0) throw new Error('pty-local: backendType must be non-empty')
if (resolved.shellPath.length === 0) throw new Error('pty-local: shellPath must be non-empty')
for (const [name, value] of Object.entries(resolved)) {
if (typeof value === 'number' && (!Number.isSafeInteger(value) || value <= 0)) {
throw new Error(`pty-local: ${name} must be a positive safe integer`)
}
}
if (resolved.maxReadBytes > resolved.scrollbackMaxBytes) {
throw new Error('pty-local: maxReadBytes must not exceed scrollbackMaxBytes')
}
}
@@ -0,0 +1,16 @@
/** Restore the executable bit stripped from node-pty's prebuilt helper. */
import { chmodSync, existsSync } from 'node:fs'
import { dirname, join } from 'node:path'
import { fileURLToPath } from 'node:url'
const entry = fileURLToPath(import.meta.resolve('node-pty'))
const packageRoot = dirname(dirname(entry))
const candidates = [
join(packageRoot, 'prebuilds', `${process.platform}-${process.arch}`, 'spawn-helper'),
join(packageRoot, 'build', 'Release', 'spawn-helper'),
]
for (const helper of candidates) {
if (existsSync(helper)) chmodSync(helper, 0o755)
}
+108
View File
@@ -0,0 +1,108 @@
/**
* Local persistent PTY backend using public `node-pty` APIs, shared sandbox
* policy, bounded output, platform readiness probes, and process-session cleanup.
* @module @deepseek-ai/dsh-pty-local
*/
import { Context } from 'cordis'
import * as nodePty from 'node-pty'
import type { IPtyForkOptions } from 'node-pty'
import type { PtyBackend, PtyBackendSpawnSpec } from '@deepseek-ai/dsh-pty'
import type { SandboxMode } from '@deepseek-ai/dsh-sandbox'
import { effectiveSandboxMode } from '@deepseek-ai/dsh-sandbox-policy'
import { type Config, type ResolvedConfig, validateConfig } from './config.ts'
import { createProcessInspector } from './process-inspector.ts'
import type { ProcessInspector } from './process-inspector.ts'
import { LocalPtySession } from './session.ts'
export { Config } from './config.ts'
export type { Config as PtyLocalConfig } from './config.ts'
/** Cordis plugin name. */
export const name = 'pty-local'
/** Required services: registry plus the one shared confinement policy. */
export const inject = ['pty', 'sandbox', 'sandboxPolicy']
const SENSITIVE_ENV_PATTERN = /KEY|SECRET|TOKEN/i
function childEnvironment(spec: PtyBackendSpawnSpec): NodeJS.ProcessEnv {
const env: NodeJS.ProcessEnv = {}
for (const [key, value] of Object.entries(process.env)) {
if (value !== undefined && !SENSITIVE_ENV_PATTERN.test(key) && !key.startsWith('DSH_')) env[key] = value
}
return {
...env,
TERM: 'dumb',
PAGER: 'cat',
GIT_PAGER: 'cat',
PS1: 'dsh> ',
PROMPT_COMMAND: 'printf "\\033]133;D;%s\\007" "$?"',
BASH_SILENCE_DEPRECATION_WARNING: '1',
DSH_SHELL: '1',
DSH_SESSION_ID: spec.owner.id,
DSH_PTY_SESSION_ID: spec.sessionId,
}
}
function spawnArgv(ctx: Context, config: ResolvedConfig, spec: PtyBackendSpawnSpec): string[] {
const argv = [config.shellPath, ...config.shellArgs]
const mode: SandboxMode = effectiveSandboxMode(spec.owner.session.events) ?? ctx.sandboxPolicy.defaultMode
if (mode === 'danger-full-access') return argv
return ctx.sandbox.confine(argv, {
mode: mode,
workspaceRoot: ctx.sandboxPolicy.workspaceRoot,
}).argv
}
/** Local shell backend registered under the configured type. */
export class LocalPtyBackend implements PtyBackend {
readonly type: string
constructor(
private readonly ctx: Context,
private readonly config: ResolvedConfig,
private readonly inspector: ProcessInspector,
private readonly spawnTerminal: typeof nodePty.spawn = nodePty.spawn,
private readonly createSession: (
terminal: ReturnType<typeof nodePty.spawn>,
inspector: ProcessInspector,
config: ResolvedConfig,
) => LocalPtySession = (terminal, inspector, config) => new LocalPtySession(terminal, inspector, config),
) {
this.type = config.backendType
}
async spawn(spec: PtyBackendSpawnSpec): Promise<LocalPtySession> {
if (spec.signal?.aborted === true) throw new Error('PTY spawn aborted')
const argv = spawnArgv(this.ctx, this.config, spec)
const file = argv[0]
if (file === undefined) throw new Error('pty-local: sandbox returned empty argv')
const options: IPtyForkOptions = {
name: 'dumb',
cols: this.config.cols,
rows: this.config.rows,
cwd: spec.cwd ?? this.ctx.sandboxPolicy.workspaceRoot,
env: childEnvironment(spec),
}
const terminal = this.spawnTerminal(file, argv.slice(1), options)
const session = this.createSession(terminal, this.inspector, this.config)
try {
await session.initialize(spec.signal)
return session
} catch (error) {
try {
await session.close('PTY startup failed')
} catch (closeError: unknown) {
throw new AggregateError([error, closeError], 'PTY startup and cleanup both failed')
}
throw error
}
}
}
/** Register the local PTY backend. */
export function apply(ctx: Context, config: Config): void {
validateConfig(config)
const inspector = createProcessInspector()
ctx.pty.registerBackend(new LocalPtyBackend(ctx, config, inspector))
}
@@ -0,0 +1,326 @@
/** Platform process-table inspection used for readiness, signals, and teardown. */
import { closeSync, openSync, readFileSync, readdirSync, readSync } from 'node:fs'
import { execFileSync } from 'node:child_process'
import type { PtySignal } from '@deepseek-ai/dsh-pty'
/** PID plus start identity, preventing teardown escalation after PID reuse. */
export interface ProcessIdentity {
pid: number
started: string
}
/** Injectable OS process operations used by one local PTY session. */
export interface ProcessInspector {
foregroundPgid(shellPid: number): number | undefined
isStdinWaiting(pgid: number): boolean
/** Return the root and its current transitive descendants, children first. */
processTree(rootPid: number): ProcessIdentity[]
isAlive(identity: ProcessIdentity): boolean
signalGroup(pgid: number, signal: PtySignal): void
signalProcess(identity: ProcessIdentity, signal: 'SIGTERM' | 'SIGKILL'): void
}
/** Testable boundary around filesystem, process-table, and signal syscalls. */
export interface ProcessInspectorInternals {
readFile(path: string): string
readDir(path: string): string[]
open(path: string): number
read(fd: number, buffer: Buffer, length: number, position: number): number
close(fd: number): void
exec(file: string, args: string[]): string
kill(pid: number, signal: NodeJS.Signals): void
}
/* v8 ignore start -- thin OS bindings; injected logic is unit-tested and real platform composition exercises them. */
const DEFAULT_INTERNALS: ProcessInspectorInternals = {
readFile: path => readFileSync(path, 'utf8'),
readDir: path => readdirSync(path),
open: path => openSync(path, 'r'),
read: (fd, buffer, length, position) => readSync(fd, buffer, 0, length, position),
close: closeSync,
exec: (file, args) => execFileSync(file, args, { encoding: 'utf8' }),
kill: (pid, signal) => process.kill(pid, signal),
}
/* v8 ignore stop */
interface ProcStat {
pid: number
parentPid: number
pgrp: number
session: number
tpgid: number
started: string
}
/**
* Parse fields used from Linux `/proc/<pid>/stat`, including parenthesized comm text.
* @param text - complete stat line.
* @returns Parsed identity/group fields, or undefined for malformed input.
*/
export function parseProcStat(text: string): ProcStat | undefined {
const open = text.indexOf('(')
const close = text.lastIndexOf(')')
if (open <= 0 || close <= open) return undefined
const pid = Number(text.slice(0, open).trim())
const rest = text.slice(close + 2).trim().split(/\s+/)
const parentPid = Number(rest[1])
const pgrp = Number(rest[2])
const session = Number(rest[3])
const tpgid = Number(rest[5])
const started = rest[19]
if (![pid, parentPid, pgrp, session, tpgid].every(Number.isSafeInteger) || started === undefined) return undefined
return { pid, parentPid, pgrp, session, tpgid, started }
}
function readLinuxStat(internals: ProcessInspectorInternals, pid: number): ProcStat | undefined {
try {
return parseProcStat(internals.readFile(`/proc/${pid}/stat`))
} catch (_unreadableProcEntry) {
return undefined
}
}
function numericEntries(internals: ProcessInspectorInternals, path: string): number[] {
try {
return internals.readDir(path).filter(entry => /^\d+$/.test(entry)).map(Number)
} catch (_unreadableProcDirectory) {
return []
}
}
interface SyscallInfo {
number: number
args: number[]
}
function readSyscall(internals: ProcessInspectorInternals, pid: number, tid: number): SyscallInfo | undefined {
try {
const text = internals.readFile(`/proc/${pid}/task/${tid}/syscall`).trim()
if (text === 'running' || text.startsWith('-1 ')) return undefined
const fields = text.split(/\s+/)
const number = Number(fields[0])
const args = fields.slice(1, 7).map(field => Number.parseInt(field, 16))
if (!Number.isSafeInteger(number) || args.some(value => !Number.isSafeInteger(value))) return undefined
return { number, args }
} catch (_unreadableSyscall) {
return undefined
}
}
function readMemory(
internals: ProcessInspectorInternals,
pid: number,
address: number,
length: number,
): Buffer | undefined {
let fd: number | undefined
try {
fd = internals.open(`/proc/${pid}/mem`)
const buffer = Buffer.alloc(length)
const count = internals.read(fd, buffer, length, address)
return buffer.subarray(0, count)
} catch (_unreadableProcessMemory) {
return undefined
} finally {
if (fd !== undefined) internals.close(fd)
}
}
function fdSetHasStdin(internals: ProcessInspectorInternals, pid: number, address: number): boolean {
return address !== 0 && (readMemory(internals, pid, address, 8)?.[0] ?? 0) % 2 === 1
}
function pollHasStdin(
internals: ProcessInspectorInternals,
pid: number,
address: number,
count: number,
): boolean {
if (address === 0 || count <= 0) return false
const memory = readMemory(internals, pid, address, Math.min(count, 1024) * 8)
if (memory === undefined) return false
for (let offset = 0; offset + 8 <= memory.length; offset += 8) {
if (memory.readInt32LE(offset) === 0 && (memory.readInt16LE(offset + 4) & 0x001) !== 0) return true
}
return false
}
function epollHasStdin(internals: ProcessInspectorInternals, pid: number, epfd: number): boolean {
try {
return internals.readFile(`/proc/${pid}/fdinfo/${epfd}`)
.split('\n')
.some(line => /^tfd:\s+0\b/.test(line.trim()))
} catch (_unreadableFdInfo) {
return false
}
}
interface SyscallTable {
read: number
select?: number
pselect: number
poll?: number
ppoll: number
epollWait?: number
epollPwait: number
}
const SYSCALLS: Partial<Record<NodeJS.Architecture, SyscallTable>> = {
x64: { read: 0, select: 23, pselect: 270, poll: 7, ppoll: 271, epollWait: 232, epollPwait: 281 },
arm64: { read: 63, pselect: 72, ppoll: 73, epollPwait: 22 },
}
function syscallWaitsOnStdin(
internals: ProcessInspectorInternals,
pid: number,
syscall: SyscallInfo,
table: SyscallTable,
): boolean {
const [a0 = 0, a1 = 0, a2 = 0] = syscall.args
if (syscall.number === table.read) return a0 === 0
if (syscall.number === table.select || syscall.number === table.pselect) {
return a0 >= 1 && fdSetHasStdin(internals, pid, a1)
}
if (syscall.number === table.poll || syscall.number === table.ppoll) {
return a1 >= 1 && pollHasStdin(internals, pid, a0, a1)
}
if (syscall.number === table.epollWait || syscall.number === table.epollPwait) {
return a2 >= 1 && epollHasStdin(internals, pid, a0)
}
return false
}
abstract class PosixProcessInspector implements ProcessInspector {
constructor(protected readonly internals: ProcessInspectorInternals) {}
abstract foregroundPgid(shellPid: number): number | undefined
abstract isStdinWaiting(pgid: number): boolean
abstract processTree(rootPid: number): ProcessIdentity[]
abstract isAlive(identity: ProcessIdentity): boolean
signalGroup(pgid: number, signal: PtySignal): void {
this.internals.kill(-pgid, signal)
}
signalProcess(identity: ProcessIdentity, signal: 'SIGTERM' | 'SIGKILL'): void {
if (this.isAlive(identity)) this.internals.kill(identity.pid, signal)
}
}
interface ProcessTreeEntry extends ProcessIdentity {
parentPid: number
}
function processTree(entries: ProcessTreeEntry[], rootPid: number): ProcessIdentity[] {
const byPid = new Map(entries.map(entry => [entry.pid, entry]))
const root = byPid.get(rootPid)
if (root === undefined) return []
const byParent = new Map<number, ProcessTreeEntry[]>()
for (const entry of entries) {
const children = byParent.get(entry.parentPid) ?? []
children.push(entry)
byParent.set(entry.parentPid, children)
}
const visited = new Set<number>()
const result: ProcessIdentity[] = []
const visit = (entry: ProcessTreeEntry): void => {
if (visited.has(entry.pid)) return
visited.add(entry.pid)
for (const child of byParent.get(entry.pid) ?? []) visit(child)
result.push({ pid: entry.pid, started: entry.started })
}
visit(root)
return result
}
class LinuxProcessInspector extends PosixProcessInspector {
constructor(
private readonly arch: NodeJS.Architecture,
internals: ProcessInspectorInternals,
) {
super(internals)
}
foregroundPgid(shellPid: number): number | undefined {
const tpgid = readLinuxStat(this.internals, shellPid)?.tpgid
return tpgid !== undefined && tpgid > 0 ? tpgid : undefined
}
isStdinWaiting(pgid: number): boolean {
const table = SYSCALLS[this.arch]
if (table === undefined) return false
for (const pid of numericEntries(this.internals, '/proc')) {
if (readLinuxStat(this.internals, pid)?.pgrp !== pgid) continue
for (const tid of numericEntries(this.internals, `/proc/${pid}/task`)) {
const syscall = readSyscall(this.internals, pid, tid)
if (syscall !== undefined && syscallWaitsOnStdin(this.internals, pid, syscall, table)) return true
}
}
return false
}
processTree(rootPid: number): ProcessIdentity[] {
const entries = numericEntries(this.internals, '/proc').flatMap((pid) => {
const stat = readLinuxStat(this.internals, pid)
return stat === undefined ? [] : [{ pid, parentPid: stat.parentPid, started: stat.started }]
})
return processTree(entries, rootPid)
}
isAlive(identity: ProcessIdentity): boolean {
return readLinuxStat(this.internals, identity.pid)?.started === identity.started
}
}
interface PsEntry extends ProcessTreeEntry {}
function macProcessTable(internals: ProcessInspectorInternals): PsEntry[] {
return internals.exec('/bin/ps', ['-axo', 'pid=,ppid=,lstart=']).split('\n').flatMap((line) => {
const match = /^\s*(\d+)\s+(\d+)\s+(.+?)\s*$/.exec(line)
if (match?.[1] === undefined || match[2] === undefined || match[3] === undefined) return []
return [{ pid: Number(match[1]), parentPid: Number(match[2]), started: match[3] }]
})
}
class MacProcessInspector extends PosixProcessInspector {
foregroundPgid(shellPid: number): number | undefined {
try {
const value = Number(this.internals.exec('/bin/ps', ['-o', 'tpgid=', '-p', String(shellPid)]).trim())
return Number.isSafeInteger(value) && value > 0 ? value : undefined
} catch (_missingProcess) {
return undefined
}
}
isStdinWaiting(_pgid: number): boolean {
return false
}
processTree(rootPid: number): ProcessIdentity[] {
return processTree(macProcessTable(this.internals), rootPid)
}
isAlive(identity: ProcessIdentity): boolean {
return macProcessTable(this.internals).some(entry => entry.pid === identity.pid && entry.started === identity.started)
}
}
/**
* Create the supported platform inspector or fail at plugin load.
* @param platform - target Node platform.
* @param arch - target CPU architecture for Linux syscall numbers.
* @param internals - filesystem/process boundary, injectable for deterministic tests.
* @returns Platform process inspector.
*/
export function createProcessInspector(
platform: NodeJS.Platform = process.platform,
arch: NodeJS.Architecture = process.arch,
internals: ProcessInspectorInternals = DEFAULT_INTERNALS,
): ProcessInspector {
if (platform === 'linux') return new LinuxProcessInspector(arch, internals)
if (platform === 'darwin') return new MacProcessInspector(internals)
throw new Error(`pty-local: unsupported platform ${platform}`)
}
+99
View File
@@ -0,0 +1,99 @@
/** Streaming terminal-control sanitizer for the line-oriented first release. */
/** OSC marker emitted by the controlled bash before each prompt. */
export const PROMPT_MARKER_PREFIX = '133;D;'
/** One sanitized chunk plus whether it contained the owned prompt marker. */
export interface SanitizedChunk {
text: string
prompt: boolean
}
/**
* Remove CSI/OSC/short escape sequences while preserving split-sequence carry.
* Full terminal emulation is deliberately deferred; ordinary line output and
* the private prompt marker are the supported contract.
*/
export class TerminalSanitizer {
private pending = ''
/**
* Consume one decoded `node-pty` data chunk.
* @param chunk - decoded terminal data.
* @returns Printable text and whether the private prompt marker completed.
*/
push(chunk: string): SanitizedChunk {
this.pending += chunk
let text = ''
let prompt = false
let index = 0
while (index < this.pending.length) {
const escape = this.pending.indexOf('\x1b', index)
if (escape < 0) {
text += this.pending.slice(index)
index = this.pending.length
break
}
text += this.pending.slice(index, escape)
if (escape + 1 >= this.pending.length) {
index = escape
break
}
const kind = this.pending[escape + 1]
if (kind === ']') {
const bel = this.pending.indexOf('\x07', escape + 2)
const stringTerminator = this.pending.indexOf('\x1b\\', escape + 2)
let end = -1
if (bel >= 0 && stringTerminator >= 0) end = Math.min(bel + 1, stringTerminator + 2)
else if (bel >= 0) end = bel + 1
else if (stringTerminator >= 0) end = stringTerminator + 2
if (end < 0) {
index = escape
break
}
const terminatorBytes = this.pending[end - 1] === '\x07' ? 1 : 2
const content = this.pending.slice(escape + 2, end - terminatorBytes)
if (content.startsWith(PROMPT_MARKER_PREFIX)) prompt = true
index = end
continue
}
if (kind === '[') {
let end = escape + 2
while (end < this.pending.length) {
const code = this.pending.charCodeAt(end)
if (code >= 0x40 && code <= 0x7e) break
end += 1
}
if (end >= this.pending.length) {
index = escape
break
}
index = end + 1
continue
}
// Two-byte escape family (save/restore cursor and similar).
index = escape + 2
}
this.pending = this.pending.slice(index)
return { text: normalizeTerminalText(text), prompt }
}
/**
* Flush a trailing printable fragment when the PTY exits.
* @returns Remaining printable text; incomplete escapes are discarded.
*/
flush(): string {
const text = this.pending.startsWith('\x1b') ? '' : this.pending
this.pending = ''
return normalizeTerminalText(text)
}
}
/**
* Normalize CRLF and standalone carriage returns for line-oriented rendering.
* @param text - sanitized terminal text.
* @returns Line-normalized text with BEL removed.
*/
export function normalizeTerminalText(text: string): string {
return text.replaceAll('\r\n', '\n').replaceAll('\r', '\n').replaceAll('\x07', '')
}
+372
View File
@@ -0,0 +1,372 @@
/** Local `node-pty` session: bounded output, readiness, signals, and teardown. */
import { constants } from 'node:os'
import { Buffer } from 'node:buffer'
import type { IDisposable, IPty } from 'node-pty'
import type {
PtyBackendSession,
PtyReadRequest,
PtyReadResult,
PtySendOperation,
PtySendRead,
PtySendRequest,
PtySendResult,
PtySessionStatus,
PtySignal,
PtySignalResult,
PtyWaitReason,
} from '@deepseek-ai/dsh-pty'
import type { ResolvedConfig } from './config.ts'
import type { ProcessInspector } from './process-inspector.ts'
import { TerminalSanitizer } from './sanitize.ts'
function delay(ms: number): Promise<void> {
return new Promise(resolve => setTimeout(resolve, ms))
}
function utf8Tail(text: string, maxBytes: number): { text: string; truncated: boolean } {
if (Buffer.byteLength(text) <= maxBytes) return { text, truncated: false }
const chars = Array.from(text)
let bytes = 0
let start = chars.length
while (start > 0) {
const next = Buffer.byteLength(chars[start - 1] as string)
if (bytes + next > maxBytes) break
bytes += next
start -= 1
}
return { text: chars.slice(start).join(''), truncated: true }
}
class BoundedTextBuffer {
private value = ''
private dropped = false
constructor(
private readonly maxBytes: number,
private readonly maxLines?: number,
) {}
append(text: string): void {
if (text.length === 0) return
this.value += text
if (this.maxLines !== undefined) {
const lines = this.value.split('\n')
if (lines.length > this.maxLines) {
this.value = lines.slice(lines.length - this.maxLines).join('\n')
this.dropped = true
}
}
const tail = utf8Tail(this.value, this.maxBytes)
this.value = tail.text
this.dropped ||= tail.truncated
}
consume(): PtySendRead {
const delta = this.value
const truncated = this.dropped
this.value = ''
this.dropped = false
return { delta, truncated }
}
snapshot(): { text: string; truncated: boolean } {
return { text: this.value, truncated: this.dropped }
}
}
class LocalSendOperation implements PtySendOperation {
private readonly output: BoundedTextBuffer
private readonly promise: PromiseWithResolvers<PtySendResult>
private finished = false
constructor(
maxBytes: number,
readonly startedAt: number,
private readonly onCancel: () => void,
) {
this.output = new BoundedTextBuffer(maxBytes)
this.promise = Promise.withResolvers<PtySendResult>()
}
get done(): Promise<PtySendResult> {
return this.promise.promise
}
append(text: string): void {
if (!this.finished) this.output.append(text)
}
settle(waitReason: PtyWaitReason, sessionStatus: PtySessionStatus, inheritedTruncation: boolean): void {
if (this.finished) return
this.finished = true
const read = this.output.snapshot()
this.promise.resolve({
viewport: read.text,
waitReason,
sessionStatus,
truncated: read.truncated || inheritedTruncation,
})
}
fail(error: unknown): void {
if (this.finished) return
this.finished = true
this.promise.reject(error)
}
readOutput(): PtySendRead {
return this.output.consume()
}
cancel(): boolean {
if (this.finished) return false
this.onCancel()
return true
}
}
function signalName(number: number | undefined): NodeJS.Signals | null {
if (number === undefined || number === 0) return null
for (const [name, value] of Object.entries(constants.signals)) {
if (value === number) return name as NodeJS.Signals
}
return null
}
/** Backend session wrapping one `node-pty` process and its captured process tree. */
export class LocalPtySession implements PtyBackendSession {
motd = ''
readonly pid: number
private readonly sanitizer = new TerminalSanitizer()
private readonly scrollback: BoundedTextBuffer
private readonly exitPromise: PromiseWithResolvers<void> = Promise.withResolvers<void>()
private readonly dataDisposable: IDisposable
private readonly exitDisposable: IDisposable
private statusValue: PtySessionStatus = { kind: 'running' }
private active: LocalSendOperation | undefined
private activeTimer: NodeJS.Timeout | undefined
private activeAbort: (() => void) | undefined
private promptSeen = false
private lastOutputAt = Date.now()
private closePromise: Promise<void> | undefined
constructor(
private readonly terminal: IPty,
private readonly inspector: ProcessInspector,
private readonly config: ResolvedConfig,
) {
this.pid = terminal.pid
this.scrollback = new BoundedTextBuffer(config.scrollbackMaxBytes, config.scrollbackLines)
this.dataDisposable = terminal.onData((data) => { this.onData(data) })
this.exitDisposable = terminal.onExit(({ exitCode, signal }) => {
const tail = this.sanitizer.flush()
this.appendOutput(tail)
this.statusValue = { kind: 'exited', exitCode, signal: signalName(signal) }
this.settleActive('session_exit')
this.exitPromise.resolve()
})
}
/**
* Capture startup output through the same readiness contract as later sends.
* @param signal - optional cancellation while the shell reaches its first prompt.
* @returns Resolves after startup readiness; rejects if the shell exits.
*/
async initialize(signal?: AbortSignal): Promise<void> {
const operation = this.startSend({ text: '', submit: false, ...signal !== undefined ? { signal } : {} })
const result = await operation.done
if (result.waitReason === 'session_exit') throw new Error('PTY shell exited during startup')
this.motd = result.viewport
}
startSend(request: PtySendRequest): PtySendOperation {
if (this.closePromise !== undefined) throw new Error('PTY session is closing')
if (this.statusValue.kind === 'exited') throw new Error('PTY session has exited')
if (this.active !== undefined) throw new Error('PTY session already has an active send')
if (request.signal?.aborted === true) throw new Error('PTY send aborted before write')
const operation = new LocalSendOperation(this.config.maxReadBytes, Date.now(), () => {
try {
this.terminal.write('\x03')
} catch (error: unknown) {
operation.fail(error)
}
})
this.active = operation
this.lastOutputAt = Date.now()
this.promptSeen = false
if (request.signal !== undefined) {
const onAbort = (): void => { operation.cancel() }
request.signal.addEventListener('abort', onAbort, { once: true })
this.activeAbort = () => request.signal?.removeEventListener('abort', onAbort)
}
try {
if (request.text.length > 0) this.terminal.write(request.text)
if (request.submit) this.terminal.write('\r')
} catch (error: unknown) {
this.clearActive()
operation.fail(error)
return operation
}
this.activeTimer = setInterval(() => { this.pollReadiness(operation) }, this.config.pollIntervalMs)
return operation
}
read(request: PtyReadRequest): PtyReadResult {
const snapshot = this.scrollback.snapshot()
const lines = snapshot.text.split('\n')
const totalLines = snapshot.text.length === 0 ? 0 : lines.length
const offset = request.offset ?? 0
const count = request.count ?? 500
if (!Number.isSafeInteger(offset) || offset < 0) throw new Error('PTY read offset must be a non-negative safe integer')
if (!Number.isSafeInteger(count) || count <= 0) throw new Error('PTY read count must be a positive safe integer')
if (offset >= totalLines) {
return { text: '', totalLines, lineBegin: offset, lineEnd: offset, truncated: snapshot.truncated }
}
const end = totalLines - offset
const start = Math.max(0, end - count)
const requested = lines.slice(start, end).join('\n')
const bounded = utf8Tail(requested, this.config.maxReadBytes)
const returnedLines = bounded.text.length === 0 ? 0 : bounded.text.split('\n').length
return {
text: bounded.text,
totalLines,
lineBegin: offset,
lineEnd: offset + returnedLines,
truncated: snapshot.truncated || bounded.truncated,
}
}
signal(signal: PtySignal): Promise<PtySignalResult> {
return Promise.resolve().then(() => {
const pgid = this.inspector.foregroundPgid(this.pid)
if (pgid === undefined) throw new Error(`cannot resolve foreground process group for PTY ${this.pid}`)
if (signal === 'SIGKILL' && pgid === this.pid) {
throw new Error('refusing to SIGKILL the PTY shell; use pty_kill')
}
this.inspector.signalGroup(pgid, signal)
return { delivered: true, targetPgid: pgid }
})
}
status(): PtySessionStatus {
return this.statusValue
}
close(reason: string): Promise<void> {
this.closePromise ??= this.closeOnce(reason)
return this.closePromise
}
private onData(data: string): void {
const sanitized = this.sanitizer.push(data)
this.appendOutput(sanitized.text)
if (sanitized.prompt) {
this.promptSeen = true
this.lastOutputAt = Date.now()
}
}
private appendOutput(text: string): void {
if (text.length === 0) return
this.lastOutputAt = Date.now()
this.scrollback.append(text)
this.active?.append(text)
}
private pollReadiness(operation: LocalSendOperation): void {
if (this.active !== operation) return
if (this.statusValue.kind === 'exited') {
this.settleActive('session_exit')
return
}
if (this.promptSeen && Date.now() - this.lastOutputAt >= this.config.pollIntervalMs) {
this.settleActive('stdin_read')
return
}
const elapsed = Date.now() - operation.startedAt
if (elapsed >= this.config.exactProbeAfterMs) {
const pgid = this.inspector.foregroundPgid(this.pid)
if (pgid !== undefined && this.inspector.isStdinWaiting(pgid)) {
this.settleActive('stdin_read')
return
}
}
if (Date.now() - this.lastOutputAt >= this.config.idleSilenceMs) {
this.settleActive('inferred_idle')
return
}
if (elapsed >= this.config.timeoutMs) this.settleActive('timeout')
}
private settleActive(waitReason: PtyWaitReason): void {
const operation = this.active
if (operation === undefined) return
const scrollbackTruncated = this.scrollback.snapshot().truncated
this.clearActive()
operation.settle(waitReason, this.statusValue, scrollbackTruncated)
}
private clearActive(): void {
if (this.activeTimer !== undefined) clearInterval(this.activeTimer)
this.activeTimer = undefined
this.activeAbort?.()
this.activeAbort = undefined
this.active = undefined
}
private async closeOnce(reason: string): Promise<void> {
this.dataDisposable.dispose()
const members = this.inspector.processTree(this.pid)
for (const member of members) {
try {
this.inspector.signalProcess(member, 'SIGTERM')
} catch (_alreadyExitedDuringTerm) {
// Identity is rechecked by the inspector; a same-tick exit is success.
}
}
try {
this.terminal.kill('SIGTERM')
} catch (_topLevelAlreadyExited) {
// onExit or identity checks below remain authoritative.
}
const deadline = Date.now() + this.config.disposeGraceMs
let survivors = members.filter(member => this.inspector.isAlive(member))
while (survivors.length > 0 && Date.now() < deadline) {
await delay(Math.min(25, this.config.disposeGraceMs))
survivors = members.filter(member => this.inspector.isAlive(member))
}
for (const survivor of survivors) {
try {
this.inspector.signalProcess(survivor, 'SIGKILL')
} catch (_alreadyExitedDuringKill) {
// Final identity check below decides success.
}
}
try {
this.terminal.kill('SIGKILL')
} catch (_topLevelAlreadyKilled) {
// The root may already have delivered onExit.
}
const killDeadline = Date.now() + this.config.disposeGraceMs
survivors = members.filter(member => this.inspector.isAlive(member))
while (survivors.length > 0 && Date.now() < killDeadline) {
await delay(Math.min(25, this.config.disposeGraceMs))
survivors = members.filter(member => this.inspector.isAlive(member))
}
const exitWaitMs = Math.max(0, killDeadline - Date.now())
await Promise.race([this.exitPromise.promise, delay(exitWaitMs)])
survivors = members.filter(member => this.inspector.isAlive(member))
this.settleActive('session_exit')
this.exitDisposable.dispose()
if (survivors.length > 0) {
throw new Error(`PTY cleanup failed (${reason}); surviving pids: ${survivors.map(member => member.pid).join(', ')}`)
}
}
}
@@ -0,0 +1,27 @@
import { describe, expect, it } from 'vitest'
import type { Config } from '@deepseek-ai/dsh-pty-local/src/config.ts'
import { validateConfig } from '@deepseek-ai/dsh-pty-local/src/config.ts'
function config(overrides: Partial<Config> = {}): Config {
return {
backendType: 'shell', shellPath: '/bin/bash', shellArgs: [], rows: 40, cols: 160,
scrollbackLines: 100, scrollbackMaxBytes: 1024, maxReadBytes: 512,
pollIntervalMs: 10, exactProbeAfterMs: 20, idleSilenceMs: 100, timeoutMs: 1000,
disposeGraceMs: 100,
...overrides,
}
}
describe('pty-local config', () => {
it('accepts resolved positive bounds', () => {
expect(() => { validateConfig(config()) }).not.toThrow()
})
it('rejects empty names, invalid numbers, and a read cap above retention', () => {
expect(() => { validateConfig(config({ backendType: '' })) }).toThrow('backendType')
expect(() => { validateConfig(config({ shellPath: '' })) }).toThrow('shellPath')
expect(() => { validateConfig(config({ rows: 0 })) }).toThrow('rows')
expect(() => { validateConfig(config({ rows: 1.5 })) }).toThrow('rows')
expect(() => { validateConfig(config({ maxReadBytes: 2048 })) }).toThrow('must not exceed')
})
})
+186
View File
@@ -0,0 +1,186 @@
import { describe, expect, it, vi } from 'vitest'
import type { IPty, IPtyForkOptions } from 'node-pty'
import { Context } from 'cordis'
import Loader from '@cordisjs/plugin-loader'
import { Session, SessionId } from '@deepseek-ai/dsh-session'
import type { Agent } from '@deepseek-ai/dsh-agent'
import SandboxProvider from '@deepseek-ai/dsh-sandbox'
import type { ConfinedArgv, SandboxPolicy } from '@deepseek-ai/dsh-sandbox'
import SandboxPolicyService from '@deepseek-ai/dsh-sandbox-policy'
import PtyService, { PtySessionId } from '@deepseek-ai/dsh-pty'
import { LocalPtyBackend } from '@deepseek-ai/dsh-pty-local'
import * as ptyLocal from '@deepseek-ai/dsh-pty-local'
import type { ResolvedConfig } from '@deepseek-ai/dsh-pty-local/src/config.ts'
import type { ProcessInspector } from '@deepseek-ai/dsh-pty-local/src/process-inspector.ts'
import type { LocalPtySession } from '@deepseek-ai/dsh-pty-local/src/session.ts'
class EmptySandbox extends SandboxProvider {
confine(_argv: readonly string[], _policy: SandboxPolicy): ConfinedArgv {
return { argv: [], enforcement: 'full', denialSignatures: [], runnerFailureSignatures: [] }
}
}
class RecordingSandbox extends SandboxProvider {
calls: { argv: readonly string[]; policy: SandboxPolicy }[] = []
confine(argv: readonly string[], policy: SandboxPolicy): ConfinedArgv {
this.calls.push({ argv, policy })
return { argv: ['/sandbox', '--', ...argv], enforcement: 'full', denialSignatures: [], runnerFailureSignatures: [] }
}
}
function config(): ResolvedConfig {
return {
backendType: 'shell', shellPath: '/bin/bash', shellArgs: [], rows: 24, cols: 80,
scrollbackLines: 10, scrollbackMaxBytes: 100, maxReadBytes: 50,
pollIntervalMs: 10, exactProbeAfterMs: 20, idleSilenceMs: 50, timeoutMs: 100,
disposeGraceMs: 10,
}
}
function agent(ctx: Context): Agent {
const id = SessionId('agent')
return {
id, options: {}, session: new Session(id), status: 'idle', ctx,
send() {}, steer() {}, inject() {}, cancel() {}, whenIdle: () => Promise.resolve(),
}
}
const inspector = {
foregroundPgid: () => undefined,
isStdinWaiting: () => false,
processTree: () => [],
isAlive: () => false,
signalGroup() {},
signalProcess() {},
} satisfies ProcessInspector
function spec(owner: Agent, signal?: AbortSignal) {
return {
sessionId: PtySessionId('pty-1'), owner, type: 'shell',
...signal !== undefined ? { signal } : {},
}
}
describe('LocalPtyBackend startup rollback', () => {
it('rejects pre-aborted setup and empty sandbox argv', async () => {
const ctx = new Context()
await ctx.plugin(EmptySandbox)
await ctx.plugin(SandboxPolicyService, { mode: 'read-only', workspaceRoot: '/tmp' })
const backend = new LocalPtyBackend(ctx, config(), inspector)
const controller = new AbortController()
controller.abort()
await expect(backend.spawn(spec(agent(ctx), controller.signal))).rejects.toThrow('spawn aborted')
await expect(backend.spawn(spec(agent(ctx)))).rejects.toThrow('empty argv')
})
it('closes failed startup and aggregates cleanup failure', async () => {
const ctx = new Context()
await ctx.plugin(EmptySandbox)
await ctx.plugin(SandboxPolicyService, { mode: 'danger-full-access', workspaceRoot: '/tmp' })
const spawnTerminal = (() => ({} as IPty)) as never
const closed = vi.fn<() => Promise<void>>().mockResolvedValue(undefined)
const failed = { initialize: () => Promise.reject(new Error('startup failed')), close: closed } as unknown as LocalPtySession
const backend = new LocalPtyBackend(ctx, config(), inspector, spawnTerminal, () => failed)
await expect(backend.spawn(spec(agent(ctx)))).rejects.toThrow('startup failed')
expect(closed).toHaveBeenCalledWith('PTY startup failed')
const doublyFailed = {
initialize: () => Promise.reject(new Error('startup failed')),
close: () => Promise.reject(new Error('cleanup failed')),
} as unknown as LocalPtySession
const aggregate = new LocalPtyBackend(ctx, config(), inspector, spawnTerminal, () => doublyFailed)
await expect(aggregate.spawn(spec(agent(ctx)))).rejects.toThrow('startup and cleanup both failed')
})
it('wraps confined argv, scrubs the environment, and returns initialized sessions', async () => {
const ctx = new Context()
await ctx.plugin(RecordingSandbox)
await ctx.plugin(SandboxPolicyService, { mode: 'workspace-write', workspaceRoot: '/workspace' })
const terminal = {} as IPty
let spawned: { file: string; args: string[]; options: IPtyForkOptions } | undefined
const spawnTerminal = ((file: string, args: string[], options: IPtyForkOptions) => {
spawned = { file, args, options }
return terminal
}) as never
const initialized = vi.fn<() => Promise<void>>().mockResolvedValue(undefined)
const session = { initialize: initialized } as unknown as LocalPtySession
const backend = new LocalPtyBackend(
ctx,
{ ...config(), shellArgs: ['-i'] },
inspector,
spawnTerminal,
() => session,
)
const previous = process.env.PTY_TEST_SECRET
process.env.PTY_TEST_SECRET = 'must-not-leak'
try {
expect(await backend.spawn({ ...spec(agent(ctx)), cwd: '/work' })).toBe(session)
} finally {
if (previous === undefined) delete process.env.PTY_TEST_SECRET
else process.env.PTY_TEST_SECRET = previous
}
expect(spawned).toMatchObject({
file: '/sandbox',
args: ['--', '/bin/bash', '-i'],
options: {
name: 'dumb', cols: 80, rows: 24, cwd: '/work',
env: {
TERM: 'dumb', PAGER: 'cat', GIT_PAGER: 'cat', PS1: 'dsh> ', BASH_SILENCE_DEPRECATION_WARNING: '1',
DSH_SHELL: '1', DSH_SESSION_ID: 'agent', DSH_PTY_SESSION_ID: 'pty-1',
},
},
})
expect(spawned?.options.env?.PTY_TEST_SECRET).toBeUndefined()
expect(initialized).toHaveBeenCalledWith(undefined)
})
it('composes the default local session around a spawned terminal', async () => {
const ctx = new Context()
await ctx.plugin(EmptySandbox)
await ctx.plugin(SandboxPolicyService, { mode: 'danger-full-access', workspaceRoot: '/workspace' })
let exitListener: ((event: { exitCode: number; signal?: number }) => void) | undefined
const terminal = {
pid: 123, cols: 80, rows: 24, process: 'bash', handleFlowControl: false,
onData(listener: (data: string) => void) {
queueMicrotask(() => { listener('\x1b]133;D;0\x07dsh> ') })
return { dispose() {} }
},
onExit(listener: (event: { exitCode: number; signal?: number }) => void) {
exitListener = listener
return { dispose() {} }
},
write() {},
kill() { exitListener?.({ exitCode: 0, signal: 15 }) },
resize() {}, clear() {}, pause() {}, resume() {},
} as IPty
const backend = new LocalPtyBackend(ctx, config(), inspector, () => terminal)
const session = await backend.spawn(spec(agent(ctx)))
expect(session.motd).toBe('dsh> ')
await session.close('test complete')
})
})
describe('pty-local plugin shape', () => {
it('keeps name, inject, and Config through Loader unwrapExports', () => {
expect('default' in ptyLocal).toBe(false)
const loader = Object.create(Loader.prototype) as Loader
const unwrapped = loader.unwrapExports(ptyLocal) as Record<string, unknown>
expect(unwrapped.name).toBe('pty-local')
expect(unwrapped.inject).toEqual(['pty', 'sandbox', 'sandboxPolicy'])
expect(unwrapped.Config).toBeDefined()
})
it('validates config and registers the configured backend', async () => {
const ctx = new Context()
await ctx.plugin(PtyService)
await ctx.plugin(EmptySandbox)
await ctx.plugin(SandboxPolicyService, { mode: 'danger-full-access', workspaceRoot: '/tmp' })
const fiber = await ctx.plugin(ptyLocal, config())
expect(ctx.pty.listBackends()).toEqual(['shell'])
await fiber.dispose()
expect(ctx.pty.listBackends()).toEqual([])
})
})
+122
View File
@@ -0,0 +1,122 @@
import { mkdtempSync, rmSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, describe, expect, it } from 'vitest'
import { Context } from 'cordis'
import { Session, SessionId } from '@deepseek-ai/dsh-session'
import AgentRegistry from '@deepseek-ai/dsh-agent'
import type { Agent } from '@deepseek-ai/dsh-agent'
import PtyService from '@deepseek-ai/dsh-pty'
import SandboxProvider from '@deepseek-ai/dsh-sandbox'
import type { ConfinedArgv, SandboxPolicy } from '@deepseek-ai/dsh-sandbox'
import SandboxPolicyService from '@deepseek-ai/dsh-sandbox-policy'
import * as ptyLocal from '@deepseek-ai/dsh-pty-local'
const roots: string[] = []
const contexts: Context[] = []
afterEach(async () => {
for (const ctx of contexts.splice(0)) await ctx.fiber.dispose()
for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true })
})
class PassthroughSandbox extends SandboxProvider {
calls: { argv: readonly string[]; policy: SandboxPolicy }[] = []
confine(argv: readonly string[], policy: SandboxPolicy): ConfinedArgv {
this.calls.push({ argv, policy })
return { argv: [...argv], enforcement: 'full', denialSignatures: [], runnerFailureSignatures: [] }
}
}
function stubAgent(ctx: Context, rawId: string): Agent {
const id = SessionId(rawId)
const scope = ctx.plugin(() => {})
return {
id, options: {}, session: new Session(id), status: 'idle', ctx: scope.ctx,
send() {}, steer() {}, inject() {}, cancel() {}, whenIdle: () => Promise.resolve(),
}
}
async function harness(mode: 'danger-full-access' | 'workspace-write') {
const root = mkdtempSync(join(tmpdir(), 'dsh-pty-local-'))
roots.push(root)
const ctx = new Context()
contexts.push(ctx)
await ctx.plugin(AgentRegistry)
await ctx.plugin(PtyService)
await ctx.plugin(PassthroughSandbox)
await ctx.plugin(SandboxPolicyService, { mode, workspaceRoot: root })
const fiber = await ctx.plugin(ptyLocal, {
pollIntervalMs: 10,
exactProbeAfterMs: 20,
idleSilenceMs: 250,
timeoutMs: 2000,
disposeGraceMs: 500,
scrollbackLines: 100,
scrollbackMaxBytes: 32_768,
maxReadBytes: 16_384,
})
const agent = stubAgent(ctx, `agent-${mode}`)
ctx.agents.register(agent)
return { ctx, root, agent, fiber, sandbox: ctx.sandbox as PassthroughSandbox }
}
describe('pty-local real shell', () => {
it('persists cwd and environment across sends, scrubs secrets, and closes', async () => {
const previous = process.env.DSH_TEST_SECRET
process.env.DSH_TEST_SECRET = 'must-not-leak'
try {
const { ctx, root, agent } = await harness('danger-full-access')
const created = await ctx.pty.spawn(agent, { type: 'shell', name: 'main', cwd: root })
expect(created.motd).toContain('dsh> ')
const first = ctx.pty.startSend(agent, created.sessionId, { text: 'export KEEP=ok; cd /', submit: true })
expect((await first.done).waitReason).toBe('stdin_read')
const second = ctx.pty.startSend(agent, created.sessionId, { text: 'printf "cwd=%s keep=%s secret=%s\\n" "$PWD" "$KEEP" "${DSH_TEST_SECRET-unset}"', submit: true })
expect((await second.done).viewport).toContain('cwd=/ keep=ok secret=unset')
expect(ctx.pty.read(agent, created.sessionId, { offset: 0, count: 20 }).text).toContain('cwd=/ keep=ok secret=unset')
expect(await ctx.pty.kill(agent, created.sessionId)).toBe(true)
expect(ctx.pty.list(agent)).toEqual([])
} finally {
if (previous === undefined) delete process.env.DSH_TEST_SECRET
else process.env.DSH_TEST_SECRET = previous
}
}, 10_000)
it('wraps the exact shell argv under confined policy and unregisters on reload', async () => {
const { ctx, root, agent, fiber, sandbox } = await harness('workspace-write')
const created = await ctx.pty.spawn(agent, { type: 'shell' })
expect(sandbox.calls).toEqual([{
argv: ['/bin/bash', '--noprofile', '--norc', '-i'],
policy: { mode: 'workspace-write', workspaceRoot: root },
}])
await fiber.dispose()
expect(ctx.pty.listBackends()).toEqual([])
expect(ctx.pty.list(agent)).toHaveLength(1)
await ctx.pty.kill(agent, created.sessionId)
}, 10_000)
it('signals a foreground command and kills a TERM-ignoring background descendant', async () => {
const { ctx, agent } = await harness('danger-full-access')
const created = await ctx.pty.spawn(agent, { type: 'shell' })
const foreground = ctx.pty.startSend(agent, created.sessionId, { text: 'sleep 60', submit: true })
await new Promise(resolve => setTimeout(resolve, 50))
expect((await ctx.pty.signal(agent, created.sessionId, 'SIGINT')).delivered).toBe(true)
expect((await foreground.done).waitReason).toBe('stdin_read')
const background = ctx.pty.startSend(agent, created.sessionId, {
text: 'sh -c \'trap "" TERM; sleep 60\' & echo CHILD=$!',
submit: true,
})
const output = (await background.done).viewport
const child = /CHILD=(\d+)/.exec(output)?.[1]
expect(child).toBeDefined()
const pid = Number(child)
expect(() => process.kill(pid, 0)).not.toThrow()
await ctx.pty.kill(agent, created.sessionId)
expect(() => process.kill(pid, 0)).toThrow()
}, 10_000)
})
@@ -0,0 +1,215 @@
import { describe, expect, it } from 'vitest'
import { createProcessInspector, parseProcStat } from '@deepseek-ai/dsh-pty-local/src/process-inspector.ts'
import type { ProcessInspectorInternals } from '@deepseek-ai/dsh-pty-local/src/process-inspector.ts'
function stat(pid: number, pgrp: number, session: number, tpgid: number, started: string, parentPid = 1): string {
const rest = ['S', String(parentPid), String(pgrp), String(session), '99', String(tpgid)]
while (rest.length < 19) rest.push('0')
rest.push(started)
return `${pid} (command with space) ${rest.join(' ')}`
}
function syscall(number: number, ...args: number[]): string {
const six = [...args]
while (six.length < 6) six.push(0)
return `${number} ${six.slice(0, 6).map(value => `0x${value.toString(16)}`).join(' ')}`
}
function fakeInternals() {
const files = new Map<string, string>()
const dirs = new Map<string, string[]>()
const memories = new Map<string, Buffer>()
const fds = new Map<number, string>()
const kills: Array<[number, NodeJS.Signals]> = []
let nextFd = 10
let ps = ''
let tpgid = '0'
const internals: ProcessInspectorInternals = {
readFile(path) {
const value = files.get(path)
if (value === undefined) throw new Error(`missing ${path}`)
return value
},
readDir(path) {
const value = dirs.get(path)
if (value === undefined) throw new Error(`missing ${path}`)
return value
},
open(path) {
if (!memories.has(path)) throw new Error(`missing ${path}`)
const fd = nextFd++
fds.set(fd, path)
return fd
},
read(fd, buffer, length, position) {
const path = fds.get(fd)
if (path === undefined) throw new Error('bad fd')
const source = memories.get(path)
if (source === undefined) throw new Error('missing memory')
return source.copy(buffer, 0, position, Math.min(source.length, position + length))
},
close(fd) { fds.delete(fd) },
exec(_file, args) {
if (args.includes('tpgid=')) return tpgid
return ps
},
kill(pid, signal) { kills.push([pid, signal]) },
}
return {
internals, files, dirs, memories, kills,
setPs(value: string) { ps = value },
setTpgid(value: string) { tpgid = value },
}
}
describe('Linux process inspector', () => {
it('parses stat safely, captures only the rooted process tree, and signals identities', () => {
expect(parseProcStat('bad')).toBeUndefined()
expect(parseProcStat('1 () S')).toBeUndefined()
expect(parseProcStat(stat(10, 20, 30, 40, '500'))).toEqual({ pid: 10, parentPid: 1, pgrp: 20, session: 30, tpgid: 40, started: '500' })
const fake = fakeInternals()
fake.dirs.set('/proc', ['x', '10', '11', '12', '13', '14'])
fake.files.set('/proc/10/stat', stat(10, 20, 30, 40, '500'))
fake.files.set('/proc/11/stat', stat(11, 21, 30, -1, '501'))
fake.files.set('/proc/12/stat', stat(12, 22, 30, -1, '502', 10))
fake.files.set('/proc/13/stat', stat(13, 23, 30, -1, '503', 12))
const inspector = createProcessInspector('linux', 'x64', fake.internals)
expect(inspector.foregroundPgid(10)).toBe(40)
expect(inspector.foregroundPgid(11)).toBeUndefined()
expect(inspector.foregroundPgid(99)).toBeUndefined()
expect(inspector.processTree(10)).toEqual([
{ pid: 13, started: '503' },
{ pid: 12, started: '502' },
{ pid: 10, started: '500' },
])
expect(inspector.processTree(99)).toEqual([])
expect(inspector.isAlive({ pid: 10, started: '500' })).toBe(true)
expect(inspector.isAlive({ pid: 10, started: 'old' })).toBe(false)
inspector.signalGroup(40, 'SIGINT')
inspector.signalProcess({ pid: 10, started: '500' }, 'SIGTERM')
inspector.signalProcess({ pid: 10, started: 'old' }, 'SIGKILL')
expect(fake.kills).toEqual([[-40, 'SIGINT'], [10, 'SIGTERM']])
})
it('detects read, select, poll, and epoll waits across non-leader threads', () => {
const fake = fakeInternals()
fake.dirs.set('/proc', ['100', '101'])
fake.files.set('/proc/100/stat', stat(100, 77, 100, 77, '1'))
fake.files.set('/proc/101/stat', stat(101, 77, 100, 77, '2'))
fake.dirs.set('/proc/100/task', ['100'])
fake.dirs.set('/proc/101/task', ['101', '102'])
const inspector = createProcessInspector('linux', 'x64', fake.internals)
fake.files.set('/proc/100/task/100/syscall', 'running')
fake.files.set('/proc/101/task/101/syscall', '-1 0x0')
fake.files.set('/proc/101/task/102/syscall', syscall(0, 0))
expect(inspector.isStdinWaiting(77)).toBe(true)
fake.files.set('/proc/101/task/102/syscall', syscall(270, 1, 0x10))
const fdSet = Buffer.alloc(0x11)
fdSet[0x10] = 1
fake.memories.set('/proc/101/mem', fdSet)
expect(inspector.isStdinWaiting(77)).toBe(true)
const poll = Buffer.alloc(8)
poll.writeInt32LE(0, 0)
poll.writeInt16LE(1, 4)
fake.files.set('/proc/101/task/102/syscall', syscall(7, 0x20, 1))
fake.memories.set('/proc/101/mem', Buffer.concat([Buffer.alloc(0x20), poll]))
expect(inspector.isStdinWaiting(77)).toBe(true)
fake.files.set('/proc/101/task/102/syscall', syscall(232, 5, 0, 1))
fake.files.set('/proc/101/fdinfo/5', 'pos: 0\ntfd: 0 events: 19\n')
expect(inspector.isStdinWaiting(77)).toBe(true)
})
it('fails closed on unsupported, malformed, unreadable, or non-stdin waits', () => {
const fake = fakeInternals()
fake.dirs.set('/proc', ['100'])
fake.files.set('/proc/100/stat', stat(100, 77, 100, 77, '1'))
fake.dirs.set('/proc/100/task', ['100'])
fake.files.set('/proc/100/task/100/syscall', syscall(0, 2))
expect(createProcessInspector('linux', 'mips', fake.internals).isStdinWaiting(77)).toBe(false)
expect(createProcessInspector('linux', 'x64', fake.internals).isStdinWaiting(77)).toBe(false)
fake.files.set('/proc/100/task/100/syscall', syscall(270, 1, 0))
expect(createProcessInspector('linux', 'x64', fake.internals).isStdinWaiting(77)).toBe(false)
fake.files.set('/proc/100/task/100/syscall', syscall(7, 0, 0))
expect(createProcessInspector('linux', 'x64', fake.internals).isStdinWaiting(77)).toBe(false)
fake.files.set('/proc/100/task/100/syscall', syscall(7, 0, 1))
expect(createProcessInspector('linux', 'x64', fake.internals).isStdinWaiting(77)).toBe(false)
fake.files.set('/proc/100/task/100/syscall', syscall(7, 0x20, 1))
expect(createProcessInspector('linux', 'x64', fake.internals).isStdinWaiting(77)).toBe(false)
fake.files.set('/proc/100/task/100/syscall', syscall(232, 9, 0, 1))
expect(createProcessInspector('linux', 'x64', fake.internals).isStdinWaiting(77)).toBe(false)
fake.files.set('/proc/100/task/100/syscall', syscall(999))
expect(createProcessInspector('linux', 'x64', fake.internals).isStdinWaiting(77)).toBe(false)
fake.files.set('/proc/100/task/100/syscall', 'not-a-number 0x0')
expect(createProcessInspector('linux', 'x64', fake.internals).isStdinWaiting(77)).toBe(false)
fake.dirs.delete('/proc/100/task')
expect(createProcessInspector('linux', 'x64', fake.internals).isStdinWaiting(77)).toBe(false)
fake.dirs.set('/proc', ['100', '200'])
fake.files.set('/proc/200/stat', stat(200, 88, 200, 88, '2'))
expect(createProcessInspector('linux', 'x64', fake.internals).isStdinWaiting(77)).toBe(false)
})
it('contains unreadable syscall, memory, and fdinfo boundaries', () => {
const fake = fakeInternals()
fake.dirs.set('/proc', ['100'])
fake.files.set('/proc/100/stat', stat(100, 77, 100, 77, '1'))
fake.dirs.set('/proc/100/task', ['100'])
const inspector = createProcessInspector('linux', 'x64', fake.internals)
expect(inspector.isStdinWaiting(77)).toBe(false)
fake.files.set('/proc/100/task/100/syscall', syscall(270, 1, 0x10))
expect(inspector.isStdinWaiting(77)).toBe(false)
fake.files.set('/proc/100/task/100/syscall', syscall(232, 5, 0, 1))
expect(inspector.isStdinWaiting(77)).toBe(false)
const noStdinPoll = Buffer.alloc(0x28)
noStdinPoll.writeInt32LE(2, 0x20)
noStdinPoll.writeInt16LE(1, 0x24)
fake.memories.set('/proc/100/mem', noStdinPoll)
fake.files.set('/proc/100/task/100/syscall', syscall(7, 0x20, 1))
expect(inspector.isStdinWaiting(77)).toBe(false)
})
})
describe('macOS process inspector', () => {
it('reads tpgid and process trees, contains cycles, and identity-fences signals', () => {
const fake = fakeInternals()
fake.setTpgid('55\n')
fake.setPs(' 10 1 Mon Jul 21 10:00:00 2026\n 11 10 Mon Jul 21 10:00:01 2026\n 12 11 Mon Jul 21 10:00:02 2026\n 13 99 Mon Jul 21 10:00:03 2026\nmalformed\n')
const inspector = createProcessInspector('darwin', 'arm64', fake.internals)
expect(inspector.foregroundPgid(10)).toBe(55)
expect(inspector.isStdinWaiting(55)).toBe(false)
expect(inspector.processTree(10)).toEqual([
{ pid: 12, started: 'Mon Jul 21 10:00:02 2026' },
{ pid: 11, started: 'Mon Jul 21 10:00:01 2026' },
{ pid: 10, started: 'Mon Jul 21 10:00:00 2026' },
])
expect(inspector.processTree(99)).toEqual([])
expect(inspector.isAlive({ pid: 11, started: 'Mon Jul 21 10:00:01 2026' })).toBe(true)
inspector.signalGroup(55, 'SIGTSTP')
inspector.signalProcess({ pid: 11, started: 'Mon Jul 21 10:00:01 2026' }, 'SIGKILL')
inspector.signalProcess({ pid: 12, started: 'missing' }, 'SIGTERM')
expect(fake.kills).toEqual([[-55, 'SIGTSTP'], [11, 'SIGKILL']])
fake.setPs(' 10 11 Mon Jul 21 10:00:00 2026\n 11 10 Mon Jul 21 10:00:01 2026\n')
expect(inspector.processTree(10)).toEqual([
{ pid: 11, started: 'Mon Jul 21 10:00:01 2026' },
{ pid: 10, started: 'Mon Jul 21 10:00:00 2026' },
])
})
it('returns undefined for missing or invalid foreground groups and rejects unsupported platforms', () => {
const fake = fakeInternals()
fake.setTpgid('-1')
expect(createProcessInspector('darwin', 'arm64', fake.internals).foregroundPgid(1)).toBeUndefined()
fake.internals.exec = () => { throw new Error('gone') }
expect(createProcessInspector('darwin', 'arm64', fake.internals).foregroundPgid(1)).toBeUndefined()
expect(() => createProcessInspector('win32', 'x64', fake.internals)).toThrow('unsupported platform win32')
})
})
@@ -0,0 +1,27 @@
import { describe, expect, it } from 'vitest'
import { normalizeTerminalText, TerminalSanitizer } from '@deepseek-ai/dsh-pty-local/src/sanitize.ts'
describe('TerminalSanitizer', () => {
it('removes split CSI and owned OSC prompt markers', () => {
const sanitizer = new TerminalSanitizer()
expect(sanitizer.push('red\x1b[3')).toEqual({ text: 'red', prompt: false })
expect(sanitizer.push('1m text\x1b[0m\r\n')).toEqual({ text: ' text\n', prompt: false })
expect(sanitizer.push('\x1b]133;')).toEqual({ text: '', prompt: false })
expect(sanitizer.push('D;0\x07dsh> ')).toEqual({ text: 'dsh> ', prompt: true })
})
it('drops unrelated OSC, short escapes, BEL, and incomplete trailing escape', () => {
const sanitizer = new TerminalSanitizer()
expect(sanitizer.push('a\x1b]0;title\x1b\\b\x1b7c\x07')).toEqual({ text: 'abc', prompt: false })
expect(sanitizer.push('tail\x1b')).toEqual({ text: 'tail', prompt: false })
expect(sanitizer.flush()).toBe('')
expect(sanitizer.flush()).toBe('')
expect(sanitizer.push('\x1b]0;one\x07middle\x1b\\')).toEqual({ text: 'middle', prompt: false })
expect(sanitizer.push('\x1b]0;one\x1b\\middle\x07')).toEqual({ text: 'middle', prompt: false })
expect(sanitizer.push('\x1b]0;title\x1b\\')).toEqual({ text: '', prompt: false })
})
it('normalizes CRLF and standalone carriage returns', () => {
expect(normalizeTerminalText('a\r\nb\rc\x07')).toBe('a\nb\nc')
})
})
@@ -0,0 +1,301 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import type { IDisposable, IPty } from 'node-pty'
import { LocalPtySession } from '@deepseek-ai/dsh-pty-local/src/session.ts'
import type { ResolvedConfig } from '@deepseek-ai/dsh-pty-local/src/config.ts'
import type { ProcessIdentity, ProcessInspector } from '@deepseek-ai/dsh-pty-local/src/process-inspector.ts'
import type { PtySendOperation, PtySessionStatus, PtySignal } from '@deepseek-ai/dsh-pty'
class FakeTerminal {
pid = 123
cols = 80
rows = 24
process = 'bash'
handleFlowControl = false
writes: string[] = []
kills: string[] = []
throwWrite = false
throwKill = false
private dataListeners = new Set<(data: string) => void>()
private exitListeners = new Set<(event: { exitCode: number; signal?: number }) => void>()
readonly onData = (listener: (data: string) => void): IDisposable => {
this.dataListeners.add(listener)
return { dispose: () => this.dataListeners.delete(listener) }
}
readonly onExit = (listener: (event: { exitCode: number; signal?: number }) => void): IDisposable => {
this.exitListeners.add(listener)
return { dispose: () => this.exitListeners.delete(listener) }
}
emitData(data: string): void {
for (const listener of this.dataListeners) listener(data)
}
emitExit(exitCode = 0, signal?: number): void {
for (const listener of this.exitListeners) listener({ exitCode, ...signal === undefined ? {} : { signal } })
}
write(data: string): void {
if (this.throwWrite) throw new Error('write failed')
this.writes.push(data)
}
kill(signal?: string): void {
if (this.throwKill) throw new Error('kill failed')
this.kills.push(signal ?? 'SIGHUP')
this.emitExit(0, signal === 'SIGKILL' ? 9 : 15)
}
resize() {}
clear() {}
pause() {}
resume() {}
asPty(): IPty {
return this
}
}
class FakeInspector implements ProcessInspector {
pgid: number | undefined = 456
waiting = false
members: ProcessIdentity[] = []
alive = new Set<number>()
groups: Array<[number, PtySignal]> = []
processes: Array<[number, 'SIGTERM' | 'SIGKILL']> = []
throwGroup = false
throwProcess = false
removeOnSignal = true
foregroundPgid() { return this.pgid }
isStdinWaiting() { return this.waiting }
processTree() { return this.members }
isAlive(identity: ProcessIdentity) { return this.alive.has(identity.pid) }
signalGroup(pgid: number, signal: PtySignal) {
if (this.throwGroup) throw new Error('group failed')
this.groups.push([pgid, signal])
}
signalProcess(identity: ProcessIdentity, signal: 'SIGTERM' | 'SIGKILL') {
if (this.throwProcess) throw new Error('process raced')
this.processes.push([identity.pid, signal])
if (this.removeOnSignal) this.alive.delete(identity.pid)
}
}
function config(overrides: Partial<ResolvedConfig> = {}): ResolvedConfig {
return {
backendType: 'shell', shellPath: '/bin/bash', shellArgs: [], rows: 24, cols: 80,
scrollbackLines: 10, scrollbackMaxBytes: 128, maxReadBytes: 64,
pollIntervalMs: 10, exactProbeAfterMs: 20, idleSilenceMs: 50, timeoutMs: 100,
disposeGraceMs: 20,
...overrides,
}
}
afterEach(() => { vi.useRealTimers() })
async function initialize(session: LocalPtySession, terminal: FakeTerminal): Promise<void> {
const pending = session.initialize()
terminal.emitData('\x1b]133;D;0\x07dsh> ')
await vi.advanceTimersByTimeAsync(10)
await pending
}
describe('LocalPtySession readiness and output', () => {
it('captures prompt MOTD, writes submit explicitly, and settles exact stdin waits', async () => {
vi.useFakeTimers()
const terminal = new FakeTerminal()
const inspector = new FakeInspector()
const session = new LocalPtySession(terminal.asPty(), inspector, config())
await initialize(session, terminal)
expect(session.motd).toBe('dsh> ')
inspector.waiting = true
const operation = session.startSend({ text: 'python3', submit: true })
expect(terminal.writes).toEqual(['python3', '\r'])
terminal.emitData('Python\r\n>>> ')
await vi.advanceTimersByTimeAsync(20)
expect(await operation.done).toMatchObject({ waitReason: 'stdin_read', viewport: 'Python\n>>> ', sessionStatus: { kind: 'running' } })
expect(operation.cancel()).toBe(false)
})
it('distinguishes inferred idle, timeout, exit signal, and operation reads', async () => {
vi.useFakeTimers()
const terminal = new FakeTerminal()
const inspector = new FakeInspector()
inspector.pgid = undefined
const session = new LocalPtySession(terminal.asPty(), inspector, config())
await initialize(session, terminal)
const inferred = session.startSend({ text: 'sleep', submit: false })
terminal.emitData('working')
expect(inferred.readOutput()).toEqual({ delta: 'working', truncated: false })
await vi.advanceTimersByTimeAsync(60)
expect((await inferred.done).waitReason).toBe('inferred_idle')
const timeout = session.startSend({ text: 'blocked', submit: false })
await vi.advanceTimersByTimeAsync(40)
terminal.emitData('.')
await vi.advanceTimersByTimeAsync(40)
terminal.emitData('.')
await vi.advanceTimersByTimeAsync(30)
expect((await timeout.done).waitReason).toBe('timeout')
const exiting = session.startSend({ text: 'exit', submit: true })
terminal.emitExit(7, 9)
expect(await exiting.done).toMatchObject({ waitReason: 'session_exit', sessionStatus: { kind: 'exited', exitCode: 7, signal: 'SIGKILL' } })
expect(() => session.startSend({ text: '', submit: false })).toThrow('has exited')
})
it('cancels with Ctrl-C, observes AbortSignal, and contains write failures', async () => {
vi.useFakeTimers()
const terminal = new FakeTerminal()
const inspector = new FakeInspector()
const session = new LocalPtySession(terminal.asPty(), inspector, config())
await initialize(session, terminal)
const controller = new AbortController()
const operation = session.startSend({ text: 'sleep', submit: true, signal: controller.signal })
expect(() => session.startSend({ text: 'again', submit: true })).toThrow('active send')
controller.abort()
expect(terminal.writes.at(-1)).toBe('\x03')
terminal.emitData('\x1b]133;D;130\x07dsh> ')
await vi.advanceTimersByTimeAsync(10)
await operation.done
const aborted = new AbortController()
aborted.abort()
expect(() => session.startSend({ text: '', submit: false, signal: aborted.signal })).toThrow('aborted before write')
terminal.throwWrite = true
const failed = session.startSend({ text: 'x', submit: false })
await expect(failed.done).rejects.toThrow('write failed')
const failedInternal = failed as unknown as { append(text: string): void; fail(error: unknown): void }
failedInternal.append('ignored')
failedInternal.fail(new Error('ignored'))
})
it('handles startup exit, unknown exit signals, cancel-write failure, and stale polls', async () => {
vi.useFakeTimers()
const startupTerminal = new FakeTerminal()
const startup = new LocalPtySession(startupTerminal.asPty(), new FakeInspector(), config())
const initializing = startup.initialize(new AbortController().signal)
startupTerminal.emitExit(1)
await expect(initializing).rejects.toThrow('exited during startup')
expect(startup.status()).toEqual({ kind: 'exited', exitCode: 1, signal: null })
const terminal = new FakeTerminal()
const session = new LocalPtySession(terminal.asPty(), new FakeInspector(), config())
await initialize(session, terminal)
const operation = session.startSend({ text: '', submit: false })
const operationInternal = operation as unknown as {
append(text: string): void
settle(reason: 'timeout', status: PtySessionStatus, inherited: boolean): void
}
operationInternal.append('')
const sessionInternal = session as unknown as {
pollReadiness(operation: PtySendOperation): void
statusValue: PtySessionStatus
appendOutput(text: string): void
}
sessionInternal.appendOutput('')
sessionInternal.pollReadiness({} as PtySendOperation)
sessionInternal.statusValue = { kind: 'exited', exitCode: 2, signal: null }
sessionInternal.pollReadiness(operation)
await operation.done
operationInternal.settle('timeout', { kind: 'running' }, false)
const unknownTerminal = new FakeTerminal()
const unknown = new LocalPtySession(unknownTerminal.asPty(), new FakeInspector(), config())
unknownTerminal.emitExit(1, 999)
expect(unknown.status()).toEqual({ kind: 'exited', exitCode: 1, signal: null })
const cancelTerminal = new FakeTerminal()
const cancel = new LocalPtySession(cancelTerminal.asPty(), new FakeInspector(), config())
await initialize(cancel, cancelTerminal)
const cancellable = cancel.startSend({ text: '', submit: false })
cancelTerminal.throwWrite = true
expect(cancellable.cancel()).toBe(true)
await expect(cancellable.done).rejects.toThrow('write failed')
})
})
describe('LocalPtySession bounds, signals, and teardown', () => {
it('validates pagination and enforces line/UTF-8 bounds', async () => {
vi.useFakeTimers()
const terminal = new FakeTerminal()
const session = new LocalPtySession(
terminal.asPty(),
new FakeInspector(),
config({ scrollbackLines: 3, scrollbackMaxBytes: 12, maxReadBytes: 6 }),
)
expect(session.read({})).toMatchObject({ text: '' })
await initialize(session, terminal)
const operation = session.startSend({ text: '', submit: false })
terminal.emitData('一\n二\n三\n四')
await vi.advanceTimersByTimeAsync(60)
expect((await operation.done).truncated).toBe(true)
const page = session.read({ offset: 0, count: 3 })
expect(Buffer.byteLength(page.text)).toBeLessThanOrEqual(6)
expect(page.truncated).toBe(true)
expect(session.read({ offset: 999 })).toMatchObject({ text: '', lineBegin: 999, lineEnd: 999 })
expect(() => session.read({ offset: -1 })).toThrow('offset')
expect(() => session.read({ count: 0 })).toThrow('count')
const tinyTerminal = new FakeTerminal()
const tiny = new LocalPtySession(tinyTerminal.asPty(), new FakeInspector(), config({ maxReadBytes: 1 }))
await initialize(tiny, tinyTerminal)
const tinyOperation = tiny.startSend({ text: '', submit: false })
tinyTerminal.emitData('一')
await vi.advanceTimersByTimeAsync(60)
await tinyOperation.done
expect(tiny.read({ offset: 0, count: 1 }).text).toBe('')
})
it('signals verified groups and refuses unresolved or shell-targeted hard kills', async () => {
const terminal = new FakeTerminal()
const inspector = new FakeInspector()
const session = new LocalPtySession(terminal.asPty(), inspector, config())
expect(await session.signal('SIGINT')).toEqual({ delivered: true, targetPgid: 456 })
inspector.pgid = terminal.pid
await expect(session.signal('SIGKILL')).rejects.toThrow('use pty_kill')
inspector.pgid = undefined
await expect(session.signal('SIGTERM')).rejects.toThrow('cannot resolve')
})
it('closes idempotently, contains signal races, and reports survivors', async () => {
const terminal = new FakeTerminal()
const inspector = new FakeInspector()
inspector.members = [{ pid: 123, started: 'a' }]
inspector.alive.add(123)
inspector.throwProcess = true
terminal.throwKill = true
const session = new LocalPtySession(terminal.asPty(), inspector, config({ disposeGraceMs: 1 }))
const closing = session.close('test')
expect(session.close('other')).toBe(closing)
await expect(closing).rejects.toThrow('surviving pids: 123')
expect(() => session.startSend({ text: '', submit: false })).toThrow('closing')
})
it('waits for SIGKILL recipients to leave the process table after the shell exits', async () => {
vi.useFakeTimers()
const terminal = new FakeTerminal()
const inspector = new FakeInspector()
inspector.members = [{ pid: 124, started: 'child' }]
inspector.alive.add(124)
inspector.removeOnSignal = false
const session = new LocalPtySession(terminal.asPty(), inspector, config({ disposeGraceMs: 20 }))
let settled = false
const closing = session.close('test').then(() => { settled = true })
await vi.advanceTimersByTimeAsync(20)
expect(inspector.processes).toContainEqual([124, 'SIGKILL'])
expect(settled).toBe(false)
inspector.alive.delete(124)
await vi.advanceTimersByTimeAsync(20)
await closing
expect(settled).toBe(true)
})
})
+30
View File
@@ -0,0 +1,30 @@
{
"extends": "../../../tsconfig.base.json",
"compilerOptions": {
"rootDir": "src",
"outDir": "lib/types"
},
"include": [
"src"
],
"references": [
{
"path": "../../../vendor/cosmokit"
},
{
"path": "../../../vendor/cordis"
},
{
"path": "../../../vendor/schemastery"
},
{
"path": "../pty"
},
{
"path": "../../sandbox/sandbox"
},
{
"path": "../../sandbox/sandbox-policy"
}
]
}
+34
View File
@@ -0,0 +1,34 @@
# @deepseek-ai/dsh-pty
Owner-scoped persistent PTY seam. `PtyService` registers as `ctx.pty`, mints opaque session ids, routes creation through named backends, fences every operation to the exact live `Agent`, and awaits backend quiescence when that agent or the service disposes.
## Contract
- Backends register one stable `type` and return an unpublished `PtyBackendSession`; failed or cancelled setup must clean partial resources.
- A successful spawn publishes one `PtySessionId`. The optional `name` is owner-local display metadata, never authority.
- One session accepts at most one live send operation. Reads and signals may observe it; another send fails until the operation settles.
- `PtySendResult.waitReason` and `sessionStatus` are independent. `session_exit` describes the top-level PTY process, not an arbitrary foreground command.
- `kill()` and disposal resolve only after the backend's captured process tree is quiescent. A cleanup failure rejects instead of claiming success.
The seam contains no `node-pty`, sandbox, tool-schema, prompt, task, or terminal-rendering policy. Implementations own terminal mechanics; consumers own model presentation and optional background-task registration.
## Model Experience
### Indirect consumer
#### What the model sees
Nothing directly. This package registers no prompt or tool; `@deepseek-ai/dsh-tool-pty` owns visible schemas and result text.
#### Token effect
None directly. Live session state stays process-local until a consumer returns a bounded result.
#### KV Cache effect
No direct invalidation; the named consumer owns request-prefix changes.
## Known Limitations and Deferred Work
- Sessions are process-local and are not restored after a harness restart.
- Cross-agent sharing is intentionally absent; a future shared-session design needs a separate authority contract.
+35
View File
@@ -0,0 +1,35 @@
{
"name": "@deepseek-ai/dsh-pty",
"description": "Persistent PTY session seam for the DeepSeek Harness — owner-scoped ids, backend registry, interactive sends, reads, signals, and awaited cleanup",
"version": "0.0.1",
"private": true,
"type": "module",
"main": "lib/index.js",
"types": "lib/types/index.d.ts",
"exports": {
".": {
"types": "./lib/types/index.d.ts",
"default": "./lib/index.js"
},
"./src/*": "./src/*",
"./package.json": "./package.json"
},
"files": [
"lib/index.js",
"lib/types/**/*.d.ts",
"lib/types/**/*.d.ts.map",
"src"
],
"license": "BSD-3-Clause",
"peerDependencies": {
"@deepseek-ai/dsh-agent": "^0.0.1",
"@deepseek-ai/dsh-brand": "^0.0.1",
"cordis": "^4.0.0-rc.7"
},
"devDependencies": {
"@deepseek-ai/dsh-agent": "workspace:^",
"@deepseek-ai/dsh-brand": "workspace:^",
"@deepseek-ai/dsh-session": "workspace:^",
"cordis": "^4.0.0-rc.7"
}
}
+356
View File
@@ -0,0 +1,356 @@
/**
* Owner-scoped persistent PTY registry. Backends own terminal mechanics while
* this service owns ids, publication, authorization, and awaited cleanup.
* @module @deepseek-ai/dsh-pty
*/
import { Context, Service } from 'cordis'
import type { Agent } from '@deepseek-ai/dsh-agent'
import type {
PtyBackend,
PtyBackendSession,
PtyReadRequest,
PtyReadResult,
PtySendOperation,
PtySendRequest,
PtySessionIdValue,
PtySessionSnapshot,
PtySignal,
PtySignalResult,
PtySpawnRequest,
PtySpawnResult,
} from './types.ts'
export type {
PtyBackend,
PtyBackendSession,
PtyBackendSpawnSpec,
PtyReadRequest,
PtyReadResult,
PtySendOperation,
PtySendRead,
PtySendRequest,
PtySendResult,
PtySessionSnapshot,
PtySessionStatus,
PtySignal,
PtySignalResult,
PtySpawnRequest,
PtySpawnResult,
PtyWaitReason,
} from './types.ts'
/** Opaque identity minted by {@link PtyService} for one live PTY session. */
export type PtySessionId = PtySessionIdValue
declare module 'cordis' {
interface Context {
pty: PtyService
}
}
/** Machine-routable PTY service failures. */
export type PtyErrorCode =
| 'DUPLICATE_BACKEND'
| 'DUPLICATE_NAME'
| 'FOREIGN_SESSION'
| 'NO_BACKEND'
| 'NO_SESSION'
| 'OWNER_NOT_LIVE'
| 'SEND_ACTIVE'
| 'SERVICE_DISPOSING'
/** Error carrying a stable {@link PtyErrorCode}. */
export class PtyError extends Error {
constructor(message: string, readonly code: PtyErrorCode) {
super(message)
this.name = 'PtyError'
}
}
/**
* Brand one registry-minted string as a {@link PtySessionId}.
* @param value - raw registry-issued id.
* @returns Same string with the PTY session brand.
*/
export function PtySessionId(value: string): PtySessionId {
return value as PtySessionId
}
function isAborted(signal: AbortSignal | undefined): boolean {
return signal?.aborted === true
}
interface SessionRecord {
readonly id: PtySessionId
readonly owner: Agent
readonly name: string | undefined
readonly type: string
readonly session: PtyBackendSession
active: PtySendOperation | undefined
closing: Promise<void> | undefined
}
/** In-process registry for replaceable PTY backends and exact-Agent sessions. */
export class PtyService extends Service {
private readonly backends = new Map<string, PtyBackend>()
private readonly sessions = new Map<PtySessionId, SessionRecord>()
private readonly reservedNames = new Map<Agent, Set<string>>()
private readonly ownerCleanups = new Map<Agent, () => Promise<void> | void>()
private readonly disposedOwners = new WeakSet<Agent>()
private nextId = 0
private disposing = false
constructor(ctx: Context) {
super(ctx, 'pty')
ctx.effect(() => () => this.disposeAll(), 'pty teardown')
}
/**
* Register one backend type for this effect scope.
* @param backend - provider with a non-empty unique type.
* @returns disposer that removes exactly this contribution.
*/
registerBackend(backend: PtyBackend): () => void {
if (backend.type.length === 0) throw new Error('pty backend type must be non-empty')
if (this.backends.has(backend.type)) {
throw new PtyError(`a PTY backend named "${backend.type}" is already registered`, 'DUPLICATE_BACKEND')
}
const dispose = this.ctx.effect(() => {
this.backends.set(backend.type, backend)
return () => {
if (this.backends.get(backend.type) === backend) this.backends.delete(backend.type)
}
}, 'pty.registerBackend()')
return () => void dispose()
}
/**
* List registered backend types in registration order.
* @returns fresh backend type names.
*/
listBackends(): string[] {
return [...this.backends.keys()]
}
/**
* Create and publish one owner-scoped session after backend setup succeeds.
* @param owner - exact registered Agent that owns access and cleanup.
* @param request - backend type plus optional owner-local name and cwd.
* @param signal - cancellation of unpublished setup.
* @returns published identity, metadata, status, and MOTD.
*/
async spawn(owner: Agent, request: PtySpawnRequest, signal?: AbortSignal): Promise<PtySpawnResult> {
this.assertActive()
this.ensureOwnerCleanup(owner)
const backend = this.backends.get(request.type)
if (backend === undefined) throw new PtyError(`no PTY backend registered for "${request.type}"`, 'NO_BACKEND')
if (request.name !== undefined && request.name.length === 0) throw new Error('PTY session name must be non-empty')
if (isAborted(signal)) throw new Error('PTY spawn aborted')
const releaseName = this.reserveName(owner, request.name)
const sessionId = PtySessionId(`pty-${++this.nextId}`)
let session: PtyBackendSession | undefined
try {
session = await backend.spawn({
sessionId,
owner,
type: request.type,
...request.name !== undefined ? { name: request.name } : {},
...request.cwd !== undefined ? { cwd: request.cwd } : {},
...signal !== undefined ? { signal } : {},
})
if (this.disposing || isAborted(signal) || !this.isLiveOwner(owner)) {
throw new PtyError('PTY owner is no longer live', 'OWNER_NOT_LIVE')
}
const record: SessionRecord = {
id: sessionId,
owner,
name: request.name,
type: request.type,
session,
active: undefined,
closing: undefined,
}
this.sessions.set(sessionId, record)
return this.snapshot(record, session.motd)
} catch (error) {
if (session !== undefined && !this.sessions.has(sessionId)) {
try {
await session.close('PTY spawn rolled back')
} catch (closeError: unknown) {
throw new AggregateError([error, closeError], 'PTY spawn and rollback both failed')
}
}
throw error
} finally {
releaseName()
}
}
/**
* Start one exclusive interactive send.
* @param owner - exact session owner.
* @param id - target PTY identity.
* @param request - explicit text, submit behavior, and cancellation.
* @returns live operation handle for foreground await or task registration.
*/
startSend(owner: Agent, id: PtySessionId, request: PtySendRequest): PtySendOperation {
const record = this.expectOwned(owner, id)
if (record.closing !== undefined) throw new Error(`PTY session ${id} is closing`)
if (record.active !== undefined) throw new PtyError(`PTY session ${id} already has an active send`, 'SEND_ACTIVE')
const operation = record.session.startSend(request)
record.active = operation
void operation.done.then(
() => { record.active = undefined },
() => { record.active = undefined },
)
return operation
}
/**
* Read one bounded scrollback page from an owned session.
* @param owner - exact session owner.
* @param id - target PTY identity.
* @param request - optional newest-relative offset and line count.
* @returns bounded retained text and pagination metadata.
*/
read(owner: Agent, id: PtySessionId, request: PtyReadRequest = {}): PtyReadResult {
return this.expectOwned(owner, id).session.read(request)
}
/**
* Deliver an allowed signal through an owned backend session.
* @param owner - exact session owner.
* @param id - target PTY identity.
* @param signal - allowed POSIX signal name.
* @returns delivered foreground process-group identity.
*/
signal(owner: Agent, id: PtySessionId, signal: PtySignal): Promise<PtySignalResult> {
return this.expectOwned(owner, id).session.signal(signal)
}
/**
* Close one owned session and remove it only after quiescent backend cleanup.
* @param owner - exact session owner.
* @param id - target PTY identity.
* @param reason - diagnostic cleanup reason.
* @returns true for a newly closed session, false when the same close is already in flight.
*/
async kill(owner: Agent, id: PtySessionId, reason = 'model request'): Promise<boolean> {
const record = this.expectOwned(owner, id)
if (record.closing !== undefined) {
await record.closing
return false
}
const closing = record.session.close(reason)
record.closing = closing
try {
await closing
this.sessions.delete(id)
return true
} catch (error) {
record.closing = undefined
throw error
}
}
/**
* List fresh snapshots for exactly one owner.
* @param owner - exact owner whose sessions are visible.
* @returns owner-visible snapshots in publication order.
*/
list(owner: Agent): PtySessionSnapshot[] {
return [...this.sessions.values()]
.filter(record => record.owner === owner)
.map(record => this.snapshot(record))
}
private assertActive(): void {
if (this.disposing) throw new PtyError('PTY service is disposing', 'SERVICE_DISPOSING')
}
private isLiveOwner(owner: Agent): boolean {
return !this.disposedOwners.has(owner) && this.ctx.get('agents')?.get(owner.id) === owner
}
private ensureOwnerCleanup(owner: Agent): void {
if (!this.isLiveOwner(owner)) {
throw new PtyError(`agent "${owner.id}" is not the registered PTY owner`, 'OWNER_NOT_LIVE')
}
if (this.ownerCleanups.has(owner)) return
const detach = owner.ctx.effect(() => async () => {
this.disposedOwners.add(owner)
this.ownerCleanups.delete(owner)
await this.disposeOwned(owner)
}, 'pty.ownerCleanup()')
this.ownerCleanups.set(owner, detach)
}
private reserveName(owner: Agent, name: string | undefined): () => void {
if (name === undefined) return () => {}
if ([...this.sessions.values()].some(record => record.owner === owner && record.name === name)) {
throw new PtyError(`PTY session name "${name}" already exists for this owner`, 'DUPLICATE_NAME')
}
const reserved = this.reservedNames.get(owner) ?? new Set<string>()
if (reserved.has(name)) throw new PtyError(`PTY session name "${name}" is already being created`, 'DUPLICATE_NAME')
reserved.add(name)
this.reservedNames.set(owner, reserved)
return () => {
reserved.delete(name)
if (reserved.size === 0) this.reservedNames.delete(owner)
}
}
private expectOwned(owner: Agent, id: PtySessionId): SessionRecord {
const record = this.sessions.get(id)
if (record === undefined) throw new PtyError(`unknown PTY session ${id}`, 'NO_SESSION')
if (record.owner !== owner) throw new PtyError(`PTY session ${id} belongs to another agent`, 'FOREIGN_SESSION')
return record
}
private snapshot(record: SessionRecord): PtySessionSnapshot
private snapshot(record: SessionRecord, motd: string): PtySpawnResult
private snapshot(record: SessionRecord, motd?: string): PtySpawnResult | PtySessionSnapshot {
return {
sessionId: record.id,
...record.name !== undefined ? { name: record.name } : {},
type: record.type,
...record.session.pid !== undefined ? { pid: record.session.pid } : {},
status: record.session.status(),
...motd !== undefined ? { motd } : {},
}
}
private async disposeOwned(owner: Agent): Promise<void> {
const owned = [...this.sessions.values()].filter(record => record.owner === owner)
await this.closeRecords(owned, 'PTY owner disposed')
this.reservedNames.delete(owner)
}
private async disposeAll(): Promise<void> {
this.disposing = true
const records = [...this.sessions.values()]
await this.closeRecords(records, 'PTY service disposed')
this.backends.clear()
this.reservedNames.clear()
const cleanups = [...this.ownerCleanups.values()]
this.ownerCleanups.clear()
await Promise.all(cleanups.map(cleanup => Promise.resolve(cleanup())))
}
private async closeRecords(records: SessionRecord[], reason: string): Promise<void> {
const results = await Promise.allSettled(records.map(async (record) => {
const closing = record.closing ?? record.session.close(reason)
record.closing = closing
await closing
this.sessions.delete(record.id)
}))
const failures = results
.filter((result): result is PromiseRejectedResult => result.status === 'rejected')
.map<unknown>(result => result.reason as unknown)
if (failures.length > 0) throw new AggregateError(failures, `failed to close ${failures.length} PTY session(s)`)
}
}
export default PtyService
+158
View File
@@ -0,0 +1,158 @@
/**
* Types shared by PTY backends, the owner-scoped registry, and tool consumers.
* Runtime service code lives in `./index.ts`.
* @module @deepseek-ai/dsh-pty/types
*/
import type { Branded } from '@deepseek-ai/dsh-brand'
import type { Agent } from '@deepseek-ai/dsh-agent'
/** Internal exported basis for the public `PtySessionId` type/value pair. */
export type PtySessionIdValue = Branded<'PtySessionId'>
/** Why one interactive send returned control to its caller. */
export type PtyWaitReason = 'stdin_read' | 'inferred_idle' | 'timeout' | 'session_exit'
/** Signals the model-facing PTY surface permits for foreground process groups. */
export type PtySignal = 'SIGINT' | 'SIGTERM' | 'SIGKILL' | 'SIGTSTP' | 'SIGHUP'
/** Top-level PTY process status, independent of a send's wait reason. */
export type PtySessionStatus =
| { kind: 'running' }
| { kind: 'exited'; exitCode: number | null; signal: NodeJS.Signals | null }
/** Request to create one owner-scoped PTY session. */
export interface PtySpawnRequest {
/** Registered backend type. */
type: string
/** Optional owner-local display name. */
name?: string
/** Optional initial working directory interpreted by the backend. */
cwd?: string
}
/** Fully identified request handed from the registry to a backend. */
export interface PtyBackendSpawnSpec extends PtySpawnRequest {
/** Registry-minted session identity. */
sessionId: PtySessionIdValue
/** Exact live owner for authority-aware backend setup. */
owner: Agent
/** Cancellation of unpublished backend setup. */
signal?: AbortSignal
}
/** Input for one line-oriented terminal interaction. */
export interface PtySendRequest {
/** UTF-8 text to write. */
text: string
/** Whether to write the backend's Enter sequence after {@link text}. */
submit: boolean
/** Cancellation for the wait; backends also interrupt the foreground command. */
signal?: AbortSignal
}
/** Incremental output consumed from one live send operation. */
export interface PtySendRead {
/** Output produced since the previous operation read. */
delta: string
/** Whether unread operation output was dropped by the backend's bound. */
truncated: boolean
}
/** Settled result for one foreground or background send. */
export interface PtySendResult {
/** Bounded rendered terminal delta remaining at settlement. */
viewport: string
/** Why the wait returned; this does not imply arbitrary child-process exit. */
waitReason: PtyWaitReason
/** Top-level session status observed at settlement. */
sessionStatus: PtySessionStatus
/** Whether output was dropped from the operation or retained scrollback. */
truncated: boolean
}
/** Live backend-owned send; exactly one may be active per PTY session. */
export interface PtySendOperation {
/** Resolves after readiness, timeout, cancellation, or top-level process exit. */
done: Promise<PtySendResult>
/** Consume output produced since the prior call. */
readOutput(): PtySendRead
/** Request `SIGINT`; returns false after the operation settled. */
cancel(): boolean
}
/** Request for one backward scrollback page. */
export interface PtyReadRequest {
/** Offset from the newest retained line; defaults are backend-owned. */
offset?: number
/** Requested line count; backend limits still apply. */
count?: number
}
/** Bounded scrollback page. */
export interface PtyReadResult {
/** Retained text in chronological order. */
text: string
/** Number of lines currently retained. */
totalLines: number
/** Inclusive newest-relative offset of the first returned line. */
lineBegin: number
/** Exclusive newest-relative offset after the returned page. */
lineEnd: number
/** Whether older retained output or the requested result exceeded a bound. */
truncated: boolean
}
/** Result of delivering a signal to a verified foreground process group. */
export interface PtySignalResult {
/** True only after the backend delivered the signal. */
delivered: true
/** Process group that received the signal. */
targetPgid: number
}
/** Owner-visible summary of one published PTY session. */
export interface PtySessionSnapshot {
/** Registry-minted identity used by every operation. */
sessionId: PtySessionIdValue
/** Optional owner-local display name. */
name?: string
/** Backend type that created the session. */
type: string
/** Top-level process id when the backend has one. */
pid?: number
/** Current top-level process status. */
status: PtySessionStatus
}
/** Backend-owned live session retained by {@link PtyService}. */
export interface PtyBackendSession {
/** Initial bounded terminal output returned from `pty_spawn`. */
readonly motd: string
/** Top-level process id when one exists. */
readonly pid?: number
/** Start one exclusive send operation. */
startSend(request: PtySendRequest): PtySendOperation
/** Read one bounded page from retained scrollback. */
read(request: PtyReadRequest): PtyReadResult
/** Signal the verified foreground process group. */
signal(signal: PtySignal): Promise<PtySignalResult>
/** Observe top-level process status. */
status(): PtySessionStatus
/** Idempotently close the captured owned process tree and await quiescence. */
close(reason: string): Promise<void>
}
/** Replaceable provider for one PTY session type. */
export interface PtyBackend {
/** Stable type selected by {@link PtySpawnRequest.type}. */
readonly type: string
/** Create an unpublished session or reject after cleaning partial resources. */
spawn(spec: PtyBackendSpawnSpec): Promise<PtyBackendSession>
}
/** Successful publication returned by {@link PtyService.spawn}. */
export interface PtySpawnResult extends PtySessionSnapshot {
/** Initial bounded output captured before publication. */
motd: string
}
+346
View File
@@ -0,0 +1,346 @@
import { describe, expect, expectTypeOf, it } from 'vitest'
import { Context } from 'cordis'
import { Session, SessionId } from '@deepseek-ai/dsh-session'
import AgentRegistry from '@deepseek-ai/dsh-agent'
import type { Agent } from '@deepseek-ai/dsh-agent'
import PtyService, { PtyError, PtySessionId } from '@deepseek-ai/dsh-pty'
import type {
PtyBackend,
PtyBackendSession,
PtyReadRequest,
PtySendOperation,
PtySendRequest,
PtySessionId as PtySessionIdType,
PtySessionStatus,
PtySignal,
} from '@deepseek-ai/dsh-pty'
const agentScopeDisposers = new WeakMap<Agent, () => Promise<void>>()
const ptyServiceDisposers = new WeakMap<Context, () => Promise<void>>()
function stubAgent(ctx: Context, rawId: string): Agent {
const id = SessionId(rawId)
const scopeFiber = ctx.plugin(() => {})
const agent: Agent = {
id,
options: {},
session: new Session(id),
status: 'idle',
ctx: scopeFiber.ctx,
send() {},
steer() {},
inject() {},
cancel() {},
whenIdle: () => Promise.resolve(),
}
agentScopeDisposers.set(agent, async () => { await scopeFiber.dispose() })
return agent
}
async function disposeAgentScope(agent: Agent): Promise<void> {
const dispose = agentScopeDisposers.get(agent)
if (dispose === undefined) throw new Error('missing agent scope')
await dispose()
}
class StubSession implements PtyBackendSession {
readonly motd = 'stub ready'
readonly pid = 123
closed: string[] = []
statusValue: PtySessionStatus = { kind: 'running' }
operation: PtySendOperation | undefined
rejectSend = false
rejectClose = false
closeGate: PromiseWithResolvers<undefined> | undefined
startSend(_request: PtySendRequest): PtySendOperation {
if (this.rejectSend) {
return { done: Promise.reject(new Error('send failed')), readOutput: () => ({ delta: '', truncated: false }), cancel: () => false }
}
let settle!: () => void
let settled = false
const done = new Promise<void>((resolve) => { settle = resolve }).then(() => ({
viewport: 'done',
waitReason: 'stdin_read' as const,
sessionStatus: this.statusValue,
truncated: false,
}))
const operation: PtySendOperation = {
done,
readOutput: () => ({ delta: 'delta', truncated: false }),
cancel: () => {
if (settled) return false
settled = true
settle()
return true
},
}
this.operation = operation
return operation
}
read(request: PtyReadRequest) {
return { text: `${request.offset ?? 0}:${request.count ?? 0}`, totalLines: 1, lineBegin: 0, lineEnd: 1, truncated: false }
}
async signal(signal: PtySignal) {
return { delivered: true as const, targetPgid: signal === 'SIGINT' ? 12 : 13 }
}
status(): PtySessionStatus {
return this.statusValue
}
async close(reason: string): Promise<void> {
this.closed.push(reason)
if (this.rejectClose) throw new Error('close failed')
if (this.closeGate !== undefined) await this.closeGate.promise
this.statusValue = { kind: 'exited', exitCode: 0, signal: null }
this.operation?.cancel()
}
}
function backend(type = 'stub') {
const sessions: StubSession[] = []
const provider: PtyBackend = {
type,
async spawn() {
const session = new StubSession()
sessions.push(session)
return session
},
}
return { provider, sessions }
}
async function harness() {
const ctx = new Context()
await ctx.plugin(AgentRegistry)
const fiber = await ctx.plugin(PtyService)
ptyServiceDisposers.set(ctx, async () => { await fiber.dispose() })
return ctx
}
async function disposePtyService(ctx: Context): Promise<void> {
const dispose = ptyServiceDisposers.get(ctx)
if (dispose === undefined) throw new Error('missing PTY service fiber')
await dispose()
}
describe('PtyService backend registry', () => {
it('preserves the id brand and disposes exact backend contributions', async () => {
expectTypeOf(PtySessionId('pty-1')).toEqualTypeOf<PtySessionIdType>()
const ctx = await harness()
const first = backend()
const dispose = ctx.pty.registerBackend(first.provider)
expect(ctx.pty.listBackends()).toEqual(['stub'])
expect(() => ctx.pty.registerBackend(backend().provider)).toThrow(PtyError)
const internal = ctx.pty as unknown as { backends: Map<string, PtyBackend> }
internal.backends.set('stub', backend('replacement').provider)
dispose()
expect(ctx.pty.listBackends()).toEqual(['stub'])
internal.backends.clear()
})
it('rejects empty backend types', async () => {
const ctx = await harness()
expect(() => ctx.pty.registerBackend(backend('').provider)).toThrow('must be non-empty')
})
})
describe('PtyService ownership and lifecycle', () => {
it('publishes only after spawn and fences every operation to the exact owner', async () => {
const ctx = await harness()
const b = backend()
ctx.pty.registerBackend(b.provider)
const owner = stubAgent(ctx, 'owner')
const foreign = stubAgent(ctx, 'foreign')
ctx.agents.register(owner)
ctx.agents.register(foreign)
const created = await ctx.pty.spawn(owner, { type: 'stub', name: 'main', cwd: '/tmp' })
expect(created).toMatchObject({ sessionId: 'pty-1', name: 'main', type: 'stub', pid: 123, motd: 'stub ready', status: { kind: 'running' } })
expect(ctx.pty.list(owner)).toHaveLength(1)
expect(ctx.pty.list(foreign)).toEqual([])
expect(() => ctx.pty.read(foreign, created.sessionId)).toThrow('belongs to another agent')
expect(() => ctx.pty.signal(foreign, created.sessionId, 'SIGINT')).toThrow('belongs to another agent')
await expect(Promise.resolve().then(() => ctx.pty.kill(foreign, created.sessionId))).rejects.toThrow('belongs to another agent')
})
it('rejects unknown backends, non-live owners, duplicate names, and active sends', async () => {
const ctx = await harness()
const owner = stubAgent(ctx, 'owner')
await expect(ctx.pty.spawn(owner, { type: 'missing' })).rejects.toMatchObject({ code: 'OWNER_NOT_LIVE' })
ctx.agents.register(owner)
await expect(ctx.pty.spawn(owner, { type: 'missing' })).rejects.toMatchObject({ code: 'NO_BACKEND' })
const b = backend()
ctx.pty.registerBackend(b.provider)
const created = await ctx.pty.spawn(owner, { type: 'stub', name: 'main' })
await expect(ctx.pty.spawn(owner, { type: 'stub', name: '' })).rejects.toThrow('must be non-empty')
const aborted = new AbortController()
aborted.abort()
await expect(ctx.pty.spawn(owner, { type: 'stub' }, aborted.signal)).rejects.toThrow('spawn aborted')
await expect(ctx.pty.spawn(owner, { type: 'stub', name: 'main' })).rejects.toMatchObject({ code: 'DUPLICATE_NAME' })
const operation = ctx.pty.startSend(owner, created.sessionId, { text: 'echo hi', submit: true })
expect(() => ctx.pty.startSend(owner, created.sessionId, { text: 'pwd', submit: true })).toThrow(PtyError)
expect(operation.readOutput()).toEqual({ delta: 'delta', truncated: false })
expect(operation.cancel()).toBe(true)
await operation.done
const next = ctx.pty.startSend(owner, created.sessionId, { text: 'pwd', submit: true })
next.cancel()
await next.done
b.sessions[0]!.rejectSend = true
await expect(ctx.pty.startSend(owner, created.sessionId, { text: 'bad', submit: true }).done).rejects.toThrow('send failed')
await new Promise(resolve => setTimeout(resolve, 0))
})
it('reserves concurrent names and rolls back a spawn whose owner disappears', async () => {
const ctx = await harness()
const gate = Promise.withResolvers<PtyBackendSession>()
const session = new StubSession()
ctx.pty.registerBackend({ type: 'slow', spawn: () => gate.promise })
const owner = stubAgent(ctx, 'owner')
ctx.agents.register(owner)
const pending = ctx.pty.spawn(owner, { type: 'slow', name: 'main' })
await expect(ctx.pty.spawn(owner, { type: 'slow', name: 'main' })).rejects.toMatchObject({ code: 'DUPLICATE_NAME' })
await disposeAgentScope(owner)
gate.resolve(session)
await expect(pending).rejects.toMatchObject({ code: 'OWNER_NOT_LIVE' })
expect(session.closed).toEqual(['PTY spawn rolled back'])
})
it('keeps independent reservations and handles provider failure before publication', async () => {
const ctx = await harness()
const firstGate = Promise.withResolvers<PtyBackendSession>()
const secondGate = Promise.withResolvers<PtyBackendSession>()
let count = 0
ctx.pty.registerBackend({
type: 'slow',
spawn: () => ++count === 1 ? firstGate.promise : secondGate.promise,
})
const owner = stubAgent(ctx, 'owner')
ctx.agents.register(owner)
const first = ctx.pty.spawn(owner, { type: 'slow', name: 'one' })
const second = ctx.pty.spawn(owner, { type: 'slow', name: 'two' })
firstGate.resolve(new StubSession())
await first
secondGate.resolve(new StubSession())
await second
ctx.pty.registerBackend({ type: 'throwing', spawn: () => Promise.reject(new Error('provider failed')) })
await expect(ctx.pty.spawn(owner, { type: 'throwing' })).rejects.toThrow('provider failed')
const controller = new AbortController()
const b = backend('signaled')
ctx.pty.registerBackend(b.provider)
await ctx.pty.spawn(owner, { type: 'signaled' }, controller.signal)
})
it('omits optional pid metadata when a backend has no process id', async () => {
const ctx = await harness()
const owner = stubAgent(ctx, 'owner')
ctx.agents.register(owner)
const session = new StubSession()
Object.defineProperty(session, 'pid', { value: undefined })
ctx.pty.registerBackend({ type: 'virtual', spawn: () => Promise.resolve(session) })
expect(await ctx.pty.spawn(owner, { type: 'virtual' })).not.toHaveProperty('pid')
})
it('reports rollback and close failures without publishing false success', async () => {
const ctx = await harness()
const owner = stubAgent(ctx, 'owner')
ctx.agents.register(owner)
const failedSpawn = new StubSession()
failedSpawn.rejectClose = true
ctx.pty.registerBackend({
type: 'bad-spawn',
async spawn() {
await disposeAgentScope(owner)
return failedSpawn
},
})
await expect(ctx.pty.spawn(owner, { type: 'bad-spawn' })).rejects.toThrow('spawn and rollback both failed')
const nextOwner = stubAgent(ctx, 'next')
ctx.agents.register(nextOwner)
const b = backend('bad-close')
ctx.pty.registerBackend(b.provider)
const created = await ctx.pty.spawn(nextOwner, { type: 'bad-close' })
b.sessions[0]!.rejectClose = true
await expect(ctx.pty.kill(nextOwner, created.sessionId)).rejects.toThrow('close failed')
expect(ctx.pty.list(nextOwner)).toHaveLength(1)
})
it('joins an already-running close and refuses new sends while closing', async () => {
const ctx = await harness()
const owner = stubAgent(ctx, 'owner')
ctx.agents.register(owner)
const b = backend()
ctx.pty.registerBackend(b.provider)
const created = await ctx.pty.spawn(owner, { type: 'stub' })
b.sessions[0]!.closeGate = Promise.withResolvers<undefined>()
const first = ctx.pty.kill(owner, created.sessionId)
expect(() => ctx.pty.startSend(owner, created.sessionId, { text: '', submit: false })).toThrow('closing')
const second = ctx.pty.kill(owner, created.sessionId)
b.sessions[0]!.closeGate?.resolve(undefined)
expect(await first).toBe(true)
expect(await second).toBe(false)
expect(() => ctx.pty.read(owner, created.sessionId)).toThrow('unknown PTY')
})
it('awaits owner cleanup and removes sessions while backend registration may reload', async () => {
const ctx = await harness()
const b = backend()
const disposeBackend = ctx.pty.registerBackend(b.provider)
const owner = stubAgent(ctx, 'owner')
ctx.agents.register(owner)
const created = await ctx.pty.spawn(owner, { type: 'stub' })
disposeBackend()
expect(ctx.pty.listBackends()).toEqual([])
expect(ctx.pty.read(owner, created.sessionId).text).toBe('0:0')
await disposeAgentScope(owner)
expect(b.sessions[0]?.closed).toEqual(['PTY owner disposed'])
expect(ctx.pty.list(owner)).toEqual([])
})
it('kills idempotently and service disposal closes all owners', async () => {
const ctx = await harness()
const b = backend()
ctx.pty.registerBackend(b.provider)
const first = stubAgent(ctx, 'first')
const second = stubAgent(ctx, 'second')
ctx.agents.register(first)
ctx.agents.register(second)
const a = await ctx.pty.spawn(first, { type: 'stub' })
await ctx.pty.spawn(second, { type: 'stub' })
expect(await ctx.pty.kill(first, a.sessionId)).toBe(true)
expect(b.sessions[0]?.closed).toEqual(['model request'])
const service = ctx.pty
await disposePtyService(ctx)
expect(b.sessions[1]?.closed).toEqual(['PTY service disposed'])
await expect(service.spawn(first, { type: 'stub' })).rejects.toMatchObject({ code: 'SERVICE_DISPOSING' })
})
it('aggregates service-disposal close failures after attempting every record', async () => {
const ctx = await harness()
const service = ctx.pty
const b = backend()
ctx.pty.registerBackend(b.provider)
const owner = stubAgent(ctx, 'owner')
ctx.agents.register(owner)
await ctx.pty.spawn(owner, { type: 'stub' })
b.sessions[0]!.rejectClose = true
const internal = service as unknown as {
sessions: Map<PtySessionIdType, unknown>
closeRecords(records: unknown[], reason: string): Promise<void>
}
await expect(internal.closeRecords([...internal.sessions.values()], 'test failure')).rejects.toThrow('failed to close 1 PTY session')
b.sessions[0]!.rejectClose = false
await disposePtyService(ctx)
await expect(service.spawn(owner, { type: 'stub' })).rejects.toMatchObject({ code: 'SERVICE_DISPOSING' })
})
})
+24
View File
@@ -0,0 +1,24 @@
{
"extends": "../../../tsconfig.base.json",
"compilerOptions": {
"rootDir": "src",
"outDir": "lib/types"
},
"include": [
"src"
],
"references": [
{
"path": "../../../vendor/cosmokit"
},
{
"path": "../../../vendor/cordis"
},
{
"path": "../../core/agent"
},
{
"path": "../../util/brand"
}
]
}
+60
View File
@@ -0,0 +1,60 @@
# @deepseek-ai/dsh-tool-pty
Six model-facing tools over `ctx.pty`: `pty_spawn`, `pty_send`, `pty_read`, `pty_signal`, `pty_kill`, and `pty_list`. Every operation requires the exact initiating `Agent`, so a model cannot address another agent's terminal even if it learns the id.
`pty_send(run_in_background: true)` reuses `ctx.tasks`; task preflight occurs before any terminal write, completion is collected with `task_output`, and `task_kill` requests `Ctrl-C`. Foreground sends use terminal ACP cards; lifecycle, history, signal, and list calls use generic cards.
## Model Experience
### System prompt
#### What the model sees
The plugin contributes this fixed guidance section:
##### PTY guidance
```markdown
Use PTY only when work needs persistent terminal state or interactive stdin; prefer bash/read/write/edit for bounded one-shot operations. Track every PTY session id and kill sessions that no longer matter. An inferred_idle or timeout result does not prove the foreground command exited.
```
#### Token effect
Small fixed input cost on every request while the plugin is active.
#### KV Cache effect
Prefix-stable while the registration scope and guidance text are unchanged.
### Tool schemas
#### What the model sees
The six generated schemas are listed in the [`dsh-tool-pty` catalog section](../../../docs/tool-catalog.md#deepseek-aidsh-tool-pty). Their fixed schema tokens are present whenever this plugin is active; agent-scoped tool filtering may hide them.
#### Token effect
Fixed schema cost on requests where the tools are visible.
#### KV Cache effect
Prefix-stable while tool visibility and definitions are unchanged.
### Tool results and task context
#### What the model sees
Spawn returns the id and bounded MOTD. Send/read return bounded terminal text plus readiness/history markers. Background mode returns a generic task id. Results remain in session history until compaction; incremental task reads do not repeat consumed output.
#### Token effect
Data-dependent and bounded by the backend; each returned result remains in history until compaction.
#### KV Cache effect
Append-only; new results follow the reusable request prefix.
## Known Limitations and Deferred Work
- No named key sequence, TUI, BEL, resize, auto-start, or cross-agent sharing schema is exposed.
- Background mode requires both `@deepseek-ai/dsh-tasks` and its model-facing control surface.
+49
View File
@@ -0,0 +1,49 @@
{
"name": "@deepseek-ai/dsh-tool-pty",
"description": "Six model-facing persistent PTY tools with owner isolation and generic background-task integration",
"version": "0.0.1",
"private": true,
"type": "module",
"main": "lib/index.js",
"types": "lib/types/index.d.ts",
"exports": {
".": {
"types": "./lib/types/index.d.ts",
"default": "./lib/index.js"
},
"./src/*": "./src/*",
"./package.json": "./package.json"
},
"files": [
"lib/index.js",
"lib/types/**/*.d.ts",
"lib/types/**/*.d.ts.map",
"src"
],
"license": "BSD-3-Clause",
"peerDependencies": {
"@deepseek-ai/dsh-agent": "^0.0.1",
"@deepseek-ai/dsh-llm": "^0.0.1",
"@deepseek-ai/dsh-pty": "^0.0.1",
"@deepseek-ai/dsh-system-prompt": "^0.0.1",
"@deepseek-ai/dsh-tasks": "^0.0.1",
"@deepseek-ai/dsh-tools": "^0.0.1",
"cordis": "^4.0.0-rc.7"
},
"devDependencies": {
"@cordisjs/plugin-include": "workspace:^",
"@cordisjs/plugin-loader": "workspace:^",
"@deepseek-ai/dsh-agent": "workspace:^",
"@deepseek-ai/dsh-llm": "workspace:^",
"@deepseek-ai/dsh-pty": "workspace:^",
"@deepseek-ai/dsh-pty-local": "workspace:^",
"@deepseek-ai/dsh-sandbox": "workspace:^",
"@deepseek-ai/dsh-sandbox-policy": "workspace:^",
"@deepseek-ai/dsh-session": "workspace:^",
"@deepseek-ai/dsh-system-prompt": "workspace:^",
"@deepseek-ai/dsh-tasks": "workspace:^",
"@deepseek-ai/dsh-tool-tasks": "workspace:^",
"@deepseek-ai/dsh-tools": "workspace:^",
"cordis": "^4.0.0-rc.7"
}
}
+225
View File
@@ -0,0 +1,225 @@
/**
* Six model-facing persistent PTY tools. Owner identity comes from the exact
* tool execution Agent; generic `ctx.tasks` owns background ids and collection.
* @module @deepseek-ai/dsh-tool-pty
*/
import { Context } from 'cordis'
import type { Agent } from '@deepseek-ai/dsh-agent'
import type { ContentBlock } from '@deepseek-ai/dsh-llm'
import { PtySessionId } from '@deepseek-ai/dsh-pty'
import type { PtySendResult, PtySessionId as PtySessionIdType, PtySignal } from '@deepseek-ai/dsh-pty'
import type {} from '@deepseek-ai/dsh-tasks'
import { defineTool } from '@deepseek-ai/dsh-tools'
import type { ToolExecutionResult, ToolResult } from '@deepseek-ai/dsh-tools'
import { renderList, renderRead, renderSend, renderSendRead, renderSpawn } from './render.ts'
declare module '@deepseek-ai/dsh-tasks' {
interface TaskKindMap {
'pty-send': 'pty-send'
}
}
/** Cordis plugin name. */
export const name = 'tool-pty'
/** Required capability, registry, and prompt services. */
export const inject = ['pty', 'tools', 'systemPrompt']
interface SpawnArgs {
type: string
name?: string
cwd?: string
}
interface SessionArgs {
sessionId: string
}
interface SendArgs extends SessionArgs {
text: string
submit?: boolean
run_in_background?: boolean
}
interface ReadArgs extends SessionArgs {
offset?: number
count?: number
}
interface SignalArgs extends SessionArgs {
signal: PtySignal
}
function requireAgent(agent: Agent | undefined): Agent {
if (agent === undefined) throw new Error('PTY tools require an initiating agent')
return agent
}
function sessionId(args: SessionArgs): PtySessionIdType {
if (args.sessionId.length === 0) {
throw new Error('sessionId must be a non-empty string')
}
return PtySessionId(args.sessionId)
}
function textResult(text: string): ContentBlock[] {
return [{ type: 'text', text }]
}
function rawResultText(result: ToolResult): string | undefined {
if (result.content.length !== 1) return undefined
const block = result.content[0]
return block?.type === 'text' ? block.text : undefined
}
function sendDetail(result: PtySendResult): string {
return result.sessionStatus.kind === 'running'
? `wait: ${result.waitReason}`
: `session exited: ${result.sessionStatus.exitCode ?? result.sessionStatus.signal ?? 'unknown'}`
}
/** Register all PTY tools and the minimal usage guidance. */
export function apply(ctx: Context): void {
ctx.systemPrompt.section({
name: 'tool:pty',
order: 106,
text: 'Use PTY only when work needs persistent terminal state or interactive stdin; prefer bash/read/write/edit for bounded one-shot operations. Track every PTY session id and kill sessions that no longer matter. An inferred_idle or timeout result does not prove the foreground command exited.',
})
ctx.tools.register(defineTool({
name: 'pty_spawn',
description: 'Create a persistent, owner-isolated PTY 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 PTY backend type, usually "shell".' },
name: { type: 'string', description: 'Optional owner-local display name such as "main" or "gdb".' },
cwd: { type: 'string', description: 'Initial working directory. Defaults to the deployment workspace root.' },
},
async execute(args: SpawnArgs, exec) {
if (args.type.length === 0) throw new Error('type must be a non-empty string')
const result = await ctx.pty.spawn(requireAgent(exec.agent), {
type: args.type,
...args.name !== undefined ? { name: args.name } : {},
...args.cwd !== undefined ? { cwd: args.cwd } : {},
}, exec.signal)
return textResult(renderSpawn(result))
},
presentCall: (args) => {
const parsed = args
return { card: 'generic', title: `Start PTY ${parsed.name ?? parsed.type}`, kind: 'execute' }
},
}))
ctx.tools.register(defineTool({
name: 'pty_send',
description: 'Send text to a persistent PTY. By default Enter is submitted and the call waits for a prompt, stdin wait, output silence, timeout, or session exit. Background mode returns a task id for task_output/task_kill.',
parameters: {
sessionId: { type: 'string', required: true, description: 'PTY session id returned by pty_spawn or pty_list.' },
text: { type: 'string', required: true, description: 'UTF-8 text to write to the terminal.' },
submit: { type: 'boolean', description: 'Submit Enter after text (default true). Set false for control characters or incomplete REPL input.' },
run_in_background: { type: 'boolean', description: 'Return a task id immediately; collect with task_output or stop with task_kill.' },
},
async execute(args: SendArgs, exec): Promise<ToolExecutionResult> {
const owner = requireAgent(exec.agent)
const id = sessionId(args)
const request = { text: args.text, submit: args.submit ?? true }
if (args.run_in_background === true) {
const tasks = ctx.get('tasks')
if (tasks === undefined) throw new Error('background PTY sends require @deepseek-ai/dsh-tasks and @deepseek-ai/dsh-tool-tasks')
if (exec.signal?.aborted === true) throw new Error('PTY send aborted')
let cancelRequested = false
const taskId = tasks.start({
kind: 'pty-send',
label: `${id}: ${args.text || '(input)'}`,
owner,
run: () => {
const operation = ctx.pty.startSend(owner, id, request)
return {
cancel: () => {
cancelRequested = true
operation.cancel()
},
done: operation.done.then(
result => ({ status: cancelRequested ? 'killed' as const : 'completed' as const, detail: sendDetail(result) }),
(error: unknown) => ({ status: 'failed' as const, detail: String(error) }),
),
readOutput: () => renderSendRead(operation.readOutput()),
}
},
})
return { content: textResult(`started background task ${taskId}`), isError: false }
}
const operation = ctx.pty.startSend(owner, id, { ...request, ...exec.signal ? { signal: exec.signal } : {} })
const result = await operation.done
if (exec.signal?.aborted === true) throw new Error('PTY send aborted')
return { content: textResult(renderSend(result)), isError: false, meta: result }
},
presentCall(args) {
const parsed = args as Partial<SendArgs>
if (parsed.run_in_background === true) {
return { card: 'generic', title: `Send PTY ${parsed.sessionId as string} in background`, kind: 'execute', rawInput: parsed.text }
}
return { card: 'terminal', title: parsed.text || '(send input)', description: `PTY ${parsed.sessionId as string}` }
},
presentResult(args, result) {
if ((args as Partial<SendArgs>).run_in_background === true || result.isError) return undefined
const raw = rawResultText(result)
return raw === undefined ? undefined : { card: 'terminal', output: raw }
},
}))
ctx.tools.register(defineTool({
name: 'pty_read',
description: 'Read a bounded page of retained output from a persistent PTY without sending input.',
parameters: {
sessionId: { type: 'string', required: true, description: 'PTY session id.' },
offset: { type: 'number', description: 'Newest-relative line offset (default 0).' },
count: { type: 'number', description: 'Requested line count (default 500; backend caps apply).' },
},
execute(args: ReadArgs, exec) {
const result = ctx.pty.read(requireAgent(exec.agent), sessionId(args), {
...args.offset !== undefined ? { offset: args.offset } : {},
...args.count !== undefined ? { count: args.count } : {},
})
return Promise.resolve(textResult(renderRead(result)))
},
presentCall: args => ({ card: 'generic', title: `Read PTY ${(args).sessionId}`, kind: 'read', rawInput: args }),
}))
ctx.tools.register(defineTool({
name: 'pty_signal',
description: 'Send an allowed signal to the current foreground process group of a persistent PTY.',
parameters: {
sessionId: { type: 'string', required: true, description: 'PTY session id.' },
signal: { type: 'string', required: true, enum: ['SIGINT', 'SIGTERM', 'SIGKILL', 'SIGTSTP', 'SIGHUP'], description: 'Signal to deliver. Shell-targeted SIGKILL is rejected; use pty_kill.' },
},
async execute(args: SignalArgs, exec) {
const result = await ctx.pty.signal(requireAgent(exec.agent), sessionId(args), args.signal)
return textResult(`delivered ${args.signal} to foreground process group ${result.targetPgid}`)
},
presentCall: args => ({ card: 'generic', title: `Signal PTY ${(args as SignalArgs).sessionId}`, kind: 'execute', rawInput: args }),
}))
ctx.tools.register(defineTool({
name: 'pty_kill',
description: 'Close one persistent PTY and wait until its captured owned process tree is gone.',
parameters: {
sessionId: { type: 'string', required: true, description: 'PTY session id.' },
},
async execute(args: SessionArgs, exec) {
const id = sessionId(args)
const killed = await ctx.pty.kill(requireAgent(exec.agent), id)
return textResult(killed ? `killed PTY session ${id}` : `PTY session ${id} was already closing`)
},
presentCall: args => ({ card: 'generic', title: `Kill PTY ${(args).sessionId}`, kind: 'delete' }),
}))
ctx.tools.register(defineTool({
name: 'pty_list',
description: 'List persistent PTY sessions owned by the current agent.',
parameters: {},
execute(_args: Record<string, never>, exec) {
return Promise.resolve(textResult(renderList(ctx.pty.list(requireAgent(exec.agent)))))
},
presentCall: () => ({ card: 'generic', title: 'List PTY sessions', kind: 'read' }),
}))
}
+62
View File
@@ -0,0 +1,62 @@
/** Model and ACP rendering for persistent PTY tool results. */
import type { PtyReadResult, PtySendRead, PtySendResult, PtySessionSnapshot, PtySpawnResult } from '@deepseek-ai/dsh-pty'
/**
* Render one created session and its bounded MOTD.
* @param result - published spawn result.
* @returns Model-facing session acknowledgement.
*/
export function renderSpawn(result: PtySpawnResult): string {
const label = result.name === undefined ? result.sessionId : `${result.sessionId} (${result.name})`
return `started PTY session ${label} [type: ${result.type}]\n${result.motd || '(no startup output)'}`
}
/**
* Render one settled interactive send.
* @param result - settled send outcome.
* @returns Terminal output plus wait/session markers.
*/
export function renderSend(result: PtySendResult): string {
const output = result.viewport || '(no new output)'
const status = result.sessionStatus.kind === 'running'
? 'running'
: `exited code=${result.sessionStatus.exitCode ?? 'null'} signal=${result.sessionStatus.signal ?? 'null'}`
return `${output}\n[wait: ${result.waitReason}]\n[session: ${status}]${result.truncated ? '\n[output truncated]' : ''}`
}
/**
* Render one incremental background operation read.
* @param read - consuming operation delta.
* @returns Delta plus truncation marker when needed.
*/
export function renderSendRead(read: PtySendRead): string {
return `${read.delta}${read.truncated ? `${read.delta.endsWith('\n') || read.delta.length === 0 ? '' : '\n'}[output truncated]` : ''}`
}
/**
* Render one bounded historical page.
* @param result - retained scrollback page.
* @returns Page text plus pagination and truncation markers.
*/
export function renderRead(result: PtyReadResult): string {
const output = result.text || '(no retained output)'
return `${output}\n[lines: ${result.lineBegin}-${result.lineEnd} of ${result.totalLines}]${result.truncated ? '\n[output truncated]' : ''}`
}
/**
* Render owner-visible live sessions.
* @param sessions - fresh owner-scoped snapshots.
* @returns One line per session or the empty marker.
*/
export function renderList(sessions: PtySessionSnapshot[]): string {
if (sessions.length === 0) return '(no PTY sessions)'
return sessions.map((session) => {
const name = session.name === undefined ? '' : ` (${session.name})`
const pid = session.pid === undefined ? '' : ` pid=${session.pid}`
const status = session.status.kind === 'running'
? 'running'
: `exited code=${session.status.exitCode ?? 'null'} signal=${session.status.signal ?? 'null'}`
return `${session.sessionId}${name} [${session.type}] ${status}${pid}`
}).join('\n')
}
@@ -0,0 +1,119 @@
import { mkdtemp, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { pathToFileURL } from 'node:url'
import { afterEach, describe, expect, it } from 'vitest'
import { Context } from 'cordis'
import Loader from '@cordisjs/plugin-loader'
import Include from '@cordisjs/plugin-include'
import { CallId } from '@deepseek-ai/dsh-llm'
import { Session, SessionId } from '@deepseek-ai/dsh-session'
import AgentRegistry from '@deepseek-ai/dsh-agent'
import type { Agent } from '@deepseek-ai/dsh-agent'
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
import ToolRegistry from '@deepseek-ai/dsh-tools'
import PtyService from '@deepseek-ai/dsh-pty'
import SandboxProvider from '@deepseek-ai/dsh-sandbox'
import type { ConfinedArgv, SandboxPolicy } from '@deepseek-ai/dsh-sandbox'
import SandboxPolicyService from '@deepseek-ai/dsh-sandbox-policy'
import * as PtyLocal from '@deepseek-ai/dsh-pty-local'
import * as ToolPty from '@deepseek-ai/dsh-tool-pty'
let root: string | undefined
let context: Context | undefined
afterEach(async () => {
await context?.fiber.dispose()
context = undefined
if (root !== undefined) await rm(root, { recursive: true, force: true })
root = undefined
})
class PassthroughSandbox extends SandboxProvider {
confine(argv: readonly string[], _policy: SandboxPolicy): ConfinedArgv {
return { argv: [...argv], enforcement: 'full', denialSignatures: [], runnerFailureSignatures: [] }
}
}
function agent(ctx: Context): Agent {
const scope = ctx.plugin(() => {})
const id = SessionId('pty-loader-agent')
const value: Agent = {
id, options: {}, session: new Session(id), status: 'idle', ctx: scope.ctx,
send() {}, steer() {}, inject() {}, cancel() {}, whenIdle: () => Promise.resolve(),
}
ctx.agents.register(value)
return value
}
function resultText(result: { content: { type: string; text?: string }[] }): string {
return result.content.filter(block => block.type === 'text').map(block => block.text).join('')
}
const suite = process.platform === 'linux' || process.platform === 'darwin' ? describe : describe.skip
suite('PTY real Loader composition through cordis.yml', () => {
it('boots cordis.yml and preserves shell state across real tool calls', async () => {
root = await mkdtemp(join(tmpdir(), 'dsh-pty-loader-'))
const configPath = join(root, 'cordis.yml')
await writeFile(configPath, [
"- name: '@deepseek-ai/dsh-agent'",
"- name: '@deepseek-ai/dsh-system-prompt'",
"- name: '@deepseek-ai/dsh-tools'",
"- name: '@deepseek-ai/dsh-pty'",
"- name: '@deepseek-ai/dsh-test-sandbox'",
"- name: '@deepseek-ai/dsh-sandbox-policy'",
' config:',
' mode: danger-full-access',
` workspaceRoot: ${JSON.stringify(root)}`,
"- name: '@deepseek-ai/dsh-pty-local'",
' config:',
' pollIntervalMs: 10',
' exactProbeAfterMs: 20',
' idleSilenceMs: 250',
' timeoutMs: 2000',
' disposeGraceMs: 500',
"- name: '@deepseek-ai/dsh-tool-pty'",
'',
].join('\n'))
context = new Context()
context.baseUrl = pathToFileURL(root).href + '/'
await context.plugin(Loader)
context.loader.builtins.include = Include
const modules = new Map<string, unknown>([
['@deepseek-ai/dsh-agent', AgentRegistry],
['@deepseek-ai/dsh-system-prompt', SystemPrompt],
['@deepseek-ai/dsh-tools', ToolRegistry],
['@deepseek-ai/dsh-pty', PtyService],
['@deepseek-ai/dsh-test-sandbox', PassthroughSandbox],
['@deepseek-ai/dsh-sandbox-policy', SandboxPolicyService],
['@deepseek-ai/dsh-pty-local', PtyLocal],
['@deepseek-ai/dsh-tool-pty', ToolPty],
])
context.loader.internal = {
version: 'v2',
async import(specifier: string) {
if (!modules.has(specifier)) throw new Error(`unexpected Loader import: ${specifier}`)
return modules.get(specifier)
},
} as unknown as NonNullable<typeof context.loader.internal>
await context.loader.create({ name: 'cordis:include', config: { path: pathToFileURL(configPath).href } })
await context.loader.await()
const owner = agent(context)
const spawn = await context.tools.execute({
callId: CallId('spawn'), name: 'pty_spawn', arguments: { type: 'shell', name: 'main', cwd: root }, agent: owner,
})
expect(resultText(spawn)).toContain('started PTY session pty-1 (main)')
await context.tools.execute({
callId: CallId('state'), name: 'pty_send', arguments: { sessionId: 'pty-1', text: 'export KEEP=loader; cd /' }, agent: owner,
})
const read = await context.tools.execute({
callId: CallId('read'), name: 'pty_send', arguments: { sessionId: 'pty-1', text: 'printf "cwd=%s keep=%s\\n" "$PWD" "$KEEP"' }, agent: owner,
})
expect(resultText(read)).toContain('cwd=/ keep=loader')
expect(context.pty.list(owner)).toHaveLength(1)
}, 15_000)
})
@@ -0,0 +1,39 @@
import { describe, expect, it } from 'vitest'
import { PtySessionId } from '@deepseek-ai/dsh-pty'
import { renderList, renderRead, renderSend, renderSendRead, renderSpawn } from '@deepseek-ai/dsh-tool-pty/src/render.ts'
describe('tool-pty rendering', () => {
it('renders spawn with and without names or MOTD', () => {
expect(renderSpawn({ sessionId: PtySessionId('pty-1'), type: 'shell', status: { kind: 'running' }, motd: '' }))
.toBe('started PTY session pty-1 [type: shell]\n(no startup output)')
expect(renderSpawn({ sessionId: PtySessionId('pty-2'), name: 'main', type: 'shell', pid: 2, status: { kind: 'running' }, motd: 'ready' }))
.toContain('pty-2 (main)')
})
it('renders running, exited, empty, and truncated sends', () => {
expect(renderSend({ viewport: '', waitReason: 'timeout', sessionStatus: { kind: 'running' }, truncated: true }))
.toBe('(no new output)\n[wait: timeout]\n[session: running]\n[output truncated]')
expect(renderSend({ viewport: 'bye', waitReason: 'session_exit', sessionStatus: { kind: 'exited', exitCode: null, signal: 'SIGTERM' }, truncated: false }))
.toContain('exited code=null signal=SIGTERM')
expect(renderSend({ viewport: 'bye', waitReason: 'session_exit', sessionStatus: { kind: 'exited', exitCode: 2, signal: null }, truncated: false }))
.toContain('exited code=2 signal=null')
expect(renderSend({ viewport: 'bye', waitReason: 'session_exit', sessionStatus: { kind: 'exited', exitCode: null, signal: null }, truncated: false }))
.toContain('exited code=null signal=null')
expect(renderSendRead({ delta: '', truncated: true })).toBe('[output truncated]')
expect(renderSendRead({ delta: 'x', truncated: true })).toBe('x\n[output truncated]')
expect(renderSendRead({ delta: 'x\n', truncated: true })).toBe('x\n[output truncated]')
expect(renderSendRead({ delta: 'x', truncated: false })).toBe('x')
})
it('renders history and every list status shape', () => {
expect(renderRead({ text: '', totalLines: 0, lineBegin: 0, lineEnd: 0, truncated: true }))
.toBe('(no retained output)\n[lines: 0-0 of 0]\n[output truncated]')
expect(renderList([])).toBe('(no PTY sessions)')
expect(renderList([
{ sessionId: PtySessionId('pty-1'), type: 'shell', status: { kind: 'running' } },
{ sessionId: PtySessionId('pty-2'), name: 'done', type: 'shell', pid: 9, status: { kind: 'exited', exitCode: 2, signal: null } },
{ sessionId: PtySessionId('pty-3'), type: 'shell', status: { kind: 'exited', exitCode: null, signal: 'SIGTERM' } },
{ sessionId: PtySessionId('pty-4'), type: 'shell', status: { kind: 'exited', exitCode: null, signal: null } },
])).toBe('pty-1 [shell] running\npty-2 (done) [shell] exited code=2 signal=null pid=9\npty-3 [shell] exited code=null signal=SIGTERM\npty-4 [shell] exited code=null signal=null')
})
})
+245
View File
@@ -0,0 +1,245 @@
import { describe, expect, it } from 'vitest'
import { Context } from 'cordis'
import { CallId } from '@deepseek-ai/dsh-llm'
import { Session, SessionId } from '@deepseek-ai/dsh-session'
import AgentRegistry from '@deepseek-ai/dsh-agent'
import type { Agent } from '@deepseek-ai/dsh-agent'
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
import ToolRegistry from '@deepseek-ai/dsh-tools'
import PtyService, { PtySessionId } from '@deepseek-ai/dsh-pty'
import type { PtyBackend, PtyBackendSession, PtySendOperation, PtySendRequest, PtySessionStatus, PtySignal } from '@deepseek-ai/dsh-pty'
import TaskService from '@deepseek-ai/dsh-tasks'
import * as ToolTasks from '@deepseek-ai/dsh-tool-tasks'
import * as ToolPty from '@deepseek-ai/dsh-tool-pty'
function fakeAgent(ctx: Context, rawId: string): Agent {
const scope = ctx.plugin(() => {})
const id = SessionId(rawId)
const agent: Agent = {
id, options: {}, session: new Session(id), status: 'idle', ctx: scope.ctx,
send() {}, steer() {}, inject() {}, cancel() {}, whenIdle: () => Promise.resolve(),
}
ctx.agents.register(agent)
return agent
}
class StubSession implements PtyBackendSession {
readonly motd = 'stub prompt'
readonly pid = 42
statusValue: PtySessionStatus = { kind: 'running' }
operation: PtySendOperation | undefined
autoSettle = true
rejectOperation = false
closeGate: PromiseWithResolvers<undefined> | undefined
startSend(_request: PtySendRequest): PtySendOperation {
let settle!: () => void
let reject!: (error: unknown) => void
let cancelled = false
const done = new Promise<void>((resolve, rejectPromise) => { settle = resolve; reject = rejectPromise }).then(() => ({
viewport: cancelled ? '^C' : 'command output',
waitReason: 'stdin_read' as const,
sessionStatus: this.statusValue,
truncated: false,
}))
const operation: PtySendOperation = {
done,
readOutput: () => ({ delta: 'live output', truncated: false }),
cancel: () => {
if (cancelled) return false
cancelled = true
settle()
return true
},
}
this.operation = operation
if (this.rejectOperation) queueMicrotask(() => { reject(new Error('operation failed')) })
else if (this.autoSettle) queueMicrotask(settle)
return operation
}
read() {
return { text: 'history', totalLines: 1, lineBegin: 0, lineEnd: 1, truncated: false }
}
async signal(signal: PtySignal) {
return { delivered: true as const, targetPgid: signal === 'SIGINT' ? 10 : 11 }
}
status() { return this.statusValue }
async close() {
if (this.closeGate !== undefined) await this.closeGate.promise
this.statusValue = { kind: 'exited', exitCode: 0, signal: null }
}
}
function stubBackend() {
const sessions: StubSession[] = []
const backend: PtyBackend = {
type: 'stub',
async spawn() {
const session = new StubSession()
sessions.push(session)
return session
},
}
return { backend, sessions }
}
async function setup(tasks: boolean) {
const ctx = new Context()
await ctx.plugin(SystemPrompt)
await ctx.plugin(ToolRegistry)
await ctx.plugin(AgentRegistry)
await ctx.plugin(PtyService)
const stub = stubBackend()
ctx.pty.registerBackend(stub.backend)
if (tasks) {
await ctx.plugin(TaskService)
await ctx.plugin(ToolTasks)
}
await ctx.plugin(ToolPty)
return { ctx, stub, agent: fakeAgent(ctx, tasks ? 'with-tasks' : 'foreground') }
}
let callNumber = 0
function call(ctx: Context, name: string, args: unknown, agent?: Agent) {
return ctx.tools.execute({ callId: CallId(`pty-call-${++callNumber}`), name, arguments: args, ...agent ? { agent } : {} })
}
function callWithSignal(ctx: Context, name: string, args: unknown, agent: Agent, signal: AbortSignal) {
return ctx.tools.execute({ callId: CallId(`pty-call-${++callNumber}`), name, arguments: args, agent, signal })
}
function text(result: { content: { type: string; text?: string }[] }): string {
return result.content.filter(block => block.type === 'text').map(block => block.text).join('')
}
describe('tool-pty foreground surface', () => {
it('registers exactly six schemas and drives the full owner-scoped lifecycle', async () => {
const { ctx, agent } = await setup(false)
expect(['pty_spawn', 'pty_send', 'pty_read', 'pty_signal', 'pty_kill', 'pty_list'].every(name => ctx.tools.get(name) !== undefined)).toBe(true)
const spawned = await call(ctx, 'pty_spawn', { type: 'stub', name: 'main' }, agent)
expect(text(spawned)).toContain('started PTY session pty-1 (main)')
expect(text(await call(ctx, 'pty_list', {}, agent))).toContain('pty-1 (main) [stub] running pid=42')
expect(text(await call(ctx, 'pty_read', { sessionId: 'pty-1' }, agent))).toContain('history\n[lines: 0-1 of 1]')
expect(text(await call(ctx, 'pty_signal', { sessionId: 'pty-1', signal: 'SIGINT' }, agent))).toBe('delivered SIGINT to foreground process group 10')
const sent = await call(ctx, 'pty_send', { sessionId: 'pty-1', text: 'echo hi' }, agent)
expect(text(sent)).toContain('command output\n[wait: stdin_read]\n[session: running]')
expect(text(await call(ctx, 'pty_kill', { sessionId: 'pty-1' }, agent))).toBe('killed PTY session pty-1')
expect(text(await call(ctx, 'pty_list', {}, agent))).toBe('(no PTY sessions)')
})
it('fails without an initiating agent and rejects background before writing', async () => {
const { ctx, agent, stub } = await setup(false)
expect((await call(ctx, 'pty_spawn', { type: 'stub' })).isError).toBe(true)
await call(ctx, 'pty_spawn', { type: 'stub' }, agent)
const result = await call(ctx, 'pty_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, 'pty_spawn', { type: '' }, agent)).isError).toBe(true)
expect((await call(ctx, 'pty_send', { sessionId: '', text: 'x' }, agent)).isError).toBe(true)
expect((await call(ctx, 'pty_send', { sessionId: 1, text: 'x' }, agent)).isError).toBe(true)
expect((await call(ctx, 'pty_send', { sessionId: 'pty-1', text: 1 }, agent)).isError).toBe(true)
await call(ctx, 'pty_spawn', { type: 'stub', name: 'named', cwd: '/tmp' }, agent)
expect(text(await call(ctx, 'pty_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('pty_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('pty_spawn')?.presentCall?.({ type: 'stub' })).toMatchObject({ card: 'generic', title: 'Start PTY stub' })
expect(ctx.tools.get('pty_spawn')?.presentCall?.({ type: 'stub', name: 'main' })).toMatchObject({ card: 'generic', title: 'Start PTY main' })
expect(ctx.tools.get('pty_read')?.presentCall?.({ sessionId: 'pty-1' })).toMatchObject({ card: 'generic', title: 'Read PTY pty-1' })
expect(ctx.tools.get('pty_signal')?.presentCall?.({ sessionId: 'pty-1', signal: 'SIGINT' })).toMatchObject({ card: 'generic', title: 'Signal PTY pty-1' })
expect(ctx.tools.get('pty_kill')?.presentCall?.({ sessionId: 'pty-1' })).toMatchObject({ card: 'generic', title: 'Kill PTY pty-1' })
expect(ctx.tools.get('pty_list')?.presentCall?.({})).toMatchObject({ card: 'generic', title: 'List PTY sessions' })
})
})
describe('tool-pty task integration', () => {
it('registers a generic task and exposes incremental output', async () => {
const { ctx, agent } = await setup(true)
await call(ctx, 'pty_spawn', { type: 'stub' }, agent)
expect(text(await call(ctx, 'pty_send', { sessionId: 'pty-1', text: 'build', run_in_background: true }, agent))).toBe('started background task pty-send-1')
const output = await call(ctx, 'task_output', { task_id: 'pty-send-1', wait: true }, agent)
expect(text(output)).toContain('live output')
expect(text(output)).toContain('[status: completed, wait: stdin_read]')
})
it('rejects pre-aborted background calls, maps task cancellation, and contains operation failure', async () => {
const { ctx, agent, stub } = await setup(true)
await call(ctx, 'pty_spawn', { type: 'stub' }, agent)
const controller = new AbortController()
controller.abort()
expect((await callWithSignal(ctx, 'pty_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, 'pty_send', { sessionId: 'pty-1', text: '', run_in_background: true }, agent))).toContain('pty-send-1')
expect(text(await call(ctx, 'task_kill', { task_id: 'pty-send-1' }, agent))).toContain('requested cancellation')
await new Promise(resolve => setTimeout(resolve, 0))
expect(text(await call(ctx, 'task_output', { task_id: 'pty-send-1' }, agent))).toContain('[status: killed')
stub.sessions[0]!.rejectOperation = true
stub.sessions[0]!.autoSettle = false
expect(text(await call(ctx, 'pty_send', { sessionId: 'pty-1', text: 'bad', run_in_background: true }, agent))).toContain('pty-send-2')
await new Promise(resolve => setTimeout(resolve, 0))
expect(text(await call(ctx, 'task_output', { task_id: 'pty-send-2' }, agent))).toContain('[status: failed')
})
it('reports foreground cancellation after the PTY operation settles', async () => {
const { ctx, agent, stub } = await setup(false)
await call(ctx, 'pty_spawn', { type: 'stub' }, agent)
stub.sessions[0]!.autoSettle = false
const controller = new AbortController()
const pending = callWithSignal(ctx, 'pty_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, 'pty_spawn', { type: 'stub' }, agent)
stub.sessions[0]!.closeGate = Promise.withResolvers<undefined>()
const first = ctx.pty.kill(agent, PtySessionId('pty-1'))
const second = call(ctx, 'pty_kill', { sessionId: 'pty-1' }, agent)
stub.sessions[0]!.closeGate?.resolve(undefined)
await first
expect(text(await second)).toBe('PTY session pty-1 was already closing')
})
it('renders an exited session detail for background completion', async () => {
const { ctx, agent, stub } = await setup(true)
await call(ctx, 'pty_spawn', { type: 'stub' }, agent)
stub.sessions[0]!.statusValue = { kind: 'exited', exitCode: null, signal: null }
await call(ctx, 'pty_send', { sessionId: 'pty-1', text: 'exit', run_in_background: true }, agent)
const output = await call(ctx, 'task_output', { task_id: 'pty-send-1', wait: true }, agent)
expect(text(output)).toContain('session exited: unknown')
})
})
describe('tool-pty plugin shape', () => {
it('is a named function plugin with no default export', () => {
expect('default' in ToolPty).toBe(false)
expect(ToolPty.name).toBe('tool-pty')
expect(ToolPty.inject).toEqual(['pty', 'tools', 'systemPrompt'])
})
})
+36
View File
@@ -0,0 +1,36 @@
{
"extends": "../../../tsconfig.base.json",
"compilerOptions": {
"rootDir": "src",
"outDir": "lib/types"
},
"include": [
"src"
],
"references": [
{
"path": "../../../vendor/cosmokit"
},
{
"path": "../../../vendor/cordis"
},
{
"path": "../pty"
},
{
"path": "../../core/agent"
},
{
"path": "../../llm/llm"
},
{
"path": "../../core/system-prompt"
},
{
"path": "../../core/tools"
},
{
"path": "../../tasks/tasks"
}
]
}
+109
View File
@@ -149,6 +149,12 @@ importers:
'@deepseek-ai/dsh-permission': '@deepseek-ai/dsh-permission':
specifier: workspace:* specifier: workspace:*
version: link:../packages/ui/permission version: link:../packages/ui/permission
'@deepseek-ai/dsh-pty':
specifier: workspace:*
version: link:../packages/pty/pty
'@deepseek-ai/dsh-pty-local':
specifier: workspace:*
version: link:../packages/pty/pty-local
'@deepseek-ai/dsh-repeat-tool-guard': '@deepseek-ai/dsh-repeat-tool-guard':
specifier: workspace:* specifier: workspace:*
version: link:../packages/guard/repeat-tool-guard version: link:../packages/guard/repeat-tool-guard
@@ -197,6 +203,9 @@ importers:
'@deepseek-ai/dsh-tool-fs-search': '@deepseek-ai/dsh-tool-fs-search':
specifier: workspace:* specifier: workspace:*
version: link:../packages/fs/tool-fs-search version: link:../packages/fs/tool-fs-search
'@deepseek-ai/dsh-tool-pty':
specifier: workspace:*
version: link:../packages/pty/tool-pty
'@deepseek-ai/dsh-tool-subagent': '@deepseek-ai/dsh-tool-subagent':
specifier: workspace:* specifier: workspace:*
version: link:../packages/subagent/tool-subagent version: link:../packages/subagent/tool-subagent
@@ -1234,6 +1243,94 @@ importers:
specifier: ^4.4.3 specifier: ^4.4.3
version: 4.4.3 version: 4.4.3
packages/pty/pty:
devDependencies:
'@deepseek-ai/dsh-agent':
specifier: workspace:^
version: link:../../core/agent
'@deepseek-ai/dsh-brand':
specifier: workspace:^
version: link:../../util/brand
'@deepseek-ai/dsh-session':
specifier: workspace:^
version: link:../../core/session
cordis:
specifier: ^4.0.0-rc.7
version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5)
packages/pty/pty-local:
dependencies:
node-pty:
specifier: ^1.1.0
version: 1.1.0
schemastery:
specifier: ^3.18.0
version: 3.18.0
devDependencies:
'@deepseek-ai/dsh-agent':
specifier: workspace:^
version: link:../../core/agent
'@deepseek-ai/dsh-pty':
specifier: workspace:^
version: link:../pty
'@deepseek-ai/dsh-sandbox':
specifier: workspace:^
version: link:../../sandbox/sandbox
'@deepseek-ai/dsh-sandbox-policy':
specifier: workspace:^
version: link:../../sandbox/sandbox-policy
'@deepseek-ai/dsh-session':
specifier: workspace:^
version: link:../../core/session
cordis:
specifier: ^4.0.0-rc.7
version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5)
packages/pty/tool-pty:
devDependencies:
'@cordisjs/plugin-include':
specifier: workspace:^
version: link:../../../vendor/include
'@cordisjs/plugin-loader':
specifier: workspace:^
version: link:../../../vendor/loader
'@deepseek-ai/dsh-agent':
specifier: workspace:^
version: link:../../core/agent
'@deepseek-ai/dsh-llm':
specifier: workspace:^
version: link:../../llm/llm
'@deepseek-ai/dsh-pty':
specifier: workspace:^
version: link:../pty
'@deepseek-ai/dsh-pty-local':
specifier: workspace:^
version: link:../pty-local
'@deepseek-ai/dsh-sandbox':
specifier: workspace:^
version: link:../../sandbox/sandbox
'@deepseek-ai/dsh-sandbox-policy':
specifier: workspace:^
version: link:../../sandbox/sandbox-policy
'@deepseek-ai/dsh-session':
specifier: workspace:^
version: link:../../core/session
'@deepseek-ai/dsh-system-prompt':
specifier: workspace:^
version: link:../../core/system-prompt
'@deepseek-ai/dsh-tasks':
specifier: workspace:^
version: link:../../tasks/tasks
'@deepseek-ai/dsh-tool-tasks':
specifier: workspace:^
version: link:../../tasks/tool-tasks
'@deepseek-ai/dsh-tools':
specifier: workspace:^
version: link:../../core/tools
cordis:
specifier: ^4.0.0-rc.7
version: 4.0.0-rc.7(@cordisjs/plugin-include@vendor+include)(@cordisjs/plugin-loader@vendor+loader)
packages/sandbox/sandbox: packages/sandbox/sandbox:
devDependencies: devDependencies:
'@deepseek-ai/dsh-llm': '@deepseek-ai/dsh-llm':
@@ -6245,6 +6342,9 @@ packages:
neo-async@2.6.2: neo-async@2.6.2:
resolution: {integrity: sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==} resolution: {integrity: sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==}
node-addon-api@7.1.1:
resolution: {integrity: sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==}
node-addon-landlock-run-linux-arm64@0.0.0-test.0: node-addon-landlock-run-linux-arm64@0.0.0-test.0:
resolution: {integrity: sha512-oJsXcC33qKl9mWYx0n9YPJ2pUAoY39PoIX0Gx4lDrSCTEvENFrEaODAsQYNY+eEGpn9YMN7E+FOftvea3/1FqQ==} resolution: {integrity: sha512-oJsXcC33qKl9mWYx0n9YPJ2pUAoY39PoIX0Gx4lDrSCTEvENFrEaODAsQYNY+eEGpn9YMN7E+FOftvea3/1FqQ==}
engines: {node: '>=20'} engines: {node: '>=20'}
@@ -6331,6 +6431,9 @@ packages:
resolution: {integrity: sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==} resolution: {integrity: sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==}
engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0}
node-pty@1.1.0:
resolution: {integrity: sha512-20JqtutY6JPXTUnL0ij1uad7Qe1baT46lyolh2sSENDd4sTzKZ4nmAFkeAARDKwmlLjPx6XKRlwRUxwjOy+lUg==}
nth-check@2.1.1: nth-check@2.1.1:
resolution: {integrity: sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==} resolution: {integrity: sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==}
@@ -10805,6 +10908,8 @@ snapshots:
neo-async@2.6.2: {} neo-async@2.6.2: {}
node-addon-api@7.1.1: {}
node-addon-landlock-run-linux-arm64@0.0.0-test.0: node-addon-landlock-run-linux-arm64@0.0.0-test.0:
optional: true optional: true
@@ -10877,6 +10982,10 @@ snapshots:
fetch-blob: 3.2.0 fetch-blob: 3.2.0
formdata-polyfill: 4.0.10 formdata-polyfill: 4.0.10
node-pty@1.1.0:
dependencies:
node-addon-api: 7.1.1
nth-check@2.1.1: nth-check@2.1.1:
dependencies: dependencies:
boolbase: 1.0.0 boolbase: 1.0.0
+2
View File
@@ -31,6 +31,8 @@ allowBuilds:
'@google/genai': false '@google/genai': false
protobufjs: false protobufjs: false
node-addon-require-builtin: false node-addon-require-builtin: false
# Persistent PTY backend: compiles/downloads the reviewed native forkpty addon.
node-pty: true
# The Landlock launcher family is our own sibling-repo release, consumed # The Landlock launcher family is our own sibling-repo release, consumed
# fresh (hours old at each coordinated bump) — the release-age quarantine # fresh (hours old at each coordinated bump) — the release-age quarantine
+11
View File
@@ -77,6 +77,17 @@ export const LINK_MAP: Record<string, string> = {
ConfinedArgv: 'sandbox.md', ConfinedArgv: 'sandbox.md',
SandboxMode: 'sandbox.md', SandboxMode: 'sandbox.md',
SandboxPolicy: 'sandbox.md', SandboxPolicy: 'sandbox.md',
PtyBackend: 'pty.md',
PtyReadRequest: 'pty.md',
PtyReadResult: 'pty.md',
PtySendOperation: 'pty.md',
PtySendRequest: 'pty.md',
PtySessionId: 'pty.md',
PtySessionSnapshot: 'pty.md',
PtySignal: 'pty.md',
PtySignalResult: 'pty.md',
PtySpawnRequest: 'pty.md',
PtySpawnResult: 'pty.md',
ScopeKey: 'scope.md', ScopeKey: 'scope.md',
Scoped: 'scope.md', Scoped: 'scope.md',
EpochHeader: 'session.md', EpochHeader: 'session.md',
+16 -6
View File
@@ -59,6 +59,7 @@ const GROUP_ORDER = [
'llm', 'llm',
'core', 'core',
'bash', 'bash',
'pty',
'sandbox', 'sandbox',
'fs', 'fs',
'skill', 'skill',
@@ -124,7 +125,7 @@ const SERVICE_ROLES: ServiceRole[] = [
pkg: 'system-prompt', pkg: 'system-prompt',
title: 'System prompt assembly registry', title: 'System prompt assembly registry',
mode: 'core', mode: 'core',
consumers: ['agent-loop', 'tools', 'tool-fs', 'tool-web'], consumers: ['agent-loop', 'tools', 'tool-fs', 'tool-pty', 'tool-web'],
note: 'Collects prompt sections and model-facing tool schemas for each step.', note: 'Collects prompt sections and model-facing tool schemas for each step.',
}, },
{ {
@@ -132,7 +133,7 @@ const SERVICE_ROLES: ServiceRole[] = [
pkg: 'tools', pkg: 'tools',
title: 'Tool registry and guarded execution pipeline', title: 'Tool registry and guarded execution pipeline',
mode: 'core', mode: 'core',
consumers: ['agent-loop', 'tool-ask-user', 'tool-bash', 'tool-cordis', 'tool-fs', 'tool-skill', 'tool-subagent', 'tool-todo', 'tool-web', 'acp'], consumers: ['agent-loop', 'tool-ask-user', 'tool-bash', 'tool-cordis', 'tool-fs', 'tool-pty', 'tool-skill', 'tool-subagent', 'tool-todo', 'tool-web', 'acp'],
note: 'Registers capabilities, owns Code Mode transport, and routes calls through pre-policy, monotonic guards, around dispatch, post-policy, and final-result observation.', note: 'Registers capabilities, owns Code Mode transport, and routes calls through pre-policy, monotonic guards, around dispatch, post-policy, and final-result observation.',
}, },
{ {
@@ -185,13 +186,22 @@ const SERVICE_ROLES: ServiceRole[] = [
mode: 'core', mode: 'core',
note: 'Plugins declare effect-scoped DSH_* facts; tool-bash collects one trusted snapshot per execution and the executor rebuilds the namespace.', note: 'Plugins declare effect-scoped DSH_* facts; tool-bash collects one trusted snapshot per execution and the executor rebuilds the namespace.',
}, },
{
key: 'pty',
pkg: 'pty',
title: 'Persistent PTY session registry',
mode: 'seam',
implementations: ['pty-local'],
consumers: ['tool-pty'],
note: 'The registry owns exact-Agent session identity and cleanup; backends own terminal mechanics, while tool-pty exposes the owner-scoped model surface.',
},
{ {
key: 'sandbox', key: 'sandbox',
pkg: 'sandbox', pkg: 'sandbox',
title: 'Process-sandbox seam', title: 'Process-sandbox seam',
mode: 'seam', mode: 'seam',
implementations: ['sandbox-local'], implementations: ['sandbox-local'],
consumers: ['bash-sandbox'], consumers: ['bash-sandbox', 'pty-local'],
note: 'Consumers hand over the exact argv they are about to spawn; same-world backends wrap it under a per-call policy and report enforcement.', note: 'Consumers hand over the exact argv they are about to spawn; same-world backends wrap it under a per-call policy and report enforcement.',
}, },
{ {
@@ -200,7 +210,7 @@ const SERVICE_ROLES: ServiceRole[] = [
title: 'Sandbox policy home', title: 'Sandbox policy home',
mode: 'core', mode: 'core',
implementations: [], implementations: [],
consumers: ['bash-sandbox', 'fs-sandbox'], consumers: ['bash-sandbox', 'fs-sandbox', 'pty-local'],
note: 'The one home for the deployment default mode + workspace root; only the sandboxed executor and provider read the service (the tool layers use the pure `sandbox/mode` fold it also exports). Both enforcing families read it so bash and fs cannot confine to different roots.', note: 'The one home for the deployment default mode + workspace root; only the sandboxed executor and provider read the service (the tool layers use the pure `sandbox/mode` fold it also exports). Both enforcing families read it so bash and fs cannot confine to different roots.',
}, },
{ {
@@ -263,8 +273,8 @@ const SERVICE_ROLES: ServiceRole[] = [
pkg: 'tasks', pkg: 'tasks',
title: 'Background task registry', title: 'Background task registry',
mode: 'core', mode: 'core',
consumers: ['tool-bash', 'tool-subagent', 'tool-tasks'], consumers: ['tool-bash', 'tool-pty', 'tool-subagent', 'tool-tasks'],
note: 'Producers (tool-bash background commands, tool-subagent background delegations) register running work; tool-tasks is the model-facing control surface that reads, lists, and kills it.', note: 'Producers (background bash, PTY sends, and subagent delegations) register running work; tool-tasks is the model-facing control surface that reads, lists, and kills it.',
}, },
{ {
key: 'web', key: 'web',
+15
View File
@@ -30,6 +30,8 @@ import * as ToolBash from '@deepseek-ai/dsh-tool-bash'
import * as ToolCordis from '@deepseek-ai/dsh-tool-cordis' import * as ToolCordis from '@deepseek-ai/dsh-tool-cordis'
import * as ToolFs from '@deepseek-ai/dsh-tool-fs' import * as ToolFs from '@deepseek-ai/dsh-tool-fs'
import * as ToolFsSearch from '@deepseek-ai/dsh-tool-fs-search' import * as ToolFsSearch from '@deepseek-ai/dsh-tool-fs-search'
import PtyService from '@deepseek-ai/dsh-pty'
import * as ToolPty from '@deepseek-ai/dsh-tool-pty'
import * as ToolSkill from '@deepseek-ai/dsh-tool-skill' import * as ToolSkill from '@deepseek-ai/dsh-tool-skill'
import * as ToolTasks from '@deepseek-ai/dsh-tool-tasks' import * as ToolTasks from '@deepseek-ai/dsh-tool-tasks'
import * as ToolTodo from '@deepseek-ai/dsh-tool-todo' import * as ToolTodo from '@deepseek-ai/dsh-tool-todo'
@@ -219,6 +221,19 @@ const TOOL_PACKAGES: ToolPackage[] = [
note: note:
'glob and grep are conditional bash-backed discovery tools: they register only when ctx.bash can find `rg`, then run fixed ripgrep commands through ctx.bash as ordinary foreground calls (never background tasks). Capped results save the complete formatted list through the optional ctx.spillStore backend; returned locators are follow-up-readable/searchable when the backend exposes local paths in co-located deployments.', 'glob and grep are conditional bash-backed discovery tools: they register only when ctx.bash can find `rg`, then run fixed ripgrep commands through ctx.bash as ordinary foreground calls (never background tasks). Capped results save the complete formatted list through the optional ctx.spillStore backend; returned locators are follow-up-readable/searchable when the backend exposes local paths in co-located deployments.',
}, },
{
pkg: '@deepseek-ai/dsh-tool-pty',
dir: 'tool-pty',
source: 'packages/pty/tool-pty/src/index.ts',
requires: ['ctx.tools', 'ctx.pty', 'ctx.systemPrompt', 'ctx.tasks at call time for run_in_background'],
writes: ['tool/call', 'tool/result'],
async mount(ctx) {
await ctx.plugin(PtyService)
await ctx.plugin(ToolPty)
},
note:
'The six PTY tools are opt-in and complement one-shot bash/filesystem tools. `pty_send(run_in_background: true)` registers with `ctx.tasks`; TUI, named key sequences, BEL, resize, auto-start, and cross-agent sharing are absent from the schema.',
},
{ {
pkg: '@deepseek-ai/dsh-tool-skill', pkg: '@deepseek-ai/dsh-tool-skill',
dir: 'tool-skill', dir: 'tool-skill',
+6
View File
@@ -118,6 +118,12 @@
{ "doc": "docs/core-data-structures/tasks.md", "symbol": "TaskOutcome", "source": "packages/tasks/tasks/src/types.ts" }, { "doc": "docs/core-data-structures/tasks.md", "symbol": "TaskOutcome", "source": "packages/tasks/tasks/src/types.ts" },
{ "doc": "docs/core-data-structures/tasks.md", "symbol": "TaskSnapshot", "source": "packages/tasks/tasks/src/types.ts" }, { "doc": "docs/core-data-structures/tasks.md", "symbol": "TaskSnapshot", "source": "packages/tasks/tasks/src/types.ts" },
{ "doc": "docs/core-data-structures/tasks.md", "symbol": "TaskRead", "source": "packages/tasks/tasks/src/types.ts" }, { "doc": "docs/core-data-structures/tasks.md", "symbol": "TaskRead", "source": "packages/tasks/tasks/src/types.ts" },
{ "doc": "docs/core-data-structures/pty.md", "symbol": "PtyWaitReason", "source": "packages/pty/pty/src/types.ts" },
{ "doc": "docs/core-data-structures/pty.md", "symbol": "PtySessionStatus", "source": "packages/pty/pty/src/types.ts" },
{ "doc": "docs/core-data-structures/pty.md", "symbol": "PtyBackend", "source": "packages/pty/pty/src/types.ts" },
{ "doc": "docs/core-data-structures/pty.md", "symbol": "PtyBackendSession", "source": "packages/pty/pty/src/types.ts" },
{ "doc": "docs/core-data-structures/pty.md", "symbol": "PtySendOperation", "source": "packages/pty/pty/src/types.ts" },
{ "doc": "docs/core-data-structures/pty.md", "symbol": "PtySendResult", "source": "packages/pty/pty/src/types.ts" },
{ "doc": "docs/core-data-structures/sandbox.md", "symbol": "SandboxMode", "source": "packages/sandbox/sandbox/src/index.ts" }, { "doc": "docs/core-data-structures/sandbox.md", "symbol": "SandboxMode", "source": "packages/sandbox/sandbox/src/index.ts" },
{ "doc": "docs/core-data-structures/sandbox.md", "symbol": "ConfinedSandboxMode", "source": "packages/sandbox/sandbox/src/index.ts" }, { "doc": "docs/core-data-structures/sandbox.md", "symbol": "ConfinedSandboxMode", "source": "packages/sandbox/sandbox/src/index.ts" },
+1
View File
@@ -44,6 +44,7 @@
"./packages/prompt/*/src", "./packages/prompt/*/src",
"./packages/llm/*/src", "./packages/llm/*/src",
"./packages/bash/*/src", "./packages/bash/*/src",
"./packages/pty/*/src",
"./packages/code-runtime/*/src", "./packages/code-runtime/*/src",
"./packages/fs/*/src", "./packages/fs/*/src",
"./packages/skill/*/src", "./packages/skill/*/src",
+3
View File
@@ -39,6 +39,9 @@
{ "path": "./packages/examples/agent-spine-demo" }, { "path": "./packages/examples/agent-spine-demo" },
{ "path": "./packages/examples/cli-demo" }, { "path": "./packages/examples/cli-demo" },
{ "path": "./packages/bash/bash" }, { "path": "./packages/bash/bash" },
{ "path": "./packages/pty/pty" },
{ "path": "./packages/pty/pty-local" },
{ "path": "./packages/pty/tool-pty" },
{ "path": "./packages/code-runtime/code-runtime" }, { "path": "./packages/code-runtime/code-runtime" },
{ "path": "./packages/code-runtime/code-runtime-worker" }, { "path": "./packages/code-runtime/code-runtime-worker" },
{ "path": "./packages/compact/compact" }, { "path": "./packages/compact/compact" },
+3
View File
@@ -50,6 +50,9 @@
{ "path": "./packages/examples/agent-spine-demo" }, { "path": "./packages/examples/agent-spine-demo" },
{ "path": "./packages/examples/cli-demo" }, { "path": "./packages/examples/cli-demo" },
{ "path": "./packages/bash/bash" }, { "path": "./packages/bash/bash" },
{ "path": "./packages/pty/pty" },
{ "path": "./packages/pty/pty-local" },
{ "path": "./packages/pty/tool-pty" },
{ "path": "./packages/code-runtime/code-runtime" }, { "path": "./packages/code-runtime/code-runtime" },
{ "path": "./packages/code-runtime/code-runtime-worker" }, { "path": "./packages/code-runtime/code-runtime-worker" },
{ "path": "./packages/llm/llm-deepseek" }, { "path": "./packages/llm/llm-deepseek" },
@@ -62,6 +62,10 @@
"text": "ctx.permission", "text": "ctx.permission",
"link": "/zh-CN/api/harness/permission" "link": "/zh-CN/api/harness/permission"
}, },
{
"text": "ctx.pty",
"link": "/zh-CN/api/harness/pty"
},
{ {
"text": "ctx.sandbox", "text": "ctx.sandbox",
"link": "/zh-CN/api/harness/sandbox" "link": "/zh-CN/api/harness/sandbox"
+178
View File
@@ -0,0 +1,178 @@
<!-- Generated by scripts/gen-website-api.ts — do not edit by hand. Run `pnpm run gen-website-api` to regenerate. -->
# ctx.pty
`PtyService` — provided by `@deepseek-ai/dsh-pty`.
In-process registry for replaceable PTY backends and exact-Agent sessions.
[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/pty/pty/src/index.ts#L95)
### ctx.pty.registerBackend(backend)
```ts website-api
/**
* Register one backend type for this effect scope.
* @param backend - provider with a non-empty unique type.
* @returns disposer that removes exactly this contribution.
*/
registerBackend(backend: PtyBackend): () => void
```
Register one backend type for this effect scope.
- `backend` — provider with a non-empty unique type.
**Returns** disposer that removes exactly this contribution.
[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/pty/pty/src/index.ts#L114)
### ctx.pty.listBackends()
```ts website-api
/**
* List registered backend types in registration order.
* @returns fresh backend type names.
*/
listBackends(): string[]
```
List registered backend types in registration order.
**Returns** fresh backend type names.
[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/pty/pty/src/index.ts#L132)
### ctx.pty.spawn(owner, request, signal?)
```ts website-api
/**
* Create and publish one owner-scoped session after backend setup succeeds.
* @param owner - exact registered Agent that owns access and cleanup.
* @param request - backend type plus optional owner-local name and cwd.
* @param signal - cancellation of unpublished setup.
* @returns published identity, metadata, status, and MOTD.
*/
async spawn(owner: Agent, request: PtySpawnRequest, signal?: AbortSignal): Promise<PtySpawnResult>
```
Create and publish one owner-scoped session after backend setup succeeds.
- `owner` — exact registered Agent that owns access and cleanup.
- `request` — backend type plus optional owner-local name and cwd.
- `signal` — cancellation of unpublished setup.
**Returns** published identity, metadata, status, and MOTD.
[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/pty/pty/src/index.ts#L143)
### ctx.pty.startSend(owner, id, request)
```ts website-api
/**
* Start one exclusive interactive send.
* @param owner - exact session owner.
* @param id - target PTY identity.
* @param request - explicit text, submit behavior, and cancellation.
* @returns live operation handle for foreground await or task registration.
*/
startSend(owner: Agent, id: PtySessionId, request: PtySendRequest): PtySendOperation
```
Start one exclusive interactive send.
- `owner` — exact session owner.
- `id` — target PTY identity.
- `request` — explicit text, submit behavior, and cancellation.
**Returns** live operation handle for foreground await or task registration.
[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/pty/pty/src/index.ts#L198)
### ctx.pty.read(owner, id, request?)
```ts website-api
/**
* Read one bounded scrollback page from an owned session.
* @param owner - exact session owner.
* @param id - target PTY identity.
* @param request - optional newest-relative offset and line count.
* @returns bounded retained text and pagination metadata.
*/
read(owner: Agent, id: PtySessionId, request: PtyReadRequest = {}): PtyReadResult
```
Read one bounded scrollback page from an owned session.
- `owner` — exact session owner.
- `id` — target PTY identity.
- `request` — optional newest-relative offset and line count.
**Returns** bounded retained text and pagination metadata.
[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/pty/pty/src/index.ts#L218)
### ctx.pty.signal(owner, id, signal)
```ts website-api
/**
* Deliver an allowed signal through an owned backend session.
* @param owner - exact session owner.
* @param id - target PTY identity.
* @param signal - allowed POSIX signal name.
* @returns delivered foreground process-group identity.
*/
signal(owner: Agent, id: PtySessionId, signal: PtySignal): Promise<PtySignalResult>
```
Deliver an allowed signal through an owned backend session.
- `owner` — exact session owner.
- `id` — target PTY identity.
- `signal` — allowed POSIX signal name.
**Returns** delivered foreground process-group identity.
[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/pty/pty/src/index.ts#L229)
### ctx.pty.kill(owner, id, reason?)
```ts website-api
/**
* Close one owned session and remove it only after quiescent backend cleanup.
* @param owner - exact session owner.
* @param id - target PTY identity.
* @param reason - diagnostic cleanup reason.
* @returns true for a newly closed session, false when the same close is already in flight.
*/
async kill(owner: Agent, id: PtySessionId, reason = 'model request'): Promise<boolean>
```
Close one owned session and remove it only after quiescent backend cleanup.
- `owner` — exact session owner.
- `id` — target PTY identity.
- `reason` — diagnostic cleanup reason.
**Returns** true for a newly closed session, false when the same close is already in flight.
[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/pty/pty/src/index.ts#L240)
### ctx.pty.list(owner)
```ts website-api
/**
* List fresh snapshots for exactly one owner.
* @param owner - exact owner whose sessions are visible.
* @returns owner-visible snapshots in publication order.
*/
list(owner: Agent): PtySessionSnapshot[]
```
List fresh snapshots for exactly one owner.
- `owner` — exact owner whose sessions are visible.
**Returns** owner-visible snapshots in publication order.
[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/pty/pty/src/index.ts#L263)