Procházet zdrojové kódy

fix(terminal-bash): handle terminal protocol replies

Unix PowerShell emits cursor-position requests while PSReadLine starts and
redraws prompts. The subprocess PTY is only a transport, so those requests
went unanswered. Startup could then accept the dsh> literal echoed from its
setup source as a rendered prompt, and later sends were lost or clipped.

Feed raw PTY output into a zero-scrollback @xterm/headless state machine and
write generated replies through the provider-owned terminal handle. Drain
replies before caller input, repeat foreground inspection when terminal
activity races the sample, and retain send ownership until parser and reply
work quiesce. Coalesce raw chunks behind one active parser write so large
Windows output cannot create thousands of queued parse callbacks.

Publish pwsh only from backend stdin_read evidence and start one timeoutMs
deadline before the complete startup retry loop, so inferred-idle follow-ups
cannot reset the bound. Dispose the emulator when the
terminal or cleanup fails. Document the fail-loud ConstrainedLanguage path
and add focused coverage for split queries, reply ordering, foreground
resampling, failure containment, batching, timeout, and disposal.
Tianyi Cui před 1 měsícem
rodič
revize
4f3a47d792

+ 2 - 2
.agents/notes/implemented/architecture/2026-08-11-pwsh-persistent-pty.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-08-11-pwsh-persistent-pty.md
-2026-08-11-pwsh-persistent-pty.md: 8353b3ab3cdbf20add22a55acb03312c94283602
-2026-08-11-pwsh-persistent-pty.zh.md: 95048a02416dfcf5f0ef2837d99a561008f6496f
+2026-08-11-pwsh-persistent-pty.md: 4c523d3c7c45e6d86942868df92b981576e76859
+2026-08-11-pwsh-persistent-pty.zh.md: 4f87490fd60ecc37ad9390e0ce990173bbafc3b8

+ 6 - 3
.agents/notes/implemented/architecture/2026-08-11-pwsh-persistent-pty.md

@@ -24,7 +24,7 @@ A model-facing persistent `pwsh` tool ships on Windows with the same contract as
 
 ### Shell dialect in `@deepseek-ai/dsh-terminal-bash`
 
-One backend, two dialects: `shellDialect: 'bash' | 'pwsh'` (default `'bash'`, existing deployments byte-identical). The effective `shellPath`/`shellArgs` resolve per dialect (bash `/bin/bash --noprofile --norc -i`; pwsh through the shared `dsh-pwsh-local` resolver with `-NoLogo -NoProfile`, keeping the interactive host for child REPLs). The child environment drops the bash-only `PS1`/`PROMPT_COMMAND` markers and adds `NO_COLOR` for pwsh. pwsh cannot install its prompt from the environment, so the backend writes the prompt function through the session at startup 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; a `session_exit` or `timeout` wait rejects the spawn. Both dialects emit the same BEL-terminated OSC `133;D;` marker, so the sanitizer, `PROMPT_MARKER_PREFIX`, `CONTROLLED_PROMPT`, and the exact-tail readiness logic are reused untouched — the marker stays a readiness signal with an unconsumed payload, exactly as in the bash path, and no model-notification channel was added (aligned with the current implementation; the deferred BEL event channel stays deferred).
+One backend, two dialects: `shellDialect: 'bash' | 'pwsh'` (default `'bash'`; the bash argv and environment defaults remain unchanged). The effective `shellPath`/`shellArgs` resolve per dialect (bash `/bin/bash --noprofile --norc -i`; pwsh through the shared `dsh-pwsh-local` resolver with `-NoLogo -NoProfile`, keeping the interactive host for child REPLs). The child environment drops the bash-only `PS1`/`PROMPT_COMMAND` markers and adds `NO_COLOR` for pwsh. pwsh cannot install its prompt from the environment, so the backend writes the prompt function through the session at startup and accepts only the backend's `stdin_read` result; a printable prompt literal in echoed setup input is not readiness. One `timeoutMs` deadline owns the complete startup retry loop, so `inferred_idle` follow-up sends cannot restart the bound. A zero-scrollback `@xterm/headless` instance consumes raw PTY data and emits terminal-protocol replies through `SubprocessTerminalHandle`; the backend drains those writes before caller input and accepts foreground state only when protocol work stayed quiet throughout inspection, so a caller's input cannot be consumed as a cursor-position response. One parser write stays active while later raw chunks coalesce into the next batch, preventing high-volume output from creating one scheduled parse per chunk. The existing sanitizer and bounded buffers remain the output projection. Both dialects emit the same BEL-terminated OSC `133;D;` marker, so `PROMPT_MARKER_PREFIX`, `CONTROLLED_PROMPT`, and the exact-tail readiness logic stay shared — the marker remains a readiness signal with an unconsumed payload, and the deferred BEL event channel stays deferred.
 
 ### `@deepseek-ai/dsh-tool-pwsh-persistent`
 
@@ -38,7 +38,7 @@ The minimal preset gates its persistent shell stack by platform with the #2234 `
 
 ### Testing
 
-The Windows test surface follows master's exemption structure: terminal-bash and subprocess-local tests stay excluded on win32 (`windowsUnsupportedTests`) and their sources stay coverage-exempt there (`windowsUnsupportedCoveragePackages`), so the platform-gated fixtures and node-translated commands remain the win32 dev-lane evidence, while the koffi-backed inspector joins the windows-only coverage exclusions on Linux. `tool-pwsh-persistent` is not exempt: its suite runs and its sources are coverage-required on the windows-native lane, mirroring `tool-bash-persistent`'s stub-mode matrix plus an echo-stripping mode; the real-pwsh suites prove persistent cwd/env, secret scrubbing, multiline and here-string commands, large-output clipping, and exit/reset over real ConPTY sessions. The ACP keyless snapshot boots the persistent tool through a real Loader composition and pins its model-visible schema and result.
+The Windows test surface follows master's exemption structure: terminal-bash and subprocess-local tests stay excluded on win32 (`windowsUnsupportedTests`) and their sources stay coverage-exempt there (`windowsUnsupportedCoveragePackages`), so the platform-gated fixtures and node-translated commands remain the win32 dev-lane evidence, while the koffi-backed inspector joins the windows-only coverage exclusions on Linux. `tool-pwsh-persistent` is not exempt: its suite runs and its sources are coverage-required on the windows-native lane, mirroring `tool-bash-persistent`'s stub-mode matrix plus an echo-stripping mode. The session suite pins split cursor-position queries, response-write ordering, and parse batching without a real shell; real-pwsh suites on macOS and Windows prove persistent cwd/env, secret scrubbing, UTF-8 output, multiline and here-string commands, large-output clipping, and exit/reset. The ACP keyless snapshot boots the persistent tool through a real Loader composition and pins its model-visible schema and result.
 
 ## Alternatives considered
 
@@ -46,6 +46,7 @@ The Windows test surface follows master's exemption structure: terminal-bash and
 - **tasklist or wmic polling for the process tree.** Rejected: `inspectForeground` runs on every readiness poll (~50 ms), so a spawned probe per tick is untenable, and wmic is removed from current Windows releases. koffi + Toolhelp32 is in-process and cheap.
 - **A native helper or `GenerateConsoleCtrlEvent` for SIGINT.** Rejected: writing `\x03` to ConPTY input interrupts running commands (verified) with zero new code. The semantic difference — at a prompt, `\x03` cancels the pending line instead of signalling a process — is documented rather than engineered around.
 - **Base64 body encoding for the wrapper.** Rejected: decoding needs `[Convert]`/`[System.Text.Encoding]` calls whose ConstrainedLanguage status is unproven, while backtick-escaped double-quoted strings use only language-level constructs and were verified end-to-end.
