@deepseek-ai/dsh-terminal-bash 0.1.1-rc.2 → 0.1.2-alpha.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.i18n.yaml CHANGED
@@ -2,5 +2,5 @@
2
2
  # side as of the last confirmed-consistent state. Both languages carry equal authority;
3
3
  # after editing either side, bring the other along and re-record with:
4
4
  # pnpm run verify-translation-pairing --write packages/terminal/terminal-bash/README.md
5
- README.md: 2f3f59b1acb88ff9905e78e7fc8d0d9fbcdbf0ba
6
- README.zh.md: f3daa0a3bc9c160236ad19b35589778d99d48b36
5
+ README.md: 779e7802f0e7e018d19a9757707e29601926d474
6
+ README.zh.md: 91c7af9fb62da79e09b1baa9ce7447b91d09448e
package/README.md CHANGED
@@ -1,39 +1,179 @@
1
+ ---
2
+ description: "The shipped shell backend for persistent terminal sessions: interactive bash or pwsh under the shared sandbox policy, with readiness detection and bounded line-oriented output."
3
+ kind: "package-reference"
4
+ ---
5
+
1
6
  # @deepseek-ai/dsh-terminal-bash
2
7
 
3
8
  English | [中文](README.zh.md)
4
9
 
5
- Persistent shell backend for `ctx.terminals` over `ctx.subprocess.spawnTerminal`. It starts an interactive shell under the shared `ctx.sandboxPolicy`, retains bounded line-oriented output, and detects readiness while the subprocess provider owns PTY allocation, environment scrubbing, foreground process groups, signalling, and complete terminal-session cleanup. The same PTY backend therefore composes with local or remote execution-world providers.
10
+ ## Summary
11
+
12
+ `dsh-terminal-bash` starts a persistent interactive shell under the deployment's sandbox policy: the session stays alive across tool calls, readiness for input is detected, and bounded line-oriented output is retained for reads. It provides the `shell` backend type and supports bash on POSIX and pwsh on Windows through a `shellDialect` setting. The same backend composes with local or remote execution worlds through the mounted subprocess provider. Full-screen terminal applications are outside its line-oriented contract.
13
+
14
+ ## Table of Contents
15
+
16
+ - [Use this package](#use-this-package)
17
+ - [Understand the implementation](#understand-the-implementation)
18
+ - [Further Exploration](#further-exploration)
19
+ - [Model Experience](#model-experience)
20
+ - [Known Limitations and Deferred Work](#known-limitations-and-deferred-work)
21
+ - [Dev Note](#dev-note)
22
+
23
+ -----
24
+
25
+ <a id="use-this-package"></a>
26
+ ## Use this package
27
+
28
+ Mount this backend when a composition needs persistent shell sessions — state such as cwd, exported variables, functions, or running interactive children must survive across tool calls. It is the default `shell` type: a composition that mounts `@deepseek-ai/dsh-terminal` without it has no sessions to open.
29
+
30
+ ### When to choose it
31
+
32
+ Choose this backend when work needs an interactive shell or REPL whose state persists: stepping a debugger, exploring in a Python or Node REPL, or returning to a shell after interrupting a foreground command. Choose the one-shot bash tool for bounded commands that should start and end in one call. The bash dialect targets POSIX; the pwsh dialect targets Windows hosts where `dsh-pwsh-local` can resolve a pwsh executable.
33
+
34
+ ### Composition
35
+
36
+ Mount the terminal service, a subprocess provider, the sandbox and policy services, this backend, and a tool package:
37
+
38
+ ```yaml
39
+ - name: '@deepseek-ai/dsh-terminal'
40
+ - name: '@deepseek-ai/dsh-subprocess-local'
41
+ - name: '@deepseek-ai/dsh-sandbox-local'
42
+ - name: '@deepseek-ai/dsh-sandbox-policy'
43
+ - name: '@deepseek-ai/dsh-terminal-bash'
44
+ - name: '@deepseek-ai/dsh-tool-terminal'
45
+ ```
46
+
47
+ `danger-full-access` starts the shell directly. Confined modes require a same-world `ctx.sandbox` provider: without one, the spawn fails before the shell starts.
48
+
49
+ ### Configuration
50
+
51
+ | Field | Default | Meaning |
52
+ |---|---|---|
53
+ | `backendType` | `shell` | Backend type registered on `ctx.terminals` |
54
+ | `shellDialect` | `bash` | Interactive shell stack: `bash` or `pwsh` |
55
+ | `shellPath` / `shellArgs` | per dialect | Shell executable and arguments; empty selects the dialect defaults |
56
+ | `maxReadBytes` | `262144` | Maximum UTF-8 bytes returned by one read or settled send |
57
+ | `timeoutMs` | `30000` | Absolute bound on one send wait |
58
+ | `disposeGraceMs` | `3000` | Grace before teardown escalates to `SIGKILL` |
59
+
60
+ The generated [configuration catalog](../../../docs/config-catalog.md#deepseek-aidsh-terminal-bash) is the exhaustive source for every field, including the readiness timings (`pollIntervalMs`, `exactProbeAfterMs`, `idleSilenceMs`, `handoffGraceMs`), terminal size (`rows`, `cols`), and scrollback bounds (`scrollbackLines`, `scrollbackMaxBytes`).
61
+
62
+ ### Shell dialects and readiness
63
+
64
+ Both dialects expose the same readiness contract, so consumers are dialect-agnostic. A send settles when the shell is ready again: after the controlled prompt is verified, after the foreground process group provably waits on stdin (Linux), after output silence (`inferred_idle`), or at the absolute `timeoutMs`. An `inferred_idle` or `timeout` result does not prove the foreground command exited.
65
+
66
+ ### Sandboxing and safe operation
67
+
68
+ The shell runs under the effective sandbox boundary for its whole life. Changing the effective sandbox mode is rejected while the owner still has open sessions or a spawn in progress — wait for creation to settle and close the sessions first, so a terminal opened with wider access cannot survive a downgrade. The backend supplies only terminal-specific environment overrides; the subprocess provider applies its shared credential scrub.
69
+
70
+ ### Observable outcomes and failures
71
+
72
+ An open returns the session id and a bounded startup message. Sends settle with one of the four wait reasons and a session status; `session_exit` means the top-level shell exited. Setup failures reject the open: a missing sandbox provider in a confined mode, a shell that exits during startup, a shell that fails to reach readiness before the startup timeout, or caller cancellation. Cleanup failures reject the close instead of claiming success.
73
+
74
+ -----
75
+
76
+ <a id="understand-the-implementation"></a>
77
+ ## Understand the implementation
78
+
79
+ <details>
80
+ <summary>Implementation internals — click to expand</summary>
81
+
82
+ This section explains the design behind the backend and points at the code that realizes it; the observable behavior is covered in [Use this package](#use-this-package).
83
+
84
+ ### Design concept
6
85
 
7
- ## Plugin (`terminal-bash`)
86
+ One backend serves both dialects: bash and pwsh share the same session machinery — sanitizer, bounded buffers, readiness polling, cancellation, and teardown — and differ only in argv, environment, and prompt installation. Bash receives a private marker through `PS1` plus `PROMPT_COMMAND`. Pwsh writes a prompt function, pins UTF-8 console encoding, and publishes startup only after the backend reports `stdin_read`; echoed setup text cannot publish the shell. A zero-scrollback `@xterm/headless` instance consumes raw PTY data and returns terminal-protocol replies through the same handle, while the line sanitizer remains the only output projection.
8
87
 
9
- The plugin injects `pty`, `sandboxPolicy`, and `subprocess`, then registers the configured backend type (`shell`). `danger-full-access` starts the shell directly without requiring a sandbox provider; confined modes require a same-world `ctx.sandbox` and wrap the exact shell argv through it, failing before spawn when none is mounted. At spawn, one `ctx.sandboxPolicy.resolve({ session })` call supplies both the effective mode and the session workspace root; the same root is the default shell cwd when the caller omits one. A change to a different effective mode is rejected before its `sandbox/mode` event commits while that owner has an open PTY or a spawn in progress; the fence is attached to the exact owner and therefore outlives a provider reload that retains existing sessions. Wait for creation to settle and close the sessions before changing modes, so a terminal opened with wider access cannot survive a downgrade.
88
+ ### Source map
10
89
 
11
- `shellDialect` selects the shell stack (`bash` default, `pwsh`): it picks the default `shellPath`/`shellArgs` (bash `--noprofile --norc -i`; pwsh `-NoLogo -NoProfile` through the shared `dsh-pwsh-local` resolver) and the startup contract. The bash dialect installs its prompt through the environment (`PS1` plus an OSC `133;D;`-terminated `PROMPT_COMMAND`). pwsh cannot install a prompt from the environment, so the backend writes a `prompt` function through the session and waits until the controlled prompt is actually visible — looping over follow-up sends because the pwsh banner-to-prompt gap can outlast the silence bound — while its environment drops the bash-only markers and adds `NO_COLOR`. That first send also prefixes the shared `dsh-pwsh-local` encoding preamble, pinning `[Console]::OutputEncoding` and `$OutputEncoding` to UTF-8 before anything runs: the session decode path reads PTY bytes as UTF-8, and an un-pinned console writes its host code page for non-ASCII output. Both dialects emit the same BEL-terminated OSC marker, so the readiness machinery and consumers are dialect-agnostic.
90
+ | File | Role |
91
+ |---|---|
92
+ | [`src/index.ts`](src/index.ts) | Backend registration, sandbox-mode fence, argv and environment assembly, startup sequence |
93
+ | [`src/config.ts`](src/config.ts) | Dialect resolution, defaults, and validation of every timing field |
94
+ | [`src/session.ts`](src/session.ts) | `LocalPtySession`: send lifecycle, readiness polling, scrollback, signals, close |
95
+ | [`src/sanitize.ts`](src/sanitize.ts) | Streaming control-sequence sanitizer and line normalization |
12
96
 
13
- Readiness combines a foreground-verified private bash prompt marker, provider-reported foreground stdin-wait facts, silence fallback, and absolute timeout. A marker is not ready until the printable tail after the latest owned marker exactly equals the controlled `PS1`, including when the OSC marker and prompt are split across data callbacks; echoed input or output following an earlier prompt therefore cannot settle the current send. The controlled `PROMPT_COMMAND` re-asserts that `PS1` before every prompt, so an in-shell prompt override cannot degrade later sends to silence readiness. Prompt and silence evidence collected before the provider write, including while pre-write foreground inspection is pending, is discarded at the write boundary. When bash prints the marker before the terminal provider publishes its return to the foreground process group, polling retains the candidate for `handoffGraceMs` past the ordinary silence bound so a coincident handoff can win. An interactive child that inherits `PROMPT_COMMAND` therefore cannot suppress inferred-idle readiness until the absolute timeout. Unknown foreground state is never a positive exact-idle signal. A foreground group's stdin wait that existed before a send is likewise not post-write readiness: the same group must be observed outside that wait before a later wait can settle the send, while a changed foreground group is new evidence. During unpublished startup, a fallback requires observed output; zero-output silence cannot publish an empty session, and timeout rejects the spawn. Cancellation closes the unpublished shell and rejects with the caller's exact abort reason; `TerminalBackendCleanupError` separately preserves a cleanup failure. The caller's signal is forwarded for terminal allocation and readiness initialization; after publication the handle owns its lifetime. Incomplete terminal-control sequences are bounded by `maxReadBytes` and discarded through their terminator after crossing that limit; malformed UTF-8 terminal output uses replacement characters, and a trailing carriage return is carried across callbacks so split CRLF becomes one newline.
97
+ ### Readiness model
14
98
 
15
- Send cancellation marks queued input as canceled before asking the terminal handle to signal the current foreground process group with a real `SIGINT`; if asynchronous pre-write inspection later settles, it cannot execute that input. If a provider write is already in flight, signalling waits for it to settle; a rejected write sends no signal. The canceled send retains its slot until the write and foreground signalling settle, so a successor cannot receive either late bytes or that signal. A provider write or signal that never settles therefore retains the slot indefinitely; closing the session (`terminal_close`) is the recovery. The absolute deadline remains armed while cancellation waits. A signal failure is a terminal transport failure and rejects the active send. Cancellation never emulates interruption by writing `\x03`, so raw-mode programs remain cancellable. Close rejects new public signals, stops readiness polling, and awaits the handle's provider-owned complete-session termination before settling the active send as `session_exit`.
99
+ Three bounded tiers settle a send: exact stdin-wait evidence from the subprocess provider (Linux only), the verified private prompt marker with an exact printable tail, and output silence (`inferred_idle`); an absolute timeout always bounds the wait. Pwsh startup uses one deadline across its complete setup loop, so an `inferred_idle` follow-up does not restart the bound. Evidence collected before the provider write is discarded at the write boundary, a stdin wait that predates the write is not post-write readiness, and unknown foreground state is never a positive exact-idle signal.
16
100
 
101
+ ### Send cancellation and teardown
102
+
103
+ Cancellation marks queued input as canceled, then signals the current foreground process group with a real `SIGINT` after any in-flight provider write settles; it never emulates interruption by writing `\x03`. Closing stops readiness polling, terminates the provider-owned process tree, awaits quiescence, and settles the active send as `session_exit`.
104
+
105
+ ### Sandbox-mode fence
106
+
107
+ A write that would change the effective sandbox mode is rejected before the `sandbox/mode` event commits while that owner has an open session or a spawn in progress. The fence is attached to the exact owner and outlives a provider reload that retains existing sessions.
108
+
109
+ </details>
110
+
111
+ -----
112
+
113
+ <a id="further-exploration"></a>
114
+ ## Further Exploration
115
+
116
+ Read these pages when the package-level contract is not enough. They move from the shared terminal model to the service, the tools, and the execution-world substrate.
117
+
118
+ - [Terminal subsystem reference](../../../docs/subsystems/terminal.md) — the service contract this backend implements and the generated `ctx.terminals` surface.
119
+ - [terminal service](../terminal/README.md) — backend registration, owner fencing, and cleanup semantics.
120
+ - [tool-terminal tools](../tool-terminal/README.md) — the model-facing tools that operate sessions.
121
+ - [Subprocess seam](../../../docs/subsystems/subprocess.md) — the terminal primitive that owns PTY allocation and process-tree cleanup.
122
+ - [Persistent PTY Agent Note](../../../.agents/notes/implemented/feature/2026-07-16-persistent-pty-sessions.md) — the capability design and deferred boundaries.
123
+ - [Persistent pwsh Agent Note](../../../.agents/notes/implemented/architecture/2026-08-11-pwsh-persistent-pty.md) — the Windows substrate and the pwsh dialect.
124
+
125
+ -----
126
+
127
+ <a id="model-experience"></a>
17
128
  ## Model Experience
18
129
 
19
- ### Current file policy and indirect consumer
130
+ ### Indirect consumer
20
131
 
21
132
  #### What the model sees
22
133
 
23
- The policy owner contributes capability-neutral `sandbox:policy` context. Through `@deepseek-ai/dsh-tool-terminal` or another PTY consumer, the model may also receive bounded MOTD, send deltas, scrollback pages, readiness reasons, and cleanup errors.
134
+ This package registers no prompt or tool. Through `@deepseek-ai/dsh-tool-terminal` or another PTY consumer, the model may receive bounded startup output, send deltas, scrollback pages, readiness reasons, and cleanup errors.
24
135
 
25
136
  #### Token effect
26
137
 
27
- The current-policy clause is present while this backend is mounted. Retained PTY scrollback is not placed in model history until a consumer returns bounded output.
138
+ Retained PTY scrollback is not placed in model history until a consumer returns bounded output.
28
139
 
29
140
  #### KV Cache effect
30
141
 
31
- A standing-policy change appends an owner-rendered superseding runtime-context snapshot after retained history; consumer results remain append-only.
142
+ No direct invalidation; consumer results remain append-only.
143
+
144
+ ### Sandbox policy context
145
+
146
+ #### What the model sees
147
+
148
+ While this backend is composed, the `sandbox-policy` owner contributes the capability-neutral `sandbox:policy` runtime-context clause to prompts.
149
+
150
+ #### Token effect
151
+
152
+ The policy clause is present on requests while the backend is mounted.
153
+
154
+ #### KV Cache effect
155
+
156
+ A standing-policy change appends a superseding runtime-context snapshot after retained history.
32
157
 
33
158
  ## Known Limitations and Deferred Work
34
159
 
35
- - Line-oriented output is normalized; full-screen alternate-buffer interaction is unsupported.
36
- - Exact stdin-wait detection depends on the mounted subprocess provider; providers that cannot prove it use prompt-marker and silence/timeout readiness. Windows is such a provider: the shell pid is the pseudo foreground group and there is no exact stdin-wait tier, so a marker-less child settles on the silence bound.
37
- - The pwsh bootstrap writes through `[Console]::` (the UTF-8 encoding pin and the prompt function), which the Windows ACL sandbox's read-only mode (ConstrainedLanguage) may deny. The shell can still settle through the controlled printable prompt and silence tier, but marker readiness is unavailable and non-ASCII output may follow the host code page.
38
- - Cleanup guarantees are those of `SubprocessTerminalHandle`; provider-specific gaps belong to that implementation's contract rather than this PTY consumer.
39
- - Sessions do not survive harness process exit.
160
+ <a id="known-limitations-and-deferred-work"></a>
161
+
162
+
163
+ These limits define where the backend is a poor fit or needs special operational care. They are current package constraints, not a general shell comparison or a task backlog.
164
+
165
+ - **Line-oriented output only** — a headless xterm maintains control-sequence state only for terminal-protocol replies. Returned output remains normalized to lines, and full-screen alternate-buffer interaction is unsupported.
166
+ - **Readiness is heuristic without an exact tier** — exact stdin-wait detection depends on the mounted subprocess provider; providers that cannot prove it (macOS, Windows) settle on prompt-marker and silence/timeout readiness.
167
+ - **pwsh bootstrap in a constrained sandbox** — the prompt function and UTF-8 pin write through `[Console]::`, which the Windows ACL sandbox's read-only mode may deny. When that prevents marker readiness, startup rejects at `timeoutMs` instead of publishing an incomplete shell.
168
+ - **Cleanup guarantees belong to the provider** — process-tree teardown is the `SubprocessTerminalHandle` contract, not this backend's.
169
+ - **Sessions do not survive process exit** — a harness restart destroys every session.
170
+
171
+ <a id="dev-note"></a>
172
+ ### Dev Note
173
+
174
+ <details>
175
+ <summary>Working context for maintainers — click to expand</summary>
176
+
177
+ None.
178
+
179
+ </details>
package/README.zh.md CHANGED
@@ -1,39 +1,179 @@
1
+ ---
2
+ description: "持久终端会话的随附 shell 后端:在共享沙箱策略下启动交互式 bash 或 pwsh,带就绪检测与有界逐行输出。"
3
+ kind: "package-reference"
4
+ ---
5
+
1
6
  # @deepseek-ai/dsh-terminal-bash
2
7
 
3
8
  [English](README.md) | 中文
4
9
 
5
- 这是一个基于 `ctx.subprocess.spawnTerminal`、为 `ctx.terminals` 提供的持久 shell 后端。它在共享 `ctx.sandboxPolicy` 下启动交互式 shell,保留有界的逐行输出并检测就绪状态;进程管理提供方则负责 PTY 分配、环境清理、前台进程组、信号发送和完整终端会话清理。因此,同一个 PTY 后端可以与本地或远程执行世界提供方组合。
10
+ ## 概述
11
+
12
+ `dsh-terminal-bash` 在部署的沙箱策略下启动持久交互式 shell:会话跨工具调用存活,检测 shell 何时可以接收输入,并保留有界的逐行输出供读取。它提供 `shell` 后端类型,并通过 `shellDialect` 设置在 POSIX 上支持 bash、在 Windows 上支持 pwsh。通过已挂载的子进程提供方,同一个后端既可以与本地执行世界组合,也可以与远程执行世界组合。全屏终端应用不在其逐行约定的范围内。
13
+
14
+ ## 目录
15
+
16
+ - [使用本包](#use-this-package)
17
+ - [理解实现](#understand-the-implementation)
18
+ - [进一步探索](#further-exploration)
19
+ - [模型体验](#model-experience)
20
+ - [已知限制与延期工作](#known-limitations-and-deferred-work)
21
+ - [开发备注](#dev-note)
22
+
23
+ -----
24
+
25
+ <a id="use-this-package"></a>
26
+ ## 使用本包
27
+
28
+ 当组合需要持久 shell 会话时挂载此后端——cwd、导出的变量、函数或正在运行的交互式子进程等状态必须跨工具调用存活。它是默认的 `shell` 类型:组合只挂载 `@deepseek-ai/dsh-terminal` 而不挂载它时,将没有任何会话可打开。
29
+
30
+ ### 何时选择
31
+
32
+ 当工作需要状态持续存在的交互式 shell 或 REPL 时选择此后端:逐步调试 gdb、在 Python 或 Node REPL 中探索,或中断前台命令后回到 shell。对于应当一次调用即开始并结束的有界命令,请选择单次 bash 工具。bash 方言面向 POSIX;pwsh 方言面向 `dsh-pwsh-local` 能解析出 pwsh 可执行文件的 Windows 主机。
33
+
34
+ ### 组合方式
35
+
36
+ 挂载终端服务、子进程提供方、沙箱与策略服务、此后端以及一个工具包:
37
+
38
+ ```yaml
39
+ - name: '@deepseek-ai/dsh-terminal'
40
+ - name: '@deepseek-ai/dsh-subprocess-local'
41
+ - name: '@deepseek-ai/dsh-sandbox-local'
42
+ - name: '@deepseek-ai/dsh-sandbox-policy'
43
+ - name: '@deepseek-ai/dsh-terminal-bash'
44
+ - name: '@deepseek-ai/dsh-tool-terminal'
45
+ ```
46
+
47
+ `danger-full-access` 直接启动 shell。受限模式要求同一执行世界中存在 `ctx.sandbox` 提供方:缺少时,spawn 会在 shell 启动前失败。
48
+
49
+ ### 配置
50
+
51
+ | 字段 | 默认值 | 含义 |
52
+ |---|---|---|
53
+ | `backendType` | `shell` | 注册到 `ctx.terminals` 的后端类型 |
54
+ | `shellDialect` | `bash` | 交互式 shell 栈:`bash` 或 `pwsh` |
55
+ | `shellPath` / `shellArgs` | 按方言 | shell 可执行文件与参数;为空时选择方言默认值 |
56
+ | `maxReadBytes` | `262144` | 一次读取或一次结算发送返回的最大 UTF-8 字节数 |
57
+ | `timeoutMs` | `30000` | 一次发送等待的绝对上限 |
58
+ | `disposeGraceMs` | `3000` | 清理升级到 `SIGKILL` 前的宽限时间 |
59
+
60
+ 生成的[配置目录](../../../docs/config-catalog.zh.md#deepseek-aidsh-terminal-bash)是每个字段的穷尽式真源,包括就绪计时(`pollIntervalMs`、`exactProbeAfterMs`、`idleSilenceMs`、`handoffGraceMs`)、终端尺寸(`rows`、`cols`)与 scrollback 上限(`scrollbackLines`、`scrollbackMaxBytes`)。
61
+
62
+ ### shell 方言与就绪
63
+
64
+ 两种方言暴露相同的就绪约定,因此消费方与方言无关。当 shell 再次就绪时发送即结算:受控提示符被验证之后、前台进程组被证明在等待 stdin(Linux)之后、输出静默(`inferred_idle`)之后,或到达绝对 `timeoutMs`。`inferred_idle` 或 `timeout` 结果并不证明前台命令已退出。
65
+
66
+ ### 沙箱与安全运行
67
+
68
+ shell 在整个生命周期内运行在有效的沙箱边界之下。当所有者仍有打开的会话或进行中的 spawn 时,改变有效沙箱模式会被拒绝——请先等待创建完成并关闭会话,避免以更宽权限打开的终端在权限降级后继续存在。后端只提供终端专属的环境覆盖;共享凭据清理由子进程提供方负责。
69
+
70
+ ### 可观察结果与失败
71
+
72
+ 打开会返回会话 id 与有界启动消息。发送以四种等待原因之一与一个会话状态结算;`session_exit` 表示顶层 shell 已退出。设置失败会拒绝打开:受限模式下缺少沙箱提供方、shell 在启动期间退出、shell 未能在启动超时前达到就绪,或调用方取消。清理失败会拒绝关闭,而不是声称成功。
73
+
74
+ -----
75
+
76
+ <a id="understand-the-implementation"></a>
77
+ ## 理解实现
78
+
79
+ <details>
80
+ <summary>实现细节——点击展开</summary>
81
+
82
+ 本节解释后端背后的设计并指出实现它们的代码位置;可观察行为已在[使用本包](#use-this-package)中说明。
83
+
84
+ ### 设计理念
6
85
 
7
- ## 插件(`terminal-bash`)
86
+ 一个后端服务两种方言:bash 与 pwsh 共享同一套会话机制——清理器、有界缓冲区、就绪轮询、取消与关闭——只在 argv、环境与提示符安装方式上不同。bash 通过 `PS1` 加 `PROMPT_COMMAND` 接收私有标记。pwsh 会写入提示符函数、钉住 UTF-8 控制台编码,并只在后端报告 `stdin_read` 后发布启动;回显的设置文本不能发布 shell。一个不保留 scrollback 的 `@xterm/headless` 实例会消费原始 PTY 数据,并通过同一句柄返回终端协议响应;逐行 sanitizer 仍是唯一输出投影。
8
87
 
9
- 该插件注入 `pty`、`sandboxPolicy` 和 `subprocess`,然后注册所配置的后端类型(`shell`)。`danger-full-access` 无需沙箱提供方即可直接启动 shell;受限模式要求同一执行世界中存在 `ctx.sandbox`,并通过它包装确切的 shell argv,未挂载时会在 spawn 前失败。spawn 时,一次 `ctx.sandboxPolicy.resolve({ session })` 调用会同时给出实际模式与会话工作区根目录;调用方省略 cwd 时,同一根目录也是 shell 的默认 cwd。当某个所有者存在开放的 PTY 或正在进行 spawn 时,如果配置变更会得到不同的实际模式,系统会在对应 `sandbox/mode` 事件提交前拒绝该变更。该限制绑定到确切所有者,因此即使提供方重新加载并保留现有会话,它仍然有效。更改模式前,请等待创建完成并关闭会话,避免以更宽权限打开的终端在权限降级后继续存在。
88
+ ### 源码地图
10
89
 
11
- `shellDialect` 选择 shell 栈(默认 `bash`,或 `pwsh`):它决定默认的 `shellPath`/`shellArgs`(bash 为 `--noprofile --norc -i`;pwsh 经共享的 `dsh-pwsh-local` 解析器得到 `-NoLogo -NoProfile`)与启动契约。bash 方言通过环境安装提示符(`PS1` 加 OSC `133;D;` 终结的 `PROMPT_COMMAND`)。pwsh 无法从环境安装提示符,因此后端通过会话写入 `prompt` 函数,并等待受控提示符真正可见——因为 pwsh 从横幅到提示符的间隙可能超过静默上限,所以会在后续 send 上循环等待;同时其环境去掉 bash 专属标记并加 `NO_COLOR`。同一条首发送还会带上共享的 `dsh-pwsh-local` 编码前缀,在一切运行之前把 `[Console]::OutputEncoding` 与 `$OutputEncoding` 钉为 UTF-8:会话解码路径按 UTF-8 读取 PTY 字节,未钉住编码的控制台会以宿主代码页输出非 ASCII 内容。两种方言发出相同的 BEL 终结 OSC 标记,因此就绪机制与消费方与方言无关。
90
+ | 文件 | 职责 |
91
+ |---|---|
92
+ | [`src/index.ts`](src/index.ts) | 后端注册、沙箱模式限制、argv 与环境组装、启动序列 |
93
+ | [`src/config.ts`](src/config.ts) | 方言解析、默认值与每个计时字段的校验 |
94
+ | [`src/session.ts`](src/session.ts) | `LocalPtySession`:发送生命周期、就绪轮询、scrollback、信号、关闭 |
95
+ | [`src/sanitize.ts`](src/sanitize.ts) | 流式控制序列清理器与行规范化 |
12
96
 
13
- 就绪检测结合以下机制:由前台状态验证的私有 bash 提示符标记、提供方报告的前台 stdin 等待事实、静默回退和绝对超时。只有最新自有标记之后的可打印尾部与受控 `PS1` 完全相等,标记才算就绪;即使 OSC 标记和提示符被拆到多个数据回调中也一样。因此,较早提示符之后的回显输入或输出无法使当前 send 完成。受控 `PROMPT_COMMAND` 会在每次输出提示符前重新设定该 `PS1`,因此在 shell 内覆盖提示符不会使后续 send 退化到静默就绪。提供方写入前收集的提示符与静默证据,包括写入前前台检查仍在等待时收集的证据,都会在写入边界丢弃。如果 bash 在终端提供方发布其重新取得前台进程组的状态前打印标记,轮询会在普通静默上限之后再保留该候选状态 `handoffGraceMs`,使恰好同时发生的前台交接有机会胜出。因此,继承 `PROMPT_COMMAND` 的交互式子进程无法一直抑制推断空闲就绪直至绝对超时。未知的前台状态绝不会作为精确空闲的正向信号。同样,一次 send 之前就已存在的前台进程组 stdin 等待并不代表写入后就绪:必须先观察到同一进程组脱离该等待,之后再次进入等待才能使该次 send 完成;前台进程组发生变化则构成新的证据。尚未发布的启动过程中,回退路径要求已经观察到输出;零输出静默不能发布空会话,超时则拒绝 spawn。取消操作会关闭尚未发布的 shell,并以调用方提供的确切中止原因拒绝;`TerminalBackendCleanupError` 会单独保留清理失败。调用方的 signal 会转发给终端分配与就绪初始化;发布后,句柄负责其生命周期。未完成的终端控制序列受 `maxReadBytes` 限制;超过上限后,系统会丢弃内容直到其终止符。格式错误的 UTF-8 终端输出使用替换字符;末尾的回车会跨回调保留,使拆分的 CRLF 合并为一个换行。
97
+ ### 就绪模型
14
98
 
15
- 取消发送时,系统会先把排队输入标记为已取消,再要求终端句柄向当前前台进程组发送真正的 `SIGINT`;异步写入前检查即使随后结算,也无法执行该输入。如果提供方写入已在途,信号发送会等待其结算;写入被拒绝时不会发送信号。已取消的 send 会保留其位置,直到写入与前台信号发送都结算,因此后继 send 不会收到延迟字节或该信号。因此,永不结算的提供方写入或信号会无限期保留该位置;恢复手段是关闭会话(`terminal_close`)。取消等待期间,绝对 deadline 仍保持启用。信号发送失败是终端传输失败,会拒绝活跃 send。取消绝不会通过写入 `\x03` 模拟中断,因此,即使程序运行在 raw 模式下,也仍可取消。关闭操作会拒绝新的公开信号、停止就绪轮询,并等待由句柄提供方负责的完整会话终止,然后才把活跃 send 结算为 `session_exit`。
99
+ 三个有界档位结算一次发送:来自子进程提供方的精确 stdin 等待证据(仅 Linux)、带精确可打印尾部的受控私有提示符标记,以及输出静默(`inferred_idle`);绝对超时始终限制等待。pwsh 启动的完整设置循环共用一条 deadline,因此 `inferred_idle` 后续发送不会重新计时。提供方写入前收集的证据会在写入边界丢弃,早于写入的 stdin 等待不算写入后就绪,未知的前台状态绝不是精确空闲的正向信号。
16
100
 
101
+ ### 发送取消与关闭
102
+
103
+ 取消先把排队输入标记为已取消,再在任何在途的提供方写入结算后向当前前台进程组发送真正的 `SIGINT`;它绝不会通过写入 `\x03` 模拟中断。关闭会停止就绪轮询、终止提供方拥有的进程树、等待完全停稳,并把活跃发送结算为 `session_exit`。
104
+
105
+ ### 沙箱模式限制
106
+
107
+ 当所有者存在打开的会话或进行中的 spawn 时,凡是会改变有效沙箱模式的写入都会在 `sandbox/mode` 事件提交前被拒绝。该限制绑定到确切所有者,并在保留现有会话的提供方重新加载后依然有效。
108
+
109
+ </details>
110
+
111
+ -----
112
+
113
+ <a id="further-exploration"></a>
114
+ ## 进一步探索
115
+
116
+ 当包级约定不够用时阅读以下页面。它们从共享终端模型进入服务、工具与执行世界基底。
117
+
118
+ - [终端子系统参考](../../../docs/subsystems/terminal.zh.md)——此后端实现的服务器约定与生成的 `ctx.terminals` 接口面。
119
+ - [terminal 服务](../terminal/README.zh.md)——后端注册、所有者限制与清理语义。
120
+ - [tool-terminal 工具](../tool-terminal/README.zh.md)——操作会话的面向模型工具。
121
+ - [子进程 seam](../../../docs/subsystems/subprocess.zh.md)——负责 PTY 分配与进程树清理的终端原语。
122
+ - [持久 PTY Agent Note](../../../.agents/notes/implemented/feature/2026-07-16-persistent-pty-sessions.zh.md)——能力设计与暂缓边界。
123
+ - [持久 pwsh Agent Note](../../../.agents/notes/implemented/architecture/2026-08-11-pwsh-persistent-pty.zh.md)——Windows 基底与 pwsh 方言。
124
+
125
+ -----
126
+
127
+ <a id="model-experience"></a>
17
128
  ## 模型体验
18
129
 
19
- ### 当前文件策略与间接消费方
130
+ ### 间接消费方
131
+
132
+ #### 模型看到什么
133
+
134
+ 此包不注册提示词或工具。模型通过 `@deepseek-ai/dsh-tool-terminal` 或其他 PTY 消费方可能收到有界的启动输出、发送增量、scrollback 页、就绪原因与清理错误。
135
+
136
+ #### Token 影响
137
+
138
+ 在消费方返回有界输出之前,保留的 PTY scrollback 不会进入模型历史。
139
+
140
+ #### KV Cache 影响
20
141
 
21
- #### 模型看到的内容
142
+ 不会直接失效;消费方结果保持仅追加。
22
143
 
23
- 策略归属方会贡献与具体能力无关的 `sandbox:policy` 上下文。模型通过 `@deepseek-ai/dsh-tool-terminal` 或其他 PTY 消费方还可能收到有界的 MOTD、发送增量、scrollback 页、就绪原因和清理错误。
144
+ ### 沙箱策略上下文
145
+
146
+ #### 模型看到什么
147
+
148
+ 组合此后端期间,`sandbox-policy` 归属方会向提示词贡献与具体能力无关的 `sandbox:policy` 运行时上下文子句。
24
149
 
25
150
  #### Token 影响
26
151
 
27
- 装载该后端期间,当前策略子句会一直存在。消费方返回有界输出前,保留的 PTY scrollback 不会进入模型历史。
152
+ 后端挂载期间,请求中会包含该策略子句。
28
153
 
29
154
  #### KV Cache 影响
30
155
 
31
- 常驻策略发生变化时,会在保留的历史之后追加一份由归属方渲染、取代先前状态的运行时上下文快照;消费方结果保持仅追加。
156
+ 常驻策略发生变化时,会在保留的历史之后追加一份取代先前状态的运行时上下文快照。
157
+
158
+ ## 已知限制与延期工作
159
+
160
+ <a id="known-limitations-and-deferred-work"></a>
161
+
162
+
163
+ 这些限制说明后端何时不合适或需要特别的运维注意。它们是当前包约束,不是通用 shell 对比或任务积压。
164
+
165
+ - **仅逐行输出**——headless xterm 只为终端协议响应维护控制序列状态。返回输出仍按行规范化;不支持全屏备用缓冲区交互。
166
+ - **没有精确档时,就绪是启发式的**——精确 stdin 等待检测取决于已挂载的子进程提供方;无法证明该状态的提供方(macOS、Windows)按提示符标记与静默/超时就绪结算。
167
+ - **受限沙箱中的 pwsh 引导**——提示符函数与 UTF-8 钉通过 `[Console]::` 写入,Windows ACL 沙箱的只读模式可能拒绝。若因此无法获得 marker 就绪,启动会在 `timeoutMs` 到期时拒绝,而不会发布不完整的 shell。
168
+ - **清理保证属于提供方**——进程树清理是 `SubprocessTerminalHandle` 的约定,而不是此后端的。
169
+ - **会话不随进程退出存活**——harness 重启会销毁所有会话。
170
+
171
+ <a id="dev-note"></a>
172
+ ### 开发备注
173
+
174
+ <details>
175
+ <summary>维护者的工作上下文——点击展开</summary>
32
176
 
33
- ## 已知限制与暂缓事项
177
+ 无。
34
178
 
35
- - 输出按行规范化;不支持全屏备用缓冲区交互。
36
- - 精确 stdin 等待检测取决于已挂载的进程管理提供方;无法证明该状态的提供方使用提示符标记和静默/超时就绪机制。Windows 正是这样的提供方:shell pid 是伪前台进程组,没有精确的 stdin-wait 档,因此无标记的子进程按静默上限结算。
37
- - pwsh 引导(UTF-8 编码钉与 `prompt` 函数)通过 `[Console]::` 写入,Windows ACL 沙箱的只读模式(ConstrainedLanguage)可能拒绝它。shell 仍可通过受控可打印提示符和静默档结算,但无法使用 marker 就绪,非 ASCII 输出也可能沿用宿主代码页。
38
- - 清理保证以 `SubprocessTerminalHandle` 的保证为准;提供方特定的缺口属于该实现的约定,而非这个 PTY 消费方。
39
- - harness 进程退出后,会话无法继续存在。
179
+ </details>
package/lib/index.js CHANGED
@@ -1,5 +1,5 @@
1
+ import { createRequire } from "node:module";
1
2
  import { TerminalBackendCleanupError, TerminalError } from "@deepseek-ai/dsh-terminal";
2
- import { effectiveSandboxMode } from "@deepseek-ai/dsh-sandbox-policy";
3
3
  import { ENCODING_PREAMBLE, resolvePwshPath } from "@deepseek-ai/dsh-pwsh-local";
4
4
  import z from "@deepseek-ai/schemastery";
5
5
  import { Buffer } from "node:buffer";
@@ -231,7 +231,8 @@ function normalizeTerminalText(text) {
231
231
  }
232
232
  //#endregion
233
233
  //#region lib/types/session.js
234
- /** Persistent PTY session over the subprocess seam's terminal primitive. */
234
+ /** Persistent PTY session with bounded output, readiness, and terminal-protocol replies. */
235
+ const { Terminal: HeadlessTerminal } = createRequire(import.meta.url)("@xterm/headless");
235
236
  function utf8Tail(text, maxBytes) {
236
237
  if (Buffer.byteLength(text) <= maxBytes) return {
237
238
  text,
@@ -361,6 +362,9 @@ var LocalPtySession = class {
361
362
  motd = "";
362
363
  pid;
363
364
  decoder = new TextDecoder();
365
+ /** Protocol state only; the sanitizer and bounded buffers own returned text. */
366
+ emulator;
367
+ emulatorData;
364
368
  sanitizer;
365
369
  scrollback;
366
370
  outputEnded = Promise.withResolvers();
@@ -383,10 +387,34 @@ var LocalPtySession = class {
383
387
  closing = false;
384
388
  closePromise;
385
389
  transportFailure;
390
+ emulatorWrites = Promise.resolve();
391
+ emulatorWriteDone;
392
+ emulatorBuffer = "";
393
+ emulatorWriting = false;
394
+ responseWrites = Promise.resolve();
395
+ pendingResponseWrites = 0;
396
+ emulatorClosed = false;
386
397
  constructor(terminal, config) {
387
398
  this.terminal = terminal;
388
399
  this.config = config;
389
400
  this.pid = terminal.pid;
401
+ this.emulator = new HeadlessTerminal({
402
+ cols: config.cols,
403
+ rows: config.rows,
404
+ scrollback: 0
405
+ });
406
+ this.emulatorData = this.emulator.onData((data) => {
407
+ this.pendingResponseWrites += 1;
408
+ const response = this.responseWrites.then(async () => {
409
+ await this.terminal.write(data);
410
+ });
411
+ this.responseWrites = response.then(() => {
412
+ this.finishResponseWrite();
413
+ }, (error) => {
414
+ this.finishResponseWrite();
415
+ if (!this.emulatorClosed && !this.closing) this.onTransportFailure(error);
416
+ });
417
+ });
390
418
  this.sanitizer = new TerminalSanitizer(config.maxReadBytes);
391
419
  this.scrollback = new BoundedTextBuffer(config.scrollbackMaxBytes, config.scrollbackLines);
392
420
  terminal.output.on("data", this.onTerminalData);
@@ -437,7 +465,7 @@ var LocalPtySession = class {
437
465
  this.activeAbort = () => request.signal?.removeEventListener("abort", onAbort);
438
466
  }
439
467
  this.activeDeadlineTimer = setTimeout(() => {
440
- if (this.active === operation) this.settleActive("timeout", this.activeWrite !== void 0 || this.interrupting === operation);
468
+ if (this.active === operation) this.settleActive("timeout", this.activeWrite !== void 0 || this.interrupting === operation || this.protocolWorkPending());
441
469
  }, this.config.timeoutMs);
442
470
  this.beginSend(operation, request);
443
471
  return operation;
@@ -445,8 +473,13 @@ var LocalPtySession = class {
445
473
  async beginSend(operation, request) {
446
474
  let foreground;
447
475
  try {
476
+ if (this.protocolWorkPending()) await this.drainTerminalProtocol();
477
+ const emulatorWrites = this.emulatorWrites;
478
+ const responseWrites = this.responseWrites;
448
479
  foreground = await this.terminal.inspectForeground();
480
+ if (this.protocolStateChanged(emulatorWrites, responseWrites)) foreground = await this.inspectForegroundAfterProtocol();
449
481
  } catch (error) {
482
+ if (this.protocolWorkPending()) await this.drainTerminalProtocol();
450
483
  if (this.active === operation && !this.closing && this.interrupting !== operation) this.failActive(error);
451
484
  return;
452
485
  }
@@ -466,7 +499,7 @@ var LocalPtySession = class {
466
499
  }
467
500
  if (operation.cancelRequested) return;
468
501
  if (this.active === operation && operation.settled) {
469
- this.clearActive();
502
+ this.releaseSettledActive();
470
503
  return;
471
504
  }
472
505
  if (this.active === operation && !this.closing) {
@@ -474,7 +507,7 @@ var LocalPtySession = class {
474
507
  this.schedulePoll(operation);
475
508
  }
476
509
  } catch (error) {
477
- if (this.active === operation && !this.closing) if (operation.settled) this.clearActive();
510
+ if (this.active === operation && !this.closing) if (operation.settled) this.releaseSettledActive();
478
511
  else this.failActive(error);
479
512
  }
480
513
  }
@@ -534,14 +567,18 @@ var LocalPtySession = class {
534
567
  }
535
568
  onTerminalData = (chunk) => {
536
569
  const bytes = typeof chunk === "string" ? Buffer.from(chunk, "utf8") : chunk;
537
- this.onData(this.decoder.decode(bytes, { stream: true }));
570
+ const data = this.decoder.decode(bytes, { stream: true });
571
+ this.queueEmulatorData(data);
572
+ this.onData(data);
538
573
  };
539
574
  onTerminalEnd = () => {
540
575
  this.onData(this.decoder.decode());
541
576
  this.appendOutput(this.sanitizer.flush());
577
+ this.closeEmulator();
542
578
  this.outputEnded.resolve();
543
579
  };
544
580
  onTerminalError = (error) => {
581
+ this.closeEmulator();
545
582
  this.onTransportFailure(error);
546
583
  this.outputEnded.resolve();
547
584
  };
@@ -578,6 +615,7 @@ var LocalPtySession = class {
578
615
  exitCode: null,
579
616
  signal: null
580
617
  };
618
+ this.closeEmulator();
581
619
  this.failActive(failure);
582
620
  this.terminal.terminate().catch(() => {});
583
621
  }
@@ -603,7 +641,11 @@ var LocalPtySession = class {
603
641
  this.settleActive("session_exit");
604
642
  return;
605
643
  }
606
- const foreground = await this.terminal.inspectForeground();
644
+ if (this.protocolWorkPending()) await this.drainTerminalProtocol();
645
+ const emulatorWrites = this.emulatorWrites;
646
+ const responseWrites = this.responseWrites;
647
+ let foreground = await this.terminal.inspectForeground();
648
+ if (this.protocolStateChanged(emulatorWrites, responseWrites)) foreground = await this.inspectForegroundAfterProtocol();
607
649
  if (this.active !== operation || this.closing || this.interrupting === operation) return;
608
650
  const idleFor = Date.now() - this.lastOutputAt;
609
651
  if (this.promptSeen && foreground !== void 0 && this.shellPgid === void 0) this.shellPgid = foreground.processGroupId;
@@ -621,6 +663,7 @@ var LocalPtySession = class {
621
663
  const handoffGrace = this.promptSeen ? this.config.handoffGraceMs : 0;
622
664
  if (startupHasOutput && idleFor >= this.config.idleSilenceMs + handoffGrace) this.settleActive("inferred_idle");
623
665
  } catch (error) {
666
+ if (this.protocolWorkPending()) await this.drainTerminalProtocol();
624
667
  if (this.active === operation && !this.closing && this.interrupting !== operation) this.failActive(error);
625
668
  } finally {
626
669
  this.polling = false;
@@ -628,6 +671,91 @@ var LocalPtySession = class {
628
671
  if (active !== void 0 && this.pollingReady === active) this.schedulePoll(active);
629
672
  }
630
673
  }
674
+ /** Wait until generated replies reach the provider before another send can publish. */
675
+ async drainTerminalProtocol() {
676
+ for (;;) {
677
+ const emulatorWrites = this.emulatorWrites;
678
+ await emulatorWrites;
679
+ const responseWrites = this.responseWrites;
680
+ await responseWrites;
681
+ if (emulatorWrites === this.emulatorWrites && responseWrites === this.responseWrites && !this.protocolWorkPending()) return;
682
+ }
683
+ }
684
+ /** Sample foreground state only after protocol replies are quiet for the entire inspection. */
685
+ async inspectForegroundAfterProtocol() {
686
+ for (;;) {
687
+ if (this.protocolWorkPending()) await this.drainTerminalProtocol();
688
+ const emulatorWrites = this.emulatorWrites;
689
+ const responseWrites = this.responseWrites;
690
+ const foreground = await this.terminal.inspectForeground();
691
+ if (!this.protocolStateChanged(emulatorWrites, responseWrites)) return foreground;
692
+ }
693
+ }
694
+ protocolStateChanged(emulatorWrites, responseWrites) {
695
+ return emulatorWrites !== this.emulatorWrites || responseWrites !== this.responseWrites || this.protocolWorkPending();
696
+ }
697
+ protocolWorkPending() {
698
+ return this.emulatorWriteDone !== void 0 || this.pendingResponseWrites > 0;
699
+ }
700
+ queueEmulatorData(data) {
701
+ if (this.emulatorClosed) return;
702
+ this.emulatorBuffer += data;
703
+ if (this.emulatorWriteDone === void 0) {
704
+ const idle = Promise.withResolvers();
705
+ this.emulatorWrites = idle.promise;
706
+ this.emulatorWriteDone = () => {
707
+ idle.resolve(void 0);
708
+ };
709
+ }
710
+ this.pumpEmulator();
711
+ }
712
+ pumpEmulator() {
713
+ if (this.emulatorWriting || this.emulatorClosed) return;
714
+ if (this.emulatorBuffer.length === 0) {
715
+ const done = this.emulatorWriteDone;
716
+ this.emulatorWriteDone = void 0;
717
+ done?.();
718
+ this.releaseSettledActive();
719
+ return;
720
+ }
721
+ const data = this.emulatorBuffer;
722
+ this.emulatorBuffer = "";
723
+ this.emulatorWriting = true;
724
+ try {
725
+ this.emulator.write(data, () => {
726
+ this.emulatorWriting = false;
727
+ this.pumpEmulator();
728
+ });
729
+ } catch (error) {
730
+ this.emulatorWriting = false;
731
+ this.emulatorBuffer = "";
732
+ const done = this.emulatorWriteDone;
733
+ this.emulatorWriteDone = void 0;
734
+ done?.();
735
+ this.releaseSettledActive();
736
+ if (!this.closing) this.onTransportFailure(error);
737
+ }
738
+ }
739
+ finishResponseWrite() {
740
+ this.pendingResponseWrites -= 1;
741
+ this.releaseSettledActive();
742
+ }
743
+ releaseSettledActive() {
744
+ const operation = this.active;
745
+ if (operation === void 0 || !operation.settled || this.activeWrite !== void 0 || this.interrupting === operation || this.protocolWorkPending()) return;
746
+ this.clearActive();
747
+ }
748
+ closeEmulator() {
749
+ if (this.emulatorClosed) return;
750
+ this.emulatorClosed = true;
751
+ this.emulatorBuffer = "";
752
+ this.emulatorWriting = false;
753
+ const done = this.emulatorWriteDone;
754
+ this.emulatorWriteDone = void 0;
755
+ done?.();
756
+ this.emulatorData.dispose();
757
+ this.emulator.dispose();
758
+ }
631
759
  settleActive(waitReason, retainOwnership = false) {
632
760
  const operation = this.active;
633
761
  if (operation === void 0) return;
@@ -681,7 +809,7 @@ var LocalPtySession = class {
681
809
  } finally {
682
810
  if (this.interrupting === operation) this.interrupting = void 0;
683
811
  }
684
- if (this.active === operation && operation.settled) this.clearActive();
812
+ if (this.active === operation && operation.settled) this.releaseSettledActive();
685
813
  else if (this.active === operation && !this.closing) {
686
814
  this.pollingReady = operation;
687
815
  this.schedulePoll(operation, 0);
@@ -689,6 +817,7 @@ var LocalPtySession = class {
689
817
  }
690
818
  async closeOnce(reason) {
691
819
  this.stopPolling();
820
+ this.closeEmulator();
692
821
  try {
693
822
  await this.terminal.terminate();
694
823
  } catch (error) {
@@ -711,10 +840,11 @@ var LocalPtySession = class {
711
840
  */
712
841
  /** Cordis plugin name. */
713
842
  const name = "terminal-bash";
714
- /** Required services: PTY registry, shared confinement policy, and process substrate. */
843
+ /** Required services: terminal registry, shared confinement policy, projection registry, and process substrate. */
715
844
  const inject = [
716
845
  "terminals",
717
846
  "sandboxPolicy",
847
+ "sessionProjections",
718
848
  "subprocess"
719
849
  ];
720
850
  const sandboxModeFences = /* @__PURE__ */ new WeakMap();
@@ -723,18 +853,20 @@ function ensureSandboxModeFence(ctx, owner) {
723
853
  if (existing !== void 0) {
724
854
  existing.pty = ctx.terminals;
725
855
  existing.sandboxPolicy = ctx.sandboxPolicy;
856
+ existing.sessionProjections = ctx.sessionProjections;
726
857
  return;
727
858
  }
728
859
  const state = {
729
860
  pty: ctx.terminals,
730
- sandboxPolicy: ctx.sandboxPolicy
861
+ sandboxPolicy: ctx.sandboxPolicy,
862
+ sessionProjections: ctx.sessionProjections
731
863
  };
732
864
  sandboxModeFences.set(owner, state);
733
865
  owner.ctx.on("internal/dispatch", (_mode, eventName, args) => {
734
866
  if (eventName !== "session/event") return;
735
867
  const [session, event] = args;
736
868
  if (session !== owner.session || event.type !== "sandbox/mode") return;
737
- const currentMode = effectiveSandboxMode(session.events) ?? state.sandboxPolicy.defaultMode;
869
+ const currentMode = state.sessionProjections.stateOf(session, "sandboxMode") ?? null ?? state.sandboxPolicy.defaultMode;
738
870
  if (event.data.mode === currentMode || !state.pty.hasOwnerActivity(owner)) return;
739
871
  throw new Error(`cannot change sandbox mode from "${currentMode}" to "${event.data.mode}" while persistent terminal sessions are open or being created; wait for creation to settle and close them first`);
740
872
  }, { global: true });
@@ -776,7 +908,8 @@ function spawnArgv(ctx, config, policy) {
776
908
  mode: policy.mode
777
909
  }).argv;
778
910
  }
779
- async function startupSession(session, dialect, signal) {
911
+ async function startupSession(session, dialect, timeoutMs, signal) {
912
+ let startupOperation;
780
913
  const start = async () => {
781
914
  if (dialect === "bash") {
782
915
  await session.initialize(signal);
@@ -785,36 +918,44 @@ async function startupSession(session, dialect, signal) {
785
918
  let viewport = "";
786
919
  for (;;) {
787
920
  const first = viewport.length === 0;
788
- const result = await session.startSend({
921
+ startupOperation = session.startSend({
789
922
  text: first ? ENCODING_PREAMBLE + PWSH_PROMPT_SETUP : "",
790
923
  submit: first,
791
924
  ...signal !== void 0 ? { signal } : {}
792
- }).done;
925
+ });
926
+ const result = await startupOperation.done;
793
927
  if (result.waitReason === "session_exit") throw new Error("PTY shell exited during startup");
794
928
  if (result.waitReason === "timeout") throw new Error("PTY shell did not reach readiness before startup timeout");
795
929
  viewport = result.viewport;
796
- const scrollback = session.read({
797
- offset: 0,
798
- count: 20
799
- }).text;
800
- if (viewport.includes("dsh> ") || scrollback.includes("dsh> ")) break;
930
+ if (result.waitReason === "stdin_read") break;
801
931
  }
802
932
  session.motd = viewport;
803
933
  };
804
- if (signal === void 0) {
805
- await start();
806
- return;
934
+ const races = [];
935
+ let onAbort;
936
+ if (signal !== void 0) {
937
+ const aborted = Promise.withResolvers();
938
+ onAbort = () => {
939
+ aborted.reject(signal.reason);
940
+ };
941
+ signal.addEventListener("abort", onAbort, { once: true });
942
+ races.push(aborted.promise);
943
+ }
944
+ let deadlineTimer;
945
+ if (dialect === "pwsh") {
946
+ const deadline = Promise.withResolvers();
947
+ deadlineTimer = setTimeout(() => {
948
+ startupOperation?.cancel();
949
+ deadline.reject(/* @__PURE__ */ new Error("PTY shell did not reach readiness before startup timeout"));
950
+ }, timeoutMs);
951
+ races.push(deadline.promise);
807
952
  }
808
- const aborted = Promise.withResolvers();
809
- const onAbort = () => {
810
- aborted.reject(signal.reason);
811
- };
812
- signal.addEventListener("abort", onAbort, { once: true });
813
953
  try {
814
- signal.throwIfAborted();
815
- await Promise.race([start(), aborted.promise]);
954
+ signal?.throwIfAborted();
955
+ await Promise.race([start(), ...races]);
816
956
  } finally {
817
- signal.removeEventListener("abort", onAbort);
957
+ if (deadlineTimer !== void 0) clearTimeout(deadlineTimer);
958
+ if (signal !== void 0 && onAbort !== void 0) signal.removeEventListener("abort", onAbort);
818
959
  }
819
960
  }
820
961
  /** Local shell backend registered under the configured type. */
@@ -848,7 +989,7 @@ var BashTerminalBackend = class {
848
989
  });
849
990
  const session = this.createSession(terminal, this.config);
850
991
  try {
851
- await startupSession(session, this.config.shellDialect, spec.signal);
992
+ await startupSession(session, this.config.shellDialect, this.config.timeoutMs, spec.signal);
852
993
  return session;
853
994
  } catch (error) {
854
995
  try {
@@ -33,7 +33,7 @@ export interface Config {
33
33
  * regain the foreground before `inferred_idle` settles; at least one `pollIntervalMs`.
34
34
  */
35
35
  handoffGraceMs?: number;
36
- /** Absolute send wait bound. */
36
+ /** Absolute bound for one send and the complete pwsh startup sequence. */
37
37
  timeoutMs?: number;
38
38
  /** Grace before teardown escalates to `SIGKILL`. */
39
39
  disposeGraceMs?: number;
@@ -12,7 +12,7 @@ export { Config } from './config.ts';
12
12
  export type { Config as TerminalLocalConfig } from './config.ts';
13
13
  /** Cordis plugin name. */
14
14
  export declare const name = "terminal-bash";
15
- /** Required services: PTY registry, shared confinement policy, and process substrate. */
15
+ /** Required services: terminal registry, shared confinement policy, projection registry, and process substrate. */
16
16
  export declare const inject: string[];
17
17
  /**
18
18
  * The pwsh prompt function that emits the shared OSC `133;D;` + BEL marker
@@ -1,4 +1,4 @@
1
- /** Persistent PTY session over the subprocess seam's terminal primitive. */
1
+ /** Persistent PTY session with bounded output, readiness, and terminal-protocol replies. */
2
2
  import type { SubprocessTerminalHandle } from '@deepseek-ai/dsh-subprocess';
3
3
  import type { TerminalBackendSession, TerminalReadRequest, TerminalReadResult, TerminalSendOperation, TerminalSendRequest, TerminalSessionStatus, TerminalSignal, TerminalSignalResult } from '@deepseek-ai/dsh-terminal';
4
4
  import type { ResolvedConfig } from './config.ts';
@@ -9,6 +9,9 @@ export declare class LocalPtySession implements TerminalBackendSession {
9
9
  motd: string;
10
10
  readonly pid: number;
11
11
  private readonly decoder;
12
+ /** Protocol state only; the sanitizer and bounded buffers own returned text. */
13
+ private readonly emulator;
14
+ private readonly emulatorData;
12
15
  private readonly sanitizer;
13
16
  private readonly scrollback;
14
17
  private readonly outputEnded;
@@ -31,6 +34,13 @@ export declare class LocalPtySession implements TerminalBackendSession {
31
34
  private closing;
32
35
  private closePromise;
33
36
  private transportFailure;
37
+ private emulatorWrites;
38
+ private emulatorWriteDone;
39
+ private emulatorBuffer;
40
+ private emulatorWriting;
41
+ private responseWrites;
42
+ private pendingResponseWrites;
43
+ private emulatorClosed;
34
44
  constructor(terminal: SubprocessTerminalHandle, config: ResolvedConfig);
35
45
  /**
36
46
  * Capture startup output through the same readiness contract as later sends.
@@ -54,6 +64,17 @@ export declare class LocalPtySession implements TerminalBackendSession {
54
64
  private appendOutput;
55
65
  private schedulePoll;
56
66
  private pollReadiness;
67
+ /** Wait until generated replies reach the provider before another send can publish. */
68
+ private drainTerminalProtocol;
69
+ /** Sample foreground state only after protocol replies are quiet for the entire inspection. */
70
+ private inspectForegroundAfterProtocol;
71
+ private protocolStateChanged;
72
+ private protocolWorkPending;
73
+ private queueEmulatorData;
74
+ private pumpEmulator;
75
+ private finishResponseWrite;
76
+ private releaseSettledActive;
77
+ private closeEmulator;
57
78
  private settleActive;
58
79
  private stopPolling;
59
80
  private stopReadinessPolling;
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@deepseek-ai/dsh-terminal-bash",
3
3
  "description": "Persistent shell PTY backend over the DeepSeek Harness subprocess terminal primitive",
4
- "version": "0.1.1-rc.2",
4
+ "version": "0.1.2-alpha.2",
5
5
  "publishConfig": {
6
6
  "access": "public"
7
7
  },
@@ -32,28 +32,30 @@
32
32
  ],
33
33
  "license": "MIT",
34
34
  "peerDependencies": {
35
- "@deepseek-ai/dsh-agent": "^0.1.1-rc.2",
36
- "@deepseek-ai/dsh-invariants": "^0.1.1-rc.2",
37
- "@deepseek-ai/dsh-terminal": "^0.1.1-rc.2",
38
- "@deepseek-ai/dsh-sandbox": "^0.1.1-rc.2",
39
- "@deepseek-ai/dsh-session": "^0.1.1-rc.2",
40
- "@deepseek-ai/dsh-subprocess": "^0.1.1-rc.2",
41
- "@deepseek-ai/cordis": "^4.0.1",
42
- "@deepseek-ai/dsh-sandbox-policy": "^0.1.1-rc.2"
35
+ "@deepseek-ai/dsh-agent": "^0.1.2-alpha.2",
36
+ "@deepseek-ai/dsh-terminal": "^0.1.2-alpha.2",
37
+ "@deepseek-ai/dsh-sandbox": "^0.1.2-alpha.2",
38
+ "@deepseek-ai/dsh-sandbox-policy": "^0.1.2-alpha.2",
39
+ "@deepseek-ai/dsh-session": "^0.1.2-alpha.2",
40
+ "@deepseek-ai/dsh-invariants": "^0.1.2-alpha.2",
41
+ "@deepseek-ai/dsh-subprocess": "^0.1.2-alpha.2",
42
+ "@deepseek-ai/cordis": "^4.0.2",
43
+ "@deepseek-ai/dsh-session-projection": "^0.1.2-alpha.2"
43
44
  },
44
45
  "dependencies": {
45
- "@deepseek-ai/dsh-pwsh-local": "^0.1.1-rc.2",
46
- "@deepseek-ai/schemastery": "^3.18.1"
46
+ "@xterm/headless": "^6.0.0",
47
+ "@deepseek-ai/dsh-pwsh-local": "^0.1.2-alpha.2",
48
+ "@deepseek-ai/schemastery": "^3.18.2"
47
49
  },
48
50
  "devDependencies": {
49
- "@deepseek-ai/dsh-agent": "^0.1.1-rc.2",
50
- "@deepseek-ai/dsh-invariants": "^0.1.1-rc.2",
51
- "@deepseek-ai/dsh-terminal": "^0.1.1-rc.2",
52
- "@deepseek-ai/dsh-sandbox": "^0.1.1-rc.2",
53
- "@deepseek-ai/dsh-sandbox-policy": "^0.1.1-rc.2",
54
- "@deepseek-ai/dsh-session": "^0.1.1-rc.2",
55
- "@deepseek-ai/dsh-subprocess": "^0.1.1-rc.2",
56
- "@deepseek-ai/dsh-subprocess-local": "^0.1.1-rc.2",
57
- "@deepseek-ai/cordis": "^4.0.1"
51
+ "@deepseek-ai/dsh-agent": "^0.1.2-alpha.2",
52
+ "@deepseek-ai/dsh-invariants": "^0.1.2-alpha.2",
53
+ "@deepseek-ai/dsh-terminal": "^0.1.2-alpha.2",
54
+ "@deepseek-ai/dsh-session": "^0.1.2-alpha.2",
55
+ "@deepseek-ai/dsh-subprocess": "^0.1.2-alpha.2",
56
+ "@deepseek-ai/dsh-sandbox-policy": "^0.1.2-alpha.2",
57
+ "@deepseek-ai/dsh-subprocess-local": "^0.1.2-alpha.2",
58
+ "@deepseek-ai/cordis": "^4.0.2",
59
+ "@deepseek-ai/dsh-sandbox": "^0.1.2-alpha.2"
58
60
  }
59
61
  }