+- **Hand-written cursor-position replies.** Rejected: the response must reflect cursor movement, wrapping, and control sequences already emitted by the shell. Fixed coordinates amplify console redraws and can exhaust bounded output; `@xterm/headless` maintains that protocol state without replacing the line-oriented output projection.
 - **Tolerating the echo without stripping the wrapper.** Rejected: in complete and prompt-settled paths the echo is naturally excluded, but timeout and lost-START fallbacks would leak the wrapper source (including marker nonces) into model-visible text.
 - **Resurrecting a BEL model-notification channel.** Rejected: the current implementation consumes no marker payload and delivers no BEL events; the design aligns with the current implementation and keeps the deferred item deferred.
 - **Windows PowerShell 5.1 as a first-class target.** Rejected: pwsh 7 (including the Store install) is the target; `resolvePwshPath` keeps 5.1 as the last-resort executable fallback without promising full persistent-shell behavior on it.
@@ -62,4 +63,6 @@ The Windows test surface follows master's exemption structure: terminal-bash and
 
 **Input echo is an accepted platform fact.** PSReadLine echoes submitted input; the marker-anchored extraction and wrapper-source strip remove it in complete results, with bounded residual in partial-output fallbacks.
 
-**Risks carried.** Under the Windows ACL sandbox's read-only mode, ConstrainedLanguage may deny the bootstrap's `[Console]::` encoding pin and prompt marker; commands then settle through the printable prompt and silence tier, while non-ASCII output may follow the host code page. A model redefinition of the `prompt` function likewise degrades readiness to the silence tier. Raw ESC characters in model commands are unsupported (PSReadLine consumes them). koffi is now a dependency of the process substrate, carrying the same install/prebuild review the sandbox package already has.
+**Terminal protocol replies precede caller input.** The headless emulator retains no scrollback and contributes no model-visible text; it tracks terminal control state and emits replies through the mounted subprocess provider. This adds the maintained `@xterm/headless` runtime dependency and prevents a cursor query from consuming a later tool command.
+
+**Risks carried.** Under the Windows ACL sandbox's read-only mode, ConstrainedLanguage may deny the bootstrap's `[Console]::` encoding pin and prompt marker; if marker readiness remains unavailable, startup rejects at `timeoutMs` instead of publishing a shell whose setup did not complete. A later model redefinition of the `prompt` function degrades command readiness to the silence tier. Raw ESC characters in model commands are unsupported (PSReadLine consumes them). koffi and `@xterm/headless` add process-substrate and terminal-backend dependency review respectively.

+ 6 - 3
.agents/notes/implemented/architecture/2026-08-11-pwsh-persistent-pty.zh.md

@@ -24,7 +24,7 @@ harness 在 Windows 上没有持久 shell。持久 `bash` 栈按构造就是 POS
 
 ### `@deepseek-ai/dsh-terminal-bash` 的 shell 方言
 
-一个 backend、两种方言:`shellDialect: 'bash' | 'pwsh'`(默认 `'bash'`,存量部署逐字节不变)。有效 `shellPath`/`shellArgs` 按方言解析(bash `/bin/bash --noprofile --norc -i`;pwsh 经共享的 `dsh-pwsh-local` 解析器取 `-NoLogo -NoProfile`,保留交互宿主供子 REPL)。子环境去掉 bash 专属 `PS1`/`PROMPT_COMMAND` 标记并为 pwsh 加 `NO_COLOR`。pwsh 无法从环境安装提示符,因此 backend 在启动时通过会话写入 prompt 函数,并等待受控提示符真正可见——因为 pwsh 从横幅到提示符的间隙可能超过静默上限,所以会在后续 send 上循环等待;`session_exit` 或 `timeout` 结算拒绝 spawn。两种方言发出相同的 BEL 终结 OSC `133;D;` 标记,因此 sanitizer、`PROMPT_MARKER_PREFIX`、`CONTROLLED_PROMPT` 与精确尾部就绪逻辑原样复用——标记仍只是就绪信号、载荷不被消费,与 bash 路径完全一致,且没有新增模型通知通道(与当前实现对齐;延后的 BEL 事件通道保持延后)。
+一个 backend、两种方言:`shellDialect: 'bash' | 'pwsh'`(默认 `'bash'`;bash 的 argv 和环境默认值保持不变)。有效 `shellPath`/`shellArgs` 按方言解析(bash `/bin/bash --noprofile --norc -i`;pwsh 经共享的 `dsh-pwsh-local` 解析器取 `-NoLogo -NoProfile`,保留交互宿主供子 REPL)。子环境去掉 bash 专属 `PS1`/`PROMPT_COMMAND` 标记并为 pwsh 加 `NO_COLOR`。pwsh 无法从环境安装提示符,因此 backend 在启动时通过会话写入 prompt 函数,并且只接受 backend 的 `stdin_read` 结果;回显引导输入中的可打印提示符字面量不代表就绪。一条 `timeoutMs` 绝对超时计时器负责限制完整启动重试循环,因此 `inferred_idle` 后续 send 无法重新计时。一个不保留 scrollback 的 `@xterm/headless` 实例会消费原始 PTY 数据,并通过 `SubprocessTerminalHandle` 发出终端协议响应;backend 会在调用方输入前排空这些写入,并且只接受协议工作在整次检查期间保持静止时的前台状态,因此调用方输入不会被当作光标位置响应而消费。一个 parser 写入保持活跃,随后到达的原始 chunk 会合并为下一批,从而避免高输出量为每个 chunk 分别调度解析。现有 sanitizer 与有界缓冲区仍负责输出投影。两种方言发出相同的 BEL 终结 OSC `133;D;` 标记,因此 `PROMPT_MARKER_PREFIX`、`CONTROLLED_PROMPT` 与精确尾部就绪逻辑保持共享——标记仍是载荷不被消费的就绪信号,延后的 BEL 事件通道也继续保持延后。
 
 ### `@deepseek-ai/dsh-tool-pwsh-persistent`
 
@@ -38,7 +38,7 @@ minimal 预设用 #2234 的 `disabled: !!js` 插值按平台门控持久 shell 
 
 ### 测试
 
-Windows 测试面沿用 master 的豁免结构:terminal-bash 与 subprocess-local 的测试在 win32 上继续排除(`windowsUnsupportedTests`),其源码在 win32 上继续覆盖豁免(`windowsUnsupportedCoveragePackages`),平台门控 fixture 与 node 翻译命令因此仍是 win32 开发车道的证据;koffi-backed inspector 在 Linux 侧加入 windows-only 覆盖豁免。`tool-pwsh-persistent` 不在豁免之列:其套件在 windows-native 车道上运行、源码受覆盖约束,镜像 `tool-bash-persistent` 的 stub 模式矩阵并加回显剥离模式;真实 pwsh 套件在真实 ConPTY 会话上证明持久 cwd/env、密钥清洗、多行与 here-string 命令、大输出裁剪与退出/重置。ACP keyless snapshot 通过真实 Loader 组合启动持久工具,并固定模型可见的 schema 与结果。
+Windows 测试面沿用 master 的豁免结构:terminal-bash 与 subprocess-local 的测试在 win32 上继续排除(`windowsUnsupportedTests`),其源码在 win32 上继续覆盖豁免(`windowsUnsupportedCoveragePackages`),平台门控 fixture 与 node 翻译命令因此仍是 win32 开发车道的证据;koffi-backed inspector 在 Linux 侧加入 windows-only 覆盖豁免。`tool-pwsh-persistent` 不在豁免之列:其套件在 windows-native 车道上运行、源码受覆盖约束,镜像 `tool-bash-persistent` 的 stub 模式矩阵并加回显剥离模式。session 套件无需真实 shell 即可固定拆分的光标位置查询、响应写入顺序与解析批处理;macOS 和 Windows 上的真实 pwsh 套件证明持久 cwd/env、密钥清洗、UTF-8 输出、多行与 here-string 命令、大输出裁剪及退出/重置。ACP keyless snapshot 通过真实 Loader 组合启动持久工具,并固定模型可见的 schema 与结果。
 
 ## 备选方案
 
@@ -46,6 +46,7 @@ Windows 测试面沿用 master 的豁免结构:terminal-bash 与 subprocess-lo
 - **tasklist 或 wmic 轮询进程树。** 拒绝:`inspectForeground` 每次就绪轮询(约 50ms)都跑,每 tick 生成一次探测进程不可行;wmic 已从现行 Windows 移除。koffi + Toolhelp32 是进程内、廉价的。
 - **为 SIGINT 加原生 helper 或 `GenerateConsoleCtrlEvent`。** 拒绝:向 ConPTY 输入写 `\x03` 即可中断运行中的命令(已实测),零新增代码。语义差异——在提示符处 `\x03` 取消当前行而不是给进程发信号——文档化而不是绕开。
 - **包装器 body 用 base64 编码。** 拒绝:解码需要 `[Convert]`/`[System.Text.Encoding]` 调用,其在 ConstrainedLanguage 下的可用性未证实;反引号转义的双引号字符串只用语言级构造,且已端到端实测。
+- **手写光标位置响应。** 拒绝:响应必须反映 shell 已经发出的光标移动、换行折叠和控制序列。固定坐标会放大控制台重绘并可能耗尽有界输出;`@xterm/headless` 会维护这份协议状态,但不取代逐行输出投影。
 - **容忍回显而不剥离包装器。** 拒绝:完整路径和提示符就绪路径下回显天然被排除,但超时和 START 丢失的回退会把包装器源码(含 marker nonce)泄漏进模型可见文本。
 - **复活 BEL 模型通知通道。** 拒绝:当前实现不消费任何 marker 载荷、不投递任何 BEL 事件;设计对齐当前实现,deferred 项保持 deferred。
 - **把 Windows PowerShell 5.1 当一等目标。** 拒绝:pwsh 7(含 Store 安装)是目标;`resolvePwshPath` 保留 5.1 作为最后的可执行回退,但不承诺持久 shell 在其上的完整行为。
@@ -62,4 +63,6 @@ Windows 测试面沿用 master 的豁免结构:terminal-bash 与 subprocess-lo
 
 **输入回显是接受的平台事实。** PSReadLine 回显提交的输入;marker 锚定提取与包装器原文剥离在完整结果中移除它,部分输出回退中残留有界。
 
-**携带的风险。** Windows ACL 沙箱只读模式下,ConstrainedLanguage 可能拒绝引导代码通过 `[Console]::` 固定编码并写入 prompt marker;此时命令通过可打印提示符和静默档结算,非 ASCII 输出可能沿用宿主代码页。模型重定义 `prompt` 函数同样会使就绪降级到静默档。模型命令中的裸 ESC 字符不受支持(PSReadLine 会吞掉)。koffi 成为进程基座的依赖,承担与沙箱包相同的安装/prebuild 评审。
+**终端协议响应先于调用方输入。** headless 模拟器不保留 scrollback,也不贡献模型可见文本;它跟踪终端控制状态,并通过已挂载的进程管理提供方发出响应。这会增加受维护的 `@xterm/headless` 运行时依赖,并避免光标查询消费后续工具命令。
+
+**携带的风险。** Windows ACL 沙箱只读模式下,ConstrainedLanguage 可能拒绝引导代码通过 `[Console]::` 固定编码并写入 prompt marker;若 marker 就绪持续不可用,启动会在 `timeoutMs` 到期时拒绝,而不会发布引导未完成的 shell。模型后来重定义 `prompt` 函数会使命令就绪降级到静默档。模型命令中的裸 ESC 字符不受支持(PSReadLine 会吞掉)。koffi 与 `@xterm/headless` 分别增加进程基座和终端后端的依赖评审。

+ 1 - 0
THIRD_PARTY_NOTICES.md

@@ -53,6 +53,7 @@ External packages that a workspace package resolves at runtime. The tier covers
 | [`@tanstack/react-virtual`](https://github.com/TanStack/virtual) | MIT |
 | [`@types/mdast`](https://github.com/DefinitelyTyped/DefinitelyTyped) | MIT |
 | [`@vscode/ripgrep`](https://github.com/microsoft/vscode-ripgrep) | MIT |
+| [`@xterm/headless`](https://github.com/xtermjs/xterm.js) | MIT |
 | [`@yarnpkg/parsers`](https://github.com/yarnpkg/berry) | BSD-2-Clause |
 | [`acorn`](https://github.com/acornjs/acorn) | MIT |
 | [`anser`](https://github.com/IonicaBizau/anser) | MIT |

+ 2 - 2
docs/config-catalog.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write docs/config-catalog.md
-config-catalog.md: 7636b1e3c7933f6d70a8e40d761b3617e746c13d
-config-catalog.zh.md: 4567f09059a05d3d87f336caa55fa7db831145af
+config-catalog.md: f255e38fdbc3c5831a625510110bb8a52ea280ad
+config-catalog.zh.md: f1f6774d957858e452f7120baeb78ef1712c6543

+ 1 - 1
docs/config-catalog.md

@@ -2440,7 +2440,7 @@ export interface Config {
    * regain the foreground before `inferred_idle` settles; at least one `pollIntervalMs`.
    */
   handoffGraceMs?: number
-  /** Absolute send wait bound. */
+  /** Absolute bound for one send and the complete pwsh startup sequence. */
   timeoutMs?: number
   /** Grace before teardown escalates to `SIGKILL`. */
   disposeGraceMs?: number

+ 1 - 1
docs/config-catalog.zh.md

@@ -2442,7 +2442,7 @@ export interface Config {
    * regain the foreground before `inferred_idle` settles; at least one `pollIntervalMs`.
    */
   handoffGraceMs?: number
-  /** Absolute send wait bound. */
+  /** Absolute bound for one send and the complete pwsh startup sequence. */
   timeoutMs?: number
   /** Grace before teardown escalates to `SIGKILL`. */
   disposeGraceMs?: number

+ 3 - 3
packages/shell/tool-pwsh-persistent/tests/loader-composition.spec.ts

@@ -1,5 +1,5 @@
 import { spawnSync } from 'node:child_process'
-import { mkdtemp, rm, writeFile } from 'node:fs/promises'
+import { mkdtemp, realpath, rm, writeFile } from 'node:fs/promises'
 import { tmpdir } from 'node:os'
 import { join } from 'node:path'
 import { pathToFileURL } from 'node:url'
@@ -72,7 +72,7 @@ function text(result: { content: { type: string; text?: string }[] }): string {
 
 describe.skipIf(!hasPwsh)('persistent pwsh through a real cordis.yml Loader composition', () => {
   it('preserves cwd and environment across calls', async () => {
-    root = await mkdtemp(join(tmpdir(), 'dsh-persistent-pwsh-loader-'))
+    root = await realpath(await mkdtemp(join(tmpdir(), 'dsh-persistent-pwsh-loader-')))
     const configPath = join(root, 'cordis.yml')
     await writeFile(configPath, [
       "- name: '@deepseek-ai/dsh-agent'",
@@ -93,7 +93,7 @@ describe.skipIf(!hasPwsh)('persistent pwsh through a real cordis.yml Loader comp
       '    idleSilenceMs: 300',
       '    handoffGraceMs: 300',
       '    scrollbackLines: 20000',
-      '    timeoutMs: 8000',
+      '    timeoutMs: 60000',
       '    disposeGraceMs: 500',
       "- name: '@deepseek-ai/dsh-tool-pwsh-persistent'",
       '  config:',

+ 2 - 2
packages/terminal/terminal-bash/README.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write packages/terminal/terminal-bash/README.md
-README.md: 2f3f59b1acb88ff9905e78e7fc8d0d9fbcdbf0ba
-README.zh.md: f3daa0a3bc9c160236ad19b35589778d99d48b36
+README.md: 2bc6dab36c8160976928a0ccf743407d27a314f2
+README.zh.md: 828f65dd5cfe71482bf74163837df75c2ed16d6a

Rozdílová data souboru nebyla zobrazena, protože soubor je příliš velký
+ 1 - 1
packages/terminal/terminal-bash/README.md


Rozdílová data souboru nebyla zobrazena, protože soubor je příliš velký
+ 1 - 1
packages/terminal/terminal-bash/README.zh.md


+ 2 - 1
packages/terminal/terminal-bash/package.json

@@ -43,7 +43,8 @@
   },
   "dependencies": {
     "@deepseek-ai/dsh-pwsh-local": "workspace:^",
-    "@deepseek-ai/schemastery": "workspace:^"
+    "@deepseek-ai/schemastery": "workspace:^",
+    "@xterm/headless": "^6.0.0"
   },
   "devDependencies": {
     "@deepseek-ai/dsh-agent": "workspace:^",

+ 1 - 1
packages/terminal/terminal-bash/src/config.ts

@@ -37,7 +37,7 @@ export interface Config {
    * regain the foreground before `inferred_idle` settles; at least one `pollIntervalMs`.
    */
   handoffGraceMs?: number
-  /** Absolute send wait bound. */
+  /** Absolute bound for one send and the complete pwsh startup sequence. */
   timeoutMs?: number
   /** Grace before teardown escalates to `SIGKILL`. */
   disposeGraceMs?: number

+ 32 - 25
packages/terminal/terminal-bash/src/index.ts

@@ -8,7 +8,7 @@ import { Context } from '@deepseek-ai/cordis'
 import type { Agent } from '@deepseek-ai/dsh-agent'
 import type { Session, SessionEvent } from '@deepseek-ai/dsh-session'
 import { TerminalBackendCleanupError } from '@deepseek-ai/dsh-terminal'
-import type { TerminalBackend, TerminalBackendSpawnSpec } from '@deepseek-ai/dsh-terminal'
+import type { TerminalBackend, TerminalBackendSpawnSpec, TerminalSendOperation } from '@deepseek-ai/dsh-terminal'
 import type { SubprocessTerminalHandle, SubprocessTerminalSpawnSpec } from '@deepseek-ai/dsh-subprocess'
 import type { SandboxExecutionPolicy } from '@deepseek-ai/dsh-sandbox'
 import { effectiveSandboxMode } from '@deepseek-ai/dsh-sandbox-policy'
@@ -106,52 +106,59 @@ function spawnArgv(ctx: Context, config: ResolvedConfig, policy: SandboxExecutio
 async function startupSession(
   session: LocalPtySession,
   dialect: ShellDialect,
+  timeoutMs: number,
   signal?: AbortSignal,
 ): Promise<void> {
+  let startupOperation: TerminalSendOperation | undefined
   const start = async (): Promise<void> => {
     if (dialect === 'bash') {
       await session.initialize(signal)
       return
     }
-    // pwsh cannot install its prompt from the environment: write the prompt
-    // function through the session and wait for the first marker prompt,
-    // which is also the readiness contract of the bash initialize path. The
-    // first send also pins UTF-8 output (the shared pwsh-local preamble)
-    // before anything runs: the session decode path treats PTY bytes as
-    // UTF-8, and an un-pinned console writes its host code page for
-    // non-ASCII output. The banner-to-prompt gap can outlast the silence
-    // bound, so the wait loops over follow-up sends until the controlled
-    // prompt is actually visible (in the viewport or the retained scrollback
-    // when it landed between sends), bounded by the send deadline.
+    // pwsh cannot install its prompt from the environment. Write the prompt
+    // function through the session, pin UTF-8 output before user input, and
+    // accept only backend stdin_read evidence; echoed setup source containing
+    // the printable prompt is not readiness. Follow-up sends bridge silence
+    // settlements during startup, while one absolute deadline bounds them.
     let viewport = ''
     for (;;) {
       const first = viewport.length === 0
-      const operation = session.startSend({
+      startupOperation = session.startSend({
         text: first ? ENCODING_PREAMBLE + PWSH_PROMPT_SETUP : '',
         submit: first,
         ...signal !== undefined ? { signal } : {},
       })
-      const result = await operation.done
+      const result = await startupOperation.done
       if (result.waitReason === 'session_exit') throw new Error('PTY shell exited during startup')
       if (result.waitReason === 'timeout') throw new Error('PTY shell did not reach readiness before startup timeout')
       viewport = result.viewport
-      const scrollback = session.read({ offset: 0, count: 20 }).text
-      if (viewport.includes(CONTROLLED_PROMPT) || scrollback.includes(CONTROLLED_PROMPT)) break
+      if (result.waitReason === 'stdin_read') break
     }
     session.motd = viewport
   }
-  if (signal === undefined) {
-    await start()
-    return
+  const races: Promise<void>[] = []
+  let onAbort: (() => void) | undefined
+  if (signal !== undefined) {
+    const aborted = Promise.withResolvers<never>()
+    onAbort = () => { aborted.reject(signal.reason) }
+    signal.addEventListener('abort', onAbort, { once: true })
+    races.push(aborted.promise)
+  }
+  let deadlineTimer: NodeJS.Timeout | undefined
+  if (dialect === 'pwsh') {
+    const deadline = Promise.withResolvers<never>()
+    deadlineTimer = setTimeout(() => {
+      startupOperation?.cancel()
+      deadline.reject(new Error('PTY shell did not reach readiness before startup timeout'))
+    }, timeoutMs)
+    races.push(deadline.promise)
   }
-  const aborted = Promise.withResolvers<never>()
-  const onAbort = (): void => { aborted.reject(signal.reason) }
-  signal.addEventListener('abort', onAbort, { once: true })
   try {
-    signal.throwIfAborted()
-    await Promise.race([start(), aborted.promise])
+    signal?.throwIfAborted()
+    await Promise.race([start(), ...races])
   } finally {
-    signal.removeEventListener('abort', onAbort)
+    if (deadlineTimer !== undefined) clearTimeout(deadlineTimer)
+    if (signal !== undefined && onAbort !== undefined) signal.removeEventListener('abort', onAbort)
   }
 }
 
@@ -190,7 +197,7 @@ export class BashTerminalBackend implements TerminalBackend {
     })
     const session = this.createSession(terminal, this.config)
     try {
-      await startupSession(session, this.config.shellDialect, spec.signal)
+      await startupSession(session, this.config.shellDialect, this.config.timeoutMs, spec.signal)
       return session
     } catch (error) {
       try {

+ 153 - 10
packages/terminal/terminal-bash/src/session.ts

@@ -1,6 +1,8 @@
-/** Persistent PTY session over the subprocess seam's terminal primitive. */
+/** Persistent PTY session with bounded output, readiness, and terminal-protocol replies. */
 
 import { Buffer } from 'node:buffer'
+import { createRequire } from 'node:module'
+import type { IDisposable, Terminal as HeadlessTerminalType } from '@xterm/headless'
 import type {
   SubprocessOutcome,
   SubprocessTerminalForeground,
@@ -23,6 +25,9 @@ import type {
 import type { ResolvedConfig } from './config.ts'
 import { CONTROLLED_PROMPT, TerminalSanitizer } from './sanitize.ts'
 
+// Node exposes this package's CommonJS main as default-only, so load its named export through require.
+const { Terminal: HeadlessTerminal } = createRequire(import.meta.url)('@xterm/headless') as typeof import('@xterm/headless')
+
 function utf8Tail(text: string, maxBytes: number): { text: string; truncated: boolean } {
   if (Buffer.byteLength(text) <= maxBytes) return { text, truncated: false }
   const chars = Array.from(text)
@@ -157,6 +162,9 @@ export class LocalPtySession implements TerminalBackendSession {
   motd = ''
   readonly pid: number
   private readonly decoder = new TextDecoder()
+  /** Protocol state only; the sanitizer and bounded buffers own returned text. */
+  private readonly emulator: HeadlessTerminalType
+  private readonly emulatorData: IDisposable
   private readonly sanitizer: TerminalSanitizer
   private readonly scrollback: BoundedTextBuffer
   private readonly outputEnded = Promise.withResolvers<void>()
@@ -164,9 +172,8 @@ export class LocalPtySession implements TerminalBackendSession {
   private statusValue: TerminalSessionStatus = { kind: 'running' }
   // TODO(pty-send-state-consolidation): Fold the per-send fields below
   // (active/activeTimer/activeDeadlineTimer/activeAbort/interrupting/
-  // activeWrite/pollingReady/polling) into one send-lifecycle owner; the
-  // cancellation/readiness interplay now has enough pinned tests to carry
-  // that refactor safely.
+  // activeWrite/pollingReady/polling and terminal-protocol work) into one send-lifecycle
+  // owner; the cancellation/readiness interplay has enough pinned tests to carry that refactor safely.
   private active: LocalSendOperation | undefined
   private activeTimer: NodeJS.Timeout | undefined
   private activeDeadlineTimer: NodeJS.Timeout | undefined
@@ -184,12 +191,31 @@ export class LocalPtySession implements TerminalBackendSession {
   private closing = false
   private closePromise: Promise<void> | undefined
   private transportFailure: Error | undefined
+  private emulatorWrites = Promise.resolve()
+  private emulatorWriteDone: (() => void) | undefined
+  private emulatorBuffer = ''
+  private emulatorWriting = false
+  private responseWrites = Promise.resolve()
+  private pendingResponseWrites = 0
+  private emulatorClosed = false
 
   constructor(
     private readonly terminal: SubprocessTerminalHandle,
     private readonly config: ResolvedConfig,
   ) {
     this.pid = terminal.pid
+    this.emulator = new HeadlessTerminal({ cols: config.cols, rows: config.rows, scrollback: 0 })
+    this.emulatorData = this.emulator.onData((data) => {
+      this.pendingResponseWrites += 1
+      const response = this.responseWrites.then(async () => { await this.terminal.write(data) })
+      this.responseWrites = response.then(
+        () => { this.finishResponseWrite() },
+        (error: unknown) => {
+          this.finishResponseWrite()
+          if (!this.emulatorClosed && !this.closing) this.onTransportFailure(error)
+        },
+      )
+    })
     this.sanitizer = new TerminalSanitizer(config.maxReadBytes)
     this.scrollback = new BoundedTextBuffer(config.scrollbackMaxBytes, config.scrollbackLines)
     terminal.output.on('data', this.onTerminalData)
@@ -250,7 +276,9 @@ export class LocalPtySession implements TerminalBackendSession {
     }
     this.activeDeadlineTimer = setTimeout(() => {
       if (this.active === operation) {
-        this.settleActive('timeout', this.activeWrite !== undefined || this.interrupting === operation)
+        this.settleActive('timeout', this.activeWrite !== undefined
+          || this.interrupting === operation
+          || this.protocolWorkPending())
       }
     }, this.config.timeoutMs)
     void this.beginSend(operation, request)
@@ -260,8 +288,15 @@ export class LocalPtySession implements TerminalBackendSession {
   private async beginSend(operation: LocalSendOperation, request: TerminalSendRequest): Promise<void> {
     let foreground: SubprocessTerminalForeground | undefined
     try {
+      if (this.protocolWorkPending()) await this.drainTerminalProtocol()
+      const emulatorWrites = this.emulatorWrites
+      const responseWrites = this.responseWrites
       foreground = await this.terminal.inspectForeground()
+      if (this.protocolStateChanged(emulatorWrites, responseWrites)) {
+        foreground = await this.inspectForegroundAfterProtocol()
+      }
     } catch (error: unknown) {
+      if (this.protocolWorkPending()) await this.drainTerminalProtocol()
       // A pre-write inspection failure while cancellation owns the slot must not
       // release it: interruptOnce's in-flight foreground signal could land on a
       // successor's foreground group. The interrupt path's post-signal tail
@@ -290,7 +325,7 @@ export class LocalPtySession implements TerminalBackendSession {
       // Cancellation owns post-write signalling and reservation release.
       if (operation.cancelRequested) return
       if (this.active === operation && operation.settled) {
-        this.clearActive()
+        this.releaseSettledActive()
         return
       }
       // Closing can race the awaited provider write even though static analysis sees only local assignments.
@@ -301,7 +336,7 @@ export class LocalPtySession implements TerminalBackendSession {
       }
     } catch (error: unknown) {
       if (this.active === operation && !this.closing) {
-        if (operation.settled) this.clearActive()
+        if (operation.settled) this.releaseSettledActive()
         else this.failActive(error)
       }
     }
@@ -363,16 +398,20 @@ export class LocalPtySession implements TerminalBackendSession {
 
   private readonly onTerminalData = (chunk: Buffer | Uint8Array | string): void => {
     const bytes = typeof chunk === 'string' ? Buffer.from(chunk, 'utf8') : chunk
-    this.onData(this.decoder.decode(bytes, { stream: true }))
+    const data = this.decoder.decode(bytes, { stream: true })
+    this.queueEmulatorData(data)
+    this.onData(data)
   }
 
   private readonly onTerminalEnd = (): void => {
     this.onData(this.decoder.decode())
     this.appendOutput(this.sanitizer.flush())
+    this.closeEmulator()
     this.outputEnded.resolve()
   }
 
   private readonly onTerminalError = (error: Error): void => {
+    this.closeEmulator()
     this.onTransportFailure(error)
     this.outputEnded.resolve()
   }
@@ -409,6 +448,7 @@ export class LocalPtySession implements TerminalBackendSession {
     const failure = error instanceof Error ? error : new Error(String(error))
     this.transportFailure ??= failure
     this.statusValue = { kind: 'exited', exitCode: null, signal: null }
+    this.closeEmulator()
     this.failActive(failure)
     void this.terminal.terminate().catch(() => {})
   }
@@ -437,7 +477,13 @@ export class LocalPtySession implements TerminalBackendSession {
         this.settleActive('session_exit')
         return
       }
-      const foreground = await this.terminal.inspectForeground()
+      if (this.protocolWorkPending()) await this.drainTerminalProtocol()
+      const emulatorWrites = this.emulatorWrites
+      const responseWrites = this.responseWrites
+      let foreground = await this.terminal.inspectForeground()
+      if (this.protocolStateChanged(emulatorWrites, responseWrites)) {
+        foreground = await this.inspectForegroundAfterProtocol()
+      }
       if (this.active !== operation || this.closing || this.interrupting === operation) return
       const idleFor = Date.now() - this.lastOutputAt
       if (this.promptSeen && foreground !== undefined && this.shellPgid === undefined) {
@@ -465,6 +511,7 @@ export class LocalPtySession implements TerminalBackendSession {
         this.settleActive('inferred_idle')
       }
     } catch (error: unknown) {
+      if (this.protocolWorkPending()) await this.drainTerminalProtocol()
       if (this.active === operation && !this.closing && this.interrupting !== operation) this.failActive(error)
     } finally {
       this.polling = false
@@ -475,6 +522,101 @@ export class LocalPtySession implements TerminalBackendSession {
     }
   }
 
+  /** Wait until generated replies reach the provider before another send can publish. */
+  private async drainTerminalProtocol(): Promise<void> {
+    for (;;) {
+      const emulatorWrites = this.emulatorWrites
+      await emulatorWrites
+      const responseWrites = this.responseWrites
+      await responseWrites
+      if (emulatorWrites === this.emulatorWrites && responseWrites === this.responseWrites
+        && !this.protocolWorkPending()) return
+    }
+  }
+
+  /** Sample foreground state only after protocol replies are quiet for the entire inspection. */
+  private async inspectForegroundAfterProtocol(): Promise<SubprocessTerminalForeground | undefined> {
+    for (;;) {
+      if (this.protocolWorkPending()) await this.drainTerminalProtocol()
+      const emulatorWrites = this.emulatorWrites
+      const responseWrites = this.responseWrites
+      const foreground = await this.terminal.inspectForeground()
+      if (!this.protocolStateChanged(emulatorWrites, responseWrites)) return foreground
+    }
+  }
+
+  private protocolStateChanged(emulatorWrites: Promise<void>, responseWrites: Promise<void>): boolean {
+    return emulatorWrites !== this.emulatorWrites || responseWrites !== this.responseWrites
+      || this.protocolWorkPending()
+  }
+
+  private protocolWorkPending(): boolean {
+    return this.emulatorWriteDone !== undefined || this.pendingResponseWrites > 0
+  }
+
+  private queueEmulatorData(data: string): void {
+    if (this.emulatorClosed) return
+    this.emulatorBuffer += data
+    if (this.emulatorWriteDone === undefined) {
+      const idle = Promise.withResolvers<undefined>()
+      this.emulatorWrites = idle.promise
+      this.emulatorWriteDone = () => { idle.resolve(undefined) }
+    }
+    this.pumpEmulator()
+  }
+
+  private pumpEmulator(): void {
+    if (this.emulatorWriting || this.emulatorClosed) return
+    if (this.emulatorBuffer.length === 0) {
+      const done = this.emulatorWriteDone
+      this.emulatorWriteDone = undefined
+      done?.()
+      this.releaseSettledActive()
+      return
+    }
+    const data = this.emulatorBuffer
+    this.emulatorBuffer = ''
+    this.emulatorWriting = true
+    try {
+      this.emulator.write(data, () => {
+        this.emulatorWriting = false
+        this.pumpEmulator()
+      })
+    } catch (error: unknown) {
+      this.emulatorWriting = false
+      this.emulatorBuffer = ''
+      const done = this.emulatorWriteDone
+      this.emulatorWriteDone = undefined
+      done?.()
+      this.releaseSettledActive()
+      if (!this.closing) this.onTransportFailure(error)
+    }
+  }
+
+  private finishResponseWrite(): void {
+    this.pendingResponseWrites -= 1
+    this.releaseSettledActive()
+  }
+
+  private releaseSettledActive(): void {
+    const operation = this.active
+    if (operation === undefined || !operation.settled || this.activeWrite !== undefined
+      || this.interrupting === operation || this.protocolWorkPending()) return
+    this.clearActive()
+  }
+
+  private closeEmulator(): void {
+    if (this.emulatorClosed) return
+    this.emulatorClosed = true
+    this.emulatorBuffer = ''
+    this.emulatorWriting = false
+    const done = this.emulatorWriteDone
+    this.emulatorWriteDone = undefined
+    done?.()
+    this.emulatorData.dispose()
+    this.emulator.dispose()
+  }
+
   private settleActive(waitReason: TerminalWaitReason, retainOwnership = false): void {
     const operation = this.active
     if (operation === undefined) return
@@ -537,7 +679,7 @@ export class LocalPtySession implements TerminalBackendSession {
       if (this.interrupting === operation) this.interrupting = undefined
     }
     if (this.active === operation && operation.settled) {
-      this.clearActive()
+      this.releaseSettledActive()
     } else if (this.active === operation && !this.closing) {
       this.pollingReady = operation
       this.schedulePoll(operation, 0)
@@ -549,6 +691,7 @@ export class LocalPtySession implements TerminalBackendSession {
     // it as session_exit below, so an in-flight send is never mis-settled as
     // stdin_read/inferred_idle/timeout during the grace period.
     this.stopPolling()
+    this.closeEmulator()
     try {
       await this.terminal.terminate()
     } catch (error: unknown) {

+ 1 - 0
packages/terminal/terminal-bash/tests/config.spec.ts

@@ -29,6 +29,7 @@ describe('terminal-bash config', () => {
     expect(() => { validateConfig(config({ handoffGraceMs: 9, pollIntervalMs: 10 })) }).toThrow('handoffGraceMs must be at least pollIntervalMs')
     expect(() => { validateConfig(config({ handoffGraceMs: 10, pollIntervalMs: 10 })) }).not.toThrow()
   })
+
 })
 
 describe('terminal-bash dialect resolution', () => {

+ 57 - 3
packages/terminal/terminal-bash/tests/index.spec.ts

@@ -379,7 +379,7 @@ describe('BashTerminalBackend startup rollback', () => {
     expect(spawned?.env?.PROMPT_COMMAND).toBeUndefined()
   })
 
-  it('keeps waiting for the marker prompt when the first send settles on silence', async () => {
+  it('keeps waiting for stdin_read when the first settled output only echoes the prompt literal', async () => {
     const ctx = new Context()
     await ctx.plugin(EmptySandbox)
     await ctx.plugin(SandboxPolicyService, { mode: 'danger-full-access', workspaceRoot: '/workspace' })
@@ -391,8 +391,8 @@ describe('BashTerminalBackend startup rollback', () => {
         const second = sends.length > 1
         return {
           done: Promise.resolve({
-            viewport: second ? 'dsh> ' : 'PowerShell 7.6.4\n',
-            waitReason: 'inferred_idle' as const,
+            viewport: second ? 'dsh> ' : "function prompt { 'dsh> ' }\n",
+            waitReason: second ? 'stdin_read' as const : 'inferred_idle' as const,
             sessionStatus: { kind: 'running' as const }, truncated: false,
           }),
           readOutput: () => ({ delta: '', truncated: false }),
@@ -435,6 +435,60 @@ describe('BashTerminalBackend startup rollback', () => {
     await expect(timedOut.spawn(spec(agent(ctx)))).rejects.toThrow('did not reach readiness before startup timeout')
   })
 
+  it('bounds all pwsh startup retries with one deadline', async () => {
+    vi.useFakeTimers()
+    try {
+      const ctx = new Context()
+      await ctx.plugin(EmptySandbox)
+      await ctx.plugin(SandboxPolicyService, { mode: 'danger-full-access', workspaceRoot: '/workspace' })
+      const pending = Promise.withResolvers<{
+        viewport: string
+        waitReason: 'inferred_idle'
+        sessionStatus: { kind: 'running' }
+        truncated: boolean
+      }>()
+      let sends = 0
+      let cancellations = 0
+      let closes = 0
+      const session = {
+        motd: '',
+        startSend: () => {
+          sends += 1
+          return {
+            done: sends === 1
+              ? Promise.resolve({
+                viewport: 'setup echo', waitReason: 'inferred_idle' as const,
+                sessionStatus: { kind: 'running' as const }, truncated: false,
+              })
+              : pending.promise,
+            readOutput: () => ({ delta: '', truncated: false }),
+            cancel: () => { cancellations += 1; return true },
+          }
+        },
+        read: () => ({ text: '', totalLines: 0, lineBegin: 0, lineEnd: 0, truncated: false }),
+        close: () => { closes += 1; return Promise.resolve() },
+      } as unknown as LocalPtySession
+      const backend = new BashTerminalBackend(
+        ctx,
+        { ...config(), shellDialect: 'pwsh', shellPath: 'pwsh' },
+        async () => terminalHandle(),
+        () => session,
+      )
+
+      const spawning = backend.spawn(spec(agent(ctx)))
+      await vi.advanceTimersByTimeAsync(0)
+      expect(sends).toBe(2)
+      const rejected = expect(spawning).rejects.toThrow('did not reach readiness before startup timeout')
+      await vi.advanceTimersByTimeAsync(100)
+
+      await rejected
+      expect(cancellations).toBe(1)
+      expect(closes).toBe(1)
+    } finally {
+      vi.useRealTimers()
+    }
+  })
+
   it('forwards the spawn signal into the pwsh bootstrap sends', async () => {
     const ctx = new Context()
     await ctx.plugin(EmptySandbox)

+ 306 - 0
packages/terminal/terminal-bash/tests/session.spec.ts

@@ -148,6 +148,310 @@ async function initialize(session: LocalPtySession, terminal: FakeTerminal): Pro
 }
 
 describe('LocalPtySession readiness and output', () => {
+  it('answers split cursor-position queries before publishing prompt readiness', async () => {
+    vi.useFakeTimers()
+    const terminal = new FakeTerminal()
+    const inspector = new FakeInspector()
+    const session = makeSession(terminal, inspector, config())
+    const responseGate = Promise.withResolvers<undefined>()
+    terminal.write = async (data) => {
+      terminal.writes.push(data)
+      await responseGate.promise
+    }
+
+    let initialized = false
+    const pending = session.initialize().then(() => { initialized = true })
+    terminal.emitData('\x1b]133;D;0\x07dsh> \x1b[')
+    terminal.emitData('6n')
+    await vi.advanceTimersByTimeAsync(20)
+
+    expect(terminal.writes).toContain('\x1b[1;6R')
+    expect(initialized).toBe(false)
+    responseGate.resolve(undefined)
+    await vi.advanceTimersByTimeAsync(10)
+    await pending
+    expect(session.motd).toBe('dsh> ')
+  })
+
+  it('drains terminal replies before caller input and re-inspects after concurrent output', async () => {
+    vi.useFakeTimers()
+    const terminal = new FakeTerminal()
+    const inspector = new FakeInspector()
+    const session = makeSession(terminal, inspector, config())
+    await initialize(session, terminal)
+    const firstInspection = Promise.withResolvers<{ processGroupId: number; inputWaiting: boolean }>()
+    const secondInspection = Promise.withResolvers<{ processGroupId: number; inputWaiting: boolean }>()
+    let inspections = 0
+    terminal.inspectForeground = async () => {
+      inspections += 1
+      if (inspections === 1) return await firstInspection.promise
+      if (inspections === 2) return await secondInspection.promise
+      return { processGroupId: 456, inputWaiting: false }
+    }
+    const responseGate = Promise.withResolvers<undefined>()
+    terminal.write = async (data) => {
+      terminal.writes.push(data)
+      if (data === '\x1b[1;6R') await responseGate.promise
+    }
+
+    const operation = session.startSend({ text: 'caller input', submit: true })
+    await Promise.resolve()
+    terminal.emitData('\x1b[6n')
+    await vi.advanceTimersByTimeAsync(0)
+    firstInspection.resolve({ processGroupId: 456, inputWaiting: true })
+    await Promise.resolve()
+
+    expect(terminal.writes).toEqual(['\x1b[1;6R'])
+    responseGate.resolve(undefined)
+    await vi.advanceTimersByTimeAsync(0)
+    expect(inspections).toBe(2)
+    terminal.emitData('\x1b[6n')
+    await vi.advanceTimersByTimeAsync(0)
+    secondInspection.resolve({ processGroupId: 456, inputWaiting: true })
+    await vi.advanceTimersByTimeAsync(0)
+    expect(inspections).toBe(3)
+    expect(terminal.writes).toEqual(['\x1b[1;6R', '\x1b[1;6R', 'caller input\r'])
+
+    terminal.emitData('\x1b]133;D;0\x07dsh> ')
+    await vi.advanceTimersByTimeAsync(10)
+    expect((await operation.done).waitReason).toBe('stdin_read')
+  })
+
+  it('drains a terminal reply that is pending when caller input starts', async () => {
+    vi.useFakeTimers()
+    const terminal = new FakeTerminal()
+    const inspector = new FakeInspector()
+    const session = makeSession(terminal, inspector, config())
+    await initialize(session, terminal)
+    const responseGate = Promise.withResolvers<undefined>()
+    terminal.write = async (data) => {
+      terminal.writes.push(data)
+      if (data === '\x1b[1;6R') await responseGate.promise
+    }
+    terminal.emitData('\x1b[6n')
+    await vi.advanceTimersByTimeAsync(0)
+
+    const operation = session.startSend({ text: 'caller input', submit: true })
+    await Promise.resolve()
+    expect(terminal.writes).toEqual(['\x1b[1;6R'])
+    responseGate.resolve(undefined)
+    await vi.advanceTimersByTimeAsync(0)
+    expect(terminal.writes).toEqual(['\x1b[1;6R', 'caller input\r'])
+
+    terminal.emitData('\x1b]133;D;0\x07dsh> ')
+    await vi.advanceTimersByTimeAsync(10)
+    expect((await operation.done).waitReason).toBe('stdin_read')
+  })
+
+  it('resamples readiness foreground state after protocol activity during inspection', async () => {
+    vi.useFakeTimers()
+    const terminal = new FakeTerminal()
+    const inspector = new FakeInspector()
+    const session = makeSession(terminal, inspector, config())
+    await initialize(session, terminal)
+    const operation = session.startSend({ text: '', submit: false })
+    await Promise.resolve()
+    await Promise.resolve()
+    const internal = session as unknown as {
+      stopReadinessPolling(): void
+      pollReadiness(operation: TerminalSendOperation): Promise<void>
+      settleActive(reason: 'timeout'): void
+    }
+    internal.stopReadinessPolling()
+    await vi.advanceTimersByTimeAsync(20)
+
+    const firstInspection = Promise.withResolvers<{ processGroupId: number; inputWaiting: boolean }>()
+    let inspections = 0
+    terminal.inspectForeground = async () => {
+      inspections += 1
+      return inspections === 1
+        ? await firstInspection.promise
+        : { processGroupId: 456, inputWaiting: false }
+    }
+    const polling = internal.pollReadiness(operation)
+    await Promise.resolve()
+    terminal.emitData('\x1b[6n')
+    await vi.advanceTimersByTimeAsync(0)
+    firstInspection.resolve({ processGroupId: 456, inputWaiting: true })
+    await polling
+
+    expect(inspections).toBe(2)
+    expect((operation as unknown as { settled: boolean }).settled).toBe(false)
+    internal.settleActive('timeout')
+    expect((await operation.done).waitReason).toBe('timeout')
+  })
+
+  it('retains send ownership while a failed inspection drains a terminal reply', async () => {
+    vi.useFakeTimers()
+    const terminal = new FakeTerminal()
+    const inspector = new FakeInspector()
+    const session = makeSession(terminal, inspector, config())
+    await initialize(session, terminal)
+    const operation = session.startSend({ text: '', submit: false })
+    await Promise.resolve()
+    await Promise.resolve()
+    const internal = session as unknown as {
+      stopReadinessPolling(): void
+      pollReadiness(operation: TerminalSendOperation): Promise<void>
+    }
+    internal.stopReadinessPolling()
+
+    const inspection = Promise.withResolvers<{ processGroupId: number; inputWaiting: boolean }>()
+    terminal.inspectForeground = async () => await inspection.promise
+    const responseGate = Promise.withResolvers<undefined>()
+    terminal.write = async (data) => {
+      terminal.writes.push(data)
+      await responseGate.promise
+    }
+    const polling = internal.pollReadiness(operation)
+    await Promise.resolve()
+    terminal.emitData('\x1b[6n')
+    await vi.advanceTimersByTimeAsync(0)
+    inspection.reject(new Error('inspection failed with reply pending'))
+    await Promise.resolve()
+
+    expect(() => session.startSend({ text: 'successor', submit: true })).toThrow('active send')
+    responseGate.resolve(undefined)
+    await polling
+    await expect(operation.done).rejects.toThrow('inspection failed with reply pending')
+  })
+
+  it('retains pre-write ownership when inspection fails with a terminal reply pending', async () => {
+    vi.useFakeTimers()
+    const terminal = new FakeTerminal()
+    const inspector = new FakeInspector()
+    const session = makeSession(terminal, inspector, config())
+    await initialize(session, terminal)
+    const inspection = Promise.withResolvers<{ processGroupId: number; inputWaiting: boolean }>()
+    terminal.inspectForeground = async () => await inspection.promise
+    const responseGate = Promise.withResolvers<undefined>()
+    terminal.write = async (data) => {
+      terminal.writes.push(data)
+      await responseGate.promise
+    }
+
+    const operation = session.startSend({ text: 'must not execute', submit: true })
+    await Promise.resolve()
+    terminal.emitData('\x1b[6n')
+    await vi.advanceTimersByTimeAsync(0)
+    inspection.reject(new Error('pre-write inspection failed with reply pending'))
+    await Promise.resolve()
+
+    expect(() => session.startSend({ text: 'successor', submit: true })).toThrow('active send')
+    responseGate.resolve(undefined)
+    await expect(operation.done).rejects.toThrow('pre-write inspection failed with reply pending')
+    expect(terminal.writes).toEqual(['\x1b[1;6R'])
+  })
+
+  it('retains a timed-out send until its terminal-protocol response settles', async () => {
+    vi.useFakeTimers()
+    const terminal = new FakeTerminal()
+    const inspector = new FakeInspector()
+    const session = makeSession(terminal, inspector, config())
+    await initialize(session, terminal)
+    const responseGate = Promise.withResolvers<undefined>()
+    terminal.write = async (data) => {
+      terminal.writes.push(data)
+      await responseGate.promise
+    }
+
+    const operation = session.startSend({ text: '', submit: false })
+    terminal.emitData('\x1b[6n')
+    await vi.advanceTimersByTimeAsync(100)
+    expect((await operation.done).waitReason).toBe('timeout')
+    expect(() => session.startSend({ text: 'successor', submit: true })).toThrow('active send')
+
+    responseGate.resolve(undefined)
+    await vi.advanceTimersByTimeAsync(0)
+    const successor = session.startSend({ text: '', submit: false })
+    terminal.emitData('\x1b]133;D;0\x07dsh> ')
+    await vi.advanceTimersByTimeAsync(10)
+    expect((await successor.done).waitReason).toBe('stdin_read')
+  })
+
+  it('contains terminal emulator and protocol-response failures', async () => {
+    const responseTerminal = new FakeTerminal()
+    responseTerminal.throwWrite = true
+    const responseSession = new LocalPtySession(responseTerminal, config())
+    const responseOperation = responseSession.startSend({ text: '', submit: false })
+    responseTerminal.emitData('\x1b[6n')
+    await expect(responseOperation.done).rejects.toThrow('write failed')
+    expect(responseSession.status()).toEqual({ kind: 'exited', exitCode: null, signal: null })
+
+    const emulatorTerminal = new FakeTerminal()
+    const emulatorSession = new LocalPtySession(emulatorTerminal, config())
+    const emulatorOperation = emulatorSession.startSend({ text: '', submit: false })
+    const emulator = (emulatorSession as unknown as {
+      emulator: { write(data: string, callback?: () => void): void }
+    }).emulator
+    emulator.write = () => { throw new Error('emulator failed') }
+    emulatorTerminal.emitData('output')
+    await expect(emulatorOperation.done).rejects.toThrow('emulator failed')
+    expect(emulatorSession.status()).toEqual({ kind: 'exited', exitCode: null, signal: null })
+  })
+
+  it('ignores terminal-protocol failures after closing starts and drains changing queues', async () => {
+    const terminal = new FakeTerminal()
+    const session = new LocalPtySession(terminal, config())
+    const internal = session as unknown as {
+      closing: boolean
+      emulator: { write(data: string, callback?: () => void): void }
+      emulatorWrites: Promise<void>
+      responseWrites: Promise<void>
+      drainTerminalProtocol(): Promise<void>
+      closeEmulator(): void
+    }
+    internal.closing = true
+    terminal.throwWrite = true
+    terminal.emitData('\x1b[6n')
+    await internal.emulatorWrites
+    await internal.responseWrites
+    expect(session.status()).toEqual({ kind: 'running' })
+
+    internal.emulator.write = () => { throw new Error('late emulator failure') }
+    terminal.emitData('late output')
+    await internal.emulatorWrites
+    expect(session.status()).toEqual({ kind: 'running' })
+
+    const first = Promise.withResolvers<undefined>()
+    internal.emulatorWrites = first.promise
+    const draining = internal.drainTerminalProtocol()
+    internal.emulatorWrites = Promise.resolve()
+    first.resolve(undefined)
+    await draining
+    internal.closeEmulator()
+    internal.closeEmulator()
+    terminal.emitData('after emulator close')
+    expect(session.status()).toEqual({ kind: 'running' })
+  })
+
+  it('coalesces terminal output that arrives while an emulator parse is pending', async () => {
+    const terminal = new FakeTerminal()
+    const session = new LocalPtySession(terminal, config())
+    const writes: Array<{ data: string; done: () => void }> = []
+    const internal = session as unknown as {
+      emulator: { write(data: string, callback?: () => void): void }
+      emulatorWrites: Promise<void>
+      closeEmulator(): void
+    }
+    internal.emulator.write = (data, callback) => {
+      writes.push({ data, done: callback ?? (() => {}) })
+    }
+
+    terminal.emitData('first')
+    terminal.emitData('second')
+    terminal.emitData('third')
+    await Promise.resolve()
+    expect(writes.map(write => write.data)).toEqual(['first'])
+
+    writes[0]!.done()
+    await Promise.resolve()
+    expect(writes.map(write => write.data)).toEqual(['first', 'secondthird'])
+    writes[1]!.done()
+    await internal.emulatorWrites
+    internal.closeEmulator()
+  })
+
   it('lets queued terminal output run before the first post-write readiness poll', async () => {
     vi.useFakeTimers()
     const terminal = new FakeTerminal()
@@ -995,6 +1299,7 @@ describe('LocalPtySession readiness and output', () => {
     await Promise.resolve()
     await Promise.resolve()
     inspection.resolve({ processGroupId: 456, inputWaiting: false })
+    await vi.advanceTimersByTimeAsync(0)
     await stalePoll
     await vi.advanceTimersByTimeAsync(10)
 
@@ -1190,6 +1495,7 @@ describe('LocalPtySession bounds, signals, and teardown', () => {
       message: 'PTY cleanup failed (survivor)',
       cause: terminal.terminateError,
     })
+    expect((session as unknown as { emulatorClosed: boolean }).emulatorClosed).toBe(true)
     expect(terminal.kills).toEqual([])
 
     terminal.terminateError = undefined

+ 8 - 0
pnpm-lock.yaml

@@ -8644,6 +8644,9 @@ importers:
       '@deepseek-ai/schemastery':
         specifier: link:../../../vendor/schemastery
         version: link:../../../vendor/schemastery
+      '@xterm/headless':
+        specifier: ^6.0.0
+        version: 6.0.0
     devDependencies:
       '@deepseek-ai/cordis':
         specifier: workspace:^
@@ -12857,6 +12860,9 @@ packages:
   '@vueuse/shared@12.8.2':
     resolution: {integrity: sha512-dznP38YzxZoNloI0qpEfpkms8knDtaoQ6Y/sfS0L7Yki4zh40LFHEhur0odJC6xTHG5dxWVPiUWBXn+wCG2s5w==}
 
+  '@xterm/headless@6.0.0':
+    resolution: {integrity: sha512-5Yj1QINYCyzrZtf8OFIHi47iQtI+0qYFPHmouEfG8dHNxbZ9Tb9YGSuLcsEwj9Z+OL75GJqPyJbyoFer80a2Hw==}
+
   '@yarnpkg/cli-dist@4.17.1':
     resolution: {integrity: sha512-2tiSQuJNl/L3QwTdrq6lKWDpkcnp9MGvCT/rIldHcbu3SWfnLdmehvt3eulX1hT7FFt1Gjfq3CesF+kvhFip6g==}
     engines: {node: '>=18.12.0'}
@@ -18167,6 +18173,8 @@ snapshots:
     transitivePeerDependencies:
       - typescript
 
+  '@xterm/headless@6.0.0': {}
+
   '@yarnpkg/cli-dist@4.17.1': {}
 
   '@yarnpkg/parsers@3.1.0':

Některé soubory nejsou zobrazeny, neboť je v těchto rozdílových datech změněno mnoho souborů