fix(pty): apply the tail grace only while the tail can still complete

Review round 1: the tolerance now requires the accumulated tail to remain a
prefix of the controlled prompt, so a prompt that arrived and was invalidated by
later output keeps the plain silence bound instead of waiting a tolerance with
nothing left to wait for. The field is validated as zero or at least one
`pollIntervalMs`, matching the handoff grace rule, and tests pin the extended
bound's expiry, the invalidated tail, and that rule. The owning readiness design
note records the new field and Tier 2 bound and cross-links this decision.
This commit is contained in:
Tianyi Cui committed 2026-09-27 21:36:42 +08:00
1 parent 4e25b13d79
commit 76a2b7a799
14 files changed
+85 -28

No files matched your search

@@ -9,8 +9,8 @@
en: ac4fb08e3203cee9
zh: 69fcfcb4f8fc0319
/agent-note-a-seen-prompt-marker-keeps-its-own-tail-grace/decision:
en: 3727656a7fab5543
zh: a641a52acb2054ea
en: 653c74442decb65a
zh: 46f01c45d48b846b
/agent-note-a-seen-prompt-marker-keeps-its-own-tail-grace/alternatives-considered:
en: 21a9a19b76f42758
zh: 30a8f5425b59900d
@@ -18,5 +18,5 @@
en: b58904921286a1db
zh: a696c2954d86c04f
/agent-note-a-seen-prompt-marker-keeps-its-own-tail-grace/testing:
en: 18436ffb83282ec5
zh: e733055f72928cc9
en: 78253191eea198fa
zh: 32bdd6807ddc13a0
@@ -10,7 +10,7 @@ Windows sessions on the self-hosted lane settled one or two of seven sends on th
## Decision
`dsh-terminal-bash` gains a validated `promptTailGraceMs` configuration field, default `0`. While a send has seen the prompt marker and its printable tail has not arrived, the silence bound becomes `idleSilenceMs + handoffGraceMs + promptTailGraceMs`; every other bound, wait reason, and code path is unchanged. Zero reproduces the previous bound exactly, so an upgrade changes no deployment's behaviour. The persistent pwsh Loader-composition case sets 5000 ms, because the hosts whose stalls the field is for are exactly the ones that run it, and its assertion (at least six `stdin_read` settlements, no `inferred_idle`) then measures the controlled-prompt path instead of the host's delivery timing. `validateConfig` accepts zero for this field — the one numeric bound whose zero is a documented value — and rejects negative or fractional values.
`dsh-terminal-bash` gains a validated `promptTailGraceMs` configuration field, default `0`. While a send has seen the prompt marker and its printable tail has not arrived, the silence bound becomes `idleSilenceMs + handoffGraceMs + promptTailGraceMs`; every other bound, wait reason, and code path is unchanged. The [persistent PTY readiness design](../feature/2026-07-16-persistent-pty-sessions.md) owns the tier ladder this bound extends. Zero reproduces the previous bound exactly, so an upgrade changes no deployment's behaviour. The persistent pwsh Loader-composition case sets 5000 ms, because the hosts whose stalls the field is for are exactly the ones that run it, and its assertion (at least six `stdin_read` settlements, no `inferred_idle`) then measures the controlled-prompt path instead of the host's delivery timing. `validateConfig` accepts zero for this field — the one numeric bound whose zero is a documented value — and rejects negative or fractional values.
## Alternatives considered
@@ -28,4 +28,4 @@ A deployment whose console renderer stalls keeps those sends on the exact path b
## Testing
`packages/terminal/terminal-bash/tests/session.spec.ts` pins both directions with fake timers: with `promptTailGraceMs` set, a marker whose tail arrives after the plain bound stays pending and then settles `stdin_read`; with the field at zero, the same delivery settles `inferred_idle`. `packages/terminal/terminal-bash/tests/config.spec.ts` accepts zero and rejects negative or fractional values. `packages/shell/tool-pwsh-persistent/tests/loader-composition.spec.ts` carries the end-to-end evidence on native Windows: with every session's prompt tail withheld for four seconds the case passes with the configured tolerance — nine settles, eight `stdin_read` and the `exit` command's `session_exit`, in 33.4 s and 37.5 s — and fails with the tolerance patched to zero, where two sends settle `inferred_idle` with `promptSeen` true, `promptTextSeen` false, an empty tail and `idleFor` 3312-3316 ms.
`packages/terminal/terminal-bash/tests/session.spec.ts` pins every state with fake timers: with `promptTailGraceMs` set, a marker whose tail arrives after the plain bound stays pending and then settles `stdin_read`; a marker whose tail never arrives falls back to `inferred_idle` at the extended bound; a tail that arrived and was invalidated by later output keeps the plain bound; and with the field at zero the same delayed delivery settles `inferred_idle`. `packages/terminal/terminal-bash/tests/config.spec.ts` accepts zero and rejects negative or fractional values, and rejects a nonzero tolerance shorter than one `pollIntervalMs`. `packages/shell/tool-pwsh-persistent/tests/loader-composition.spec.ts` carries the end-to-end evidence on native Windows: with every session's prompt tail withheld for four seconds the case passes with the configured tolerance — nine settles, eight `stdin_read` and the `exit` command's `session_exit`, in 33.4 s and 37.5 s — and fails with the tolerance patched to zero, where two sends settle `inferred_idle` with `promptSeen` true, `promptTextSeen` false, an empty tail and `idleFor` 3312-3316 ms.
@@ -10,7 +10,7 @@ Status: implemented
## Decision
`dsh-terminal-bash` 新增一个经过校验的 `promptTailGraceMs` 配置字段,默认 `0`。当某次 send 已看到提示符标记而其可打印尾部尚未到达时,静默上界变为 `idleSilenceMs + handoffGraceMs + promptTailGraceMs`;其他上界、等待原因与代码路径均不变。零精确复现此前的上界,因此升级不改变任何部署的行为。持久 pwsh 的 Loader 组合用例设置 5000 毫秒,因为需要该字段的宿主机正是运行该用例的那些,而其断言(至少六次 `stdin_read` 结算、不含 `inferred_idle`)由此度量受控提示符路径,而不是宿主机的投递时序。`validateConfig` 允许该字段取零——这是唯一一个零为文档化取值的数值上界——并拒绝负数与小数。
`dsh-terminal-bash` 新增一个经过校验的 `promptTailGraceMs` 配置字段,默认 `0`。当某次 send 已看到提示符标记而其可打印尾部尚未到达时,静默上界变为 `idleSilenceMs + handoffGraceMs + promptTailGraceMs`;其他上界、等待原因与代码路径均不变。该上界所扩展的层级阶梯由[持久 PTY 就绪设计](../feature/2026-07-16-persistent-pty-sessions.zh.md)拥有。零精确复现此前的上界,因此升级不改变任何部署的行为。持久 pwsh 的 Loader 组合用例设置 5000 毫秒,因为需要该字段的宿主机正是运行该用例的那些,而其断言(至少六次 `stdin_read` 结算、不含 `inferred_idle`)由此度量受控提示符路径,而不是宿主机的投递时序。`validateConfig` 允许该字段取零——这是唯一一个零为文档化取值的数值上界——并拒绝负数与小数。
## Alternatives considered
@@ -28,4 +28,4 @@ Status: implemented
## Testing
`packages/terminal/terminal-bash/tests/session.spec.ts` 用假定时器固定两个方向:设置 `promptTailGraceMs` 时,尾部晚于普通上界到达的标记保持挂起,随后按 `stdin_read` 结算;字段为零时,同样的投递按 `inferred_idle` 结算。`packages/terminal/terminal-bash/tests/config.spec.ts` 接受零并拒绝负数或小数。`packages/shell/tool-pwsh-persistent/tests/loader-composition.spec.ts` 承载原生 Windows 上的端到端证据:把每个 session 的提示符尾部扣留四秒时,配置了该宽限后用例通过——九次结算、八次 `stdin_read` 加 `exit` 命令的 `session_exit`,耗时 33.4 秒与 37.5 秒;把该宽限改为零后同一停摆失败,其中两次 send 以 `inferred_idle` 结算,`promptSeen` 为 true、`promptTextSeen` 为 false、尾部为空且 `idleFor` 为 3312–3316 毫秒。
`packages/terminal/terminal-bash/tests/session.spec.ts` 用假定时器固定每一种状态:设置 `promptTailGraceMs` 时,尾部晚于普通上界到达的标记保持挂起,随后按 `stdin_read` 结算;尾部始终不到时在扩展上界处回落到 `inferred_idle`;尾部已到达但被后续输出失效时保持普通上界;字段为零时,同样的延迟投递按 `inferred_idle` 结算。`packages/terminal/terminal-bash/tests/config.spec.ts` 接受零、拒绝负数或小数,并拒绝非零但短于一个 `pollIntervalMs` 的宽限。`packages/shell/tool-pwsh-persistent/tests/loader-composition.spec.ts` 承载原生 Windows 上的端到端证据:把每个 session 的提示符尾部扣留四秒时,配置了该宽限后用例通过——九次结算、八次 `stdin_read` 加 `exit` 命令的 `session_exit`,耗时 33.4 秒与 37.5 秒;把该宽限改为零后同一停摆失败,其中两次 send 以 `inferred_idle` 结算,`promptSeen` 为 true、`promptTextSeen` 为 false、尾部为空且 `idleFor` 为 3312–3316 毫秒。
@@ -24,8 +24,8 @@
en: b92715f36550c728
zh: e18d7e0e2363347a
/agent-note-persistent-pty-sessions/decision/local-readiness-detection:
en: cd8b4032a624c277
zh: 47f0d8c15f1c9cad
en: 8f36f06869bd5367
zh: f3560477a8965cbe
/agent-note-persistent-pty-sessions/decision/model-visible-output-and-durability:
en: 1b504ab41b47216d
zh: 8fb3d360b3a4e6a1
@@ -72,13 +72,13 @@ With `run_in_background: true`, `dsh-tool-terminal` registers the in-flight send
### Local readiness detection
The local backend first recognizes a private OSC prompt marker emitted by its controlled bash startup, then requires the printable tail after the latest marker to exactly equal the controlled `PS1` before declaring prompt readiness and runs three bounded fallback tiers. Carrying that tail across data callbacks covers delivery where the marker and prompt arrive separately; requiring the exact tail rejects a delayed earlier prompt once echoed input or output follows it, so it cannot settle the current send. The marker is removed before output reaches the model and avoids a fixed silence delay for ordinary shell commands on both platforms. Unpublished startup does not accept zero-output silence as readiness; timeout rejects the spawn. If caller cancellation wins during startup, the backend closes the private session and propagates the exact `AbortSignal.reason`; a foreground PGID that is not observable yet cannot replace cancellation with a lookup error. All timings are validated config fields: `pollIntervalMs`, `exactProbeAfterMs`, `idleSilenceMs`, `handoffGraceMs`, and `timeoutMs`.
The local backend first recognizes a private OSC prompt marker emitted by its controlled bash startup, then requires the printable tail after the latest marker to exactly equal the controlled `PS1` before declaring prompt readiness and runs three bounded fallback tiers. Carrying that tail across data callbacks covers delivery where the marker and prompt arrive separately; requiring the exact tail rejects a delayed earlier prompt once echoed input or output follows it, so it cannot settle the current send. The marker is removed before output reaches the model and avoids a fixed silence delay for ordinary shell commands on both platforms. Unpublished startup does not accept zero-output silence as readiness; timeout rejects the spawn. If caller cancellation wins during startup, the backend closes the private session and propagates the exact `AbortSignal.reason`; a foreground PGID that is not observable yet cannot replace cancellation with a lookup error. All timings are validated config fields: `pollIntervalMs`, `exactProbeAfterMs`, `idleSilenceMs`, `handoffGraceMs`, `promptTailGraceMs`, and `timeoutMs`.
On Linux, the inspector reads the shell's terminal foreground PGID from `/proc/<shellPid>/stat`, enumerates every process and thread in that process group, and probes their current syscalls. A positive Tier 1 result requires an observed stdin wait: direct `read(0)`, a permitted read of a `select`/`pselect6` or `poll`/`ppoll` argument containing fd 0, or an epoll interest list containing fd 0. The waiting thread's `/proc/<pid>/task/<tid>/fd/0` must identify the shell's controlling terminal device, so a thread-local fd table cannot substitute its leader's terminal descriptor and a pipeline reader blocked on its pipe remains a running command. Direct PTY descriptors use their device number; `/dev/tty` uses the owning process's `tty_nr` because `stat` reports the alias device rather than the selected PTY. A wait already present before terminal input is not post-write readiness: the same PGID must be observed outside that wait before re-entering it, while a changed foreground PGID is new evidence. Unreadable process memory and unrecognized syscalls are misses, never positive guesses. A host policy that denies `/proc/<pid>/task/<tid>/syscall`, including hardened ptrace policy, likewise skips Tier 1 and preserves the bounded Tier 2 idle inference; process sleep state never substitutes for inaccessible syscall evidence. Architecture tables contain only syscall numbers defined by the corresponding Linux UAPI; the inspector admits the runtime architecture, then checks every supported kernel ABI because `/proc` can report a different ABI under user-mode emulation. Unsupported runtime architectures skip Tier 1.
On macOS there is no exact syscall tier. Output silence returns `inferred_idle` for any foreground process group, including Python and `gdb`; `ps`-derived terminal PGID is used for signaling, not as proof that only the shell can be idle. Pure process-inspector logic is injectable and unit-tested on Linux, while a macOS CI job exercises the real PTY and process-table path.
Tier 2 returns `inferred_idle` after `idleSilenceMs` without output. A sleeping or network-blocked command can therefore look ready. When a prompt marker was already seen, Tier 2 waits a further `handoffGraceMs` so a bash foreground handoff that lands on the silence boundary still settles as the exact `stdin_read` attribution instead of the weaker inference; the grace is a deployment-owned config field validated to cover at least one `pollIntervalMs`, because a grace shorter than the poll period cannot contain a single readiness poll and so cannot change any outcome. It bounds only sends that saw a marker, so its cost is the interactive return latency of that one case rather than every send. Tier 3 returns `timeout` after `timeoutMs` so a foreground tool call cannot hold the agent indefinitely. The result preserves the distinction; callers may wait through `ctx.jobs`, signal the foreground group, or inspect from another session.
Tier 2 returns `inferred_idle` after `idleSilenceMs` without output. A sleeping or network-blocked command can therefore look ready. When a prompt marker was already seen, Tier 2 waits a further `handoffGraceMs` so a bash foreground handoff that lands on the silence boundary still settles as the exact `stdin_read` attribution instead of the weaker inference; the grace is a deployment-owned config field validated to cover at least one `pollIntervalMs`, because a grace shorter than the poll period cannot contain a single readiness poll and so cannot change any outcome. A marker whose printable tail has not arrived yet adds `promptTailGraceMs` to that bound, because the marker and its tail are written by the same prompt render and a contended host delays only the tail; the [prompt tail grace decision](../bug-fix/2026-09-27-pwsh-prompt-tail-grace.md) owns the field, and it applies only while the tail can still complete. It bounds only sends that saw a marker, so its cost is the interactive return latency of that one case rather than every send. Tier 3 returns `timeout` after `timeoutMs` so a foreground tool call cannot hold the agent indefinitely. The result preserves the distinction; callers may wait through `ctx.jobs`, signal the foreground group, or inspect from another session.
Once a send settles under any tier, `TerminalSendOperation.append` stops accepting output, so later child output no longer reaches that settled operation; it still reaches the scrollback, and any send that is active when it arrives. A test that waits for a marker on the operation it started must therefore set `idleSilenceMs` and `timeoutMs` above the child's own startup latency; interpreter startup on a loaded macOS runner otherwise ends the send before the marker is printed.
@@ -72,13 +72,13 @@ UI 渲染约定精确且不携带位置信息。`terminal_send` 只为前台发
### 本地就绪检测
本地后端先识别受控 bash 启动时发出的私有 OSC prompt marker,并且只有在最近一个 marker 后的可打印尾部与受控 `PS1` 完全相等时才声明 prompt 就绪;除此之外,它还运行 3 个有界 fallback 层级。在 data callback 之间保留该尾部,可以适配 marker 与 prompt 被分开交付的情况;如果回显的输入或输出跟在延迟到达的先前 prompt 之后,要求尾部完全相等会拒绝该 prompt,使其无法完成当前 send。marker 在输出到达模型前被移除,使两个平台上的普通 shell 命令都无需固定等待静默阈值。尚未发布的 startup 不会把零输出静默视为就绪;timeout 会拒绝 spawn。若调用方取消在 startup 期间胜出,后端会关闭私有会话并原样抛出 `AbortSignal.reason`;尚不可观察的前台 PGID 不会再用查找错误覆盖取消原因。所有时间参数都是经校验的配置字段:`pollIntervalMs`、`exactProbeAfterMs`、`idleSilenceMs`、`handoffGraceMs` 和 `timeoutMs`。
本地后端先识别受控 bash 启动时发出的私有 OSC prompt marker,并且只有在最近一个 marker 后的可打印尾部与受控 `PS1` 完全相等时才声明 prompt 就绪;除此之外,它还运行 3 个有界 fallback 层级。在 data callback 之间保留该尾部,可以适配 marker 与 prompt 被分开交付的情况;如果回显的输入或输出跟在延迟到达的先前 prompt 之后,要求尾部完全相等会拒绝该 prompt,使其无法完成当前 send。marker 在输出到达模型前被移除,使两个平台上的普通 shell 命令都无需固定等待静默阈值。尚未发布的 startup 不会把零输出静默视为就绪;timeout 会拒绝 spawn。若调用方取消在 startup 期间胜出,后端会关闭私有会话并原样抛出 `AbortSignal.reason`;尚不可观察的前台 PGID 不会再用查找错误覆盖取消原因。所有时间参数都是经校验的配置字段:`pollIntervalMs`、`exactProbeAfterMs`、`idleSilenceMs`、`handoffGraceMs`、`promptTailGraceMs` 和 `timeoutMs`。
在 Linux 上,检查器从 `/proc/<shellPid>/stat` 读取 shell 的终端前台 PGID,枚举该进程组中的每个进程与线程,并检查它们当前的 syscall。Tier 1 只有观察到 stdin 等待才返回正结果:直接 `read(0)`、获准读取且含 fd 0 的 `select`/`pselect6` 或 `poll`/`ppoll` 参数,或者含 fd 0 的 epoll interest list。等待线程的 `/proc/<pid>/task/<tid>/fd/0` 还必须标识 shell 的控制终端设备,因此线程本地 fd 表无法用 leader 的终端描述符冒充自身 fd,阻塞于管道的流水线读取端仍属于正在运行的命令。直接 PTY 描述符使用其设备号;`/dev/tty` 则使用所属进程的 `tty_nr`,因为 `stat` 报告的是别名设备而非选定的 PTY。终端输入前就已存在的等待并不代表写入后就绪:必须先观察到同一 PGID 脱离该等待,之后再次进入等待才能使该次 send 完成;前台 PGID 发生变化则构成新的证据。无法读取的进程内存和未识别的 syscall 都是 miss,绝不作为正向猜测。宿主策略(包括加固的 ptrace 策略)若拒绝读取 `/proc/<pid>/task/<tid>/syscall`,同样会跳过 Tier 1 并保留有界的 Tier 2 idle 推断;进程休眠状态绝不会代替不可访问的 syscall 证据。架构表只包含对应 Linux UAPI 定义的 syscall number;检查器先准入运行时架构,再检查每个受支持的内核 ABI,因为在用户态模拟下,`/proc` 可能报告不同的 ABI。不受支持的运行时架构会跳过 Tier 1。
macOS 没有精确 syscall 层。任何前台进程组输出静默都会返回 `inferred_idle`,包括 Python 和 `gdb`;从 `ps` 推导的终端 PGID 只用于发送信号,不作为「只有 shell 才能 idle」的证明。纯进程检查逻辑可注入,并在 Linux 上经过单元测试,同时由 macOS CI job 驱动真实 PTY 和进程表路径。
Tier 2 在持续 `idleSilenceMs` 没有输出后返回 `inferred_idle`,因此 sleep 或网络阻塞的命令可能看似 ready。如果此前已经见过 prompt marker,Tier 2 会再等待 `handoffGraceMs`,使恰好落在静默边界上的 bash 前台交接仍然以精确的 `stdin_read` 归因结束,而不是退到较弱的推断;该宽限是由部署方拥有的配置字段,并被校验为至少覆盖一个 `pollIntervalMs`——短于轮询周期的宽限装不下一次就绪轮询,因此不可能改变任何结果。它只约束见过 marker 的 send,代价是这一种情况的交互返回延迟,而不是每一次 send。Tier 3 在 `timeoutMs` 后返回 `timeout`,避免前台工具调用无限占住 agent。结果保留这些区别;调用方可以通过 `ctx.jobs` 等待、向前台组发信号,或从另一个会话排查。
Tier 2 在持续 `idleSilenceMs` 没有输出后返回 `inferred_idle`,因此 sleep 或网络阻塞的命令可能看似 ready。如果此前已经见过 prompt marker,Tier 2 会再等待 `handoffGraceMs`,使恰好落在静默边界上的 bash 前台交接仍然以精确的 `stdin_read` 归因结束,而不是退到较弱的推断;该宽限是由部署方拥有的配置字段,并被校验为至少覆盖一个 `pollIntervalMs`——短于轮询周期的宽限装不下一次就绪轮询,因此不可能改变任何结果。若已见 marker 而其可打印尾部尚未到达,该上界还会加上 `promptTailGraceMs`:marker 与尾部由同一次提示符渲染写出,负载高的宿主机只会拖慢尾部;该字段由[提示符尾部宽限决策](../bug-fix/2026-09-27-pwsh-prompt-tail-grace.zh.md)拥有,且只在尾部仍可能补齐时生效。它只约束见过 marker 的 send,代价是这一种情况的交互返回延迟,而不是每一次 send。Tier 3 在 `timeoutMs` 后返回 `timeout`,避免前台工具调用无限占住 agent。结果保留这些区别;调用方可以通过 `ctx.jobs` 等待、向前台组发信号,或从另一个会话排查。
一次 send 在任一层级 settle 之后,`TerminalSendOperation.append` 就不再接受输出,此后子进程的输出不会再进入那个已 settle 的 operation;它仍然会进入 scrollback,以及此时恰好处于活跃状态的任何 send。因此,等待自己所启动的 operation 上出现标记的测试,必须把 `idleSilenceMs` 与 `timeoutMs` 设得高于子进程自身的启动耗时;否则在负载较高的 macOS runner 上,解释器启动会在标记打印之前就结束这次 send。
@@ -102,9 +102,9 @@ describe.skipIf(!hasPwsh)('persistent pwsh through a real cordis.yml Loader comp
' handoffGraceMs: 300',
// The self-hosted Windows pool stalls the console renderer for seconds (issue 2487): the
// OSC marker reaches the session while the five-byte prompt tail that follows it does not
// arrive until the plain silence bound has passed, and every such send used to settle as
// inferred_idle. The tolerance keeps those sends on the controlled-prompt path this case
// pins, without letting a missing prompt (no marker at all) escape the silence tier.
// arrive until the plain silence bound has passed, and every such send would otherwise
// settle as inferred_idle. The tolerance keeps those sends on the controlled-prompt path
// this case pins, without letting a missing prompt (no marker at all) escape the silence tier.
' promptTailGraceMs: 5000',
' scrollbackLines: 20000',
// The first call pays the full pwsh cold-start latency (spawn + .NET +
@@ -24,8 +24,8 @@
en: 71449ef1985b2f1b
zh: 562e9b5ef73cd5e1
/deepseek-ai-dsh-terminal-bash/use-this-package/configuration:
en: c5d0197950c2207a
zh: 0e7b8b08fd6e4b0e
en: 2a10fc681cee698d
zh: ac60c8918bae3c85
/deepseek-ai-dsh-terminal-bash/use-this-package/shell-dialects-and-readiness:
en: 2cab01d85f524e3d
zh: 0d00b3e399a935b1
@@ -56,7 +56,6 @@ Mount the terminal service, a subprocess provider, the sandbox and policy servic
| `maxReadBytes` | `262144` | Maximum UTF-8 bytes returned by one read or settled send |
| `timeoutMs` | `30000` | Absolute bound on one send wait |
| `disposeGraceMs` | `3000` | Grace before teardown escalates to `SIGKILL` |
| `promptTailGraceMs` | `0` | Extra wait for a seen prompt marker's printable tail before `inferred_idle` settles |
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`, `promptTailGraceMs`), terminal size (`rows`, `cols`), and scrollback bounds (`scrollbackLines`, `scrollbackMaxBytes`).
@@ -56,7 +56,6 @@ kind: "package-reference"
| `maxReadBytes` | `262144` | 一次读取或一次结算发送返回的最大 UTF-8 字节数 |
| `timeoutMs` | `30000` | 一次发送等待的绝对上限 |
| `disposeGraceMs` | `3000` | 清理升级到 `SIGKILL` 前的宽限时间 |
| `promptTailGraceMs` | `0` | 已看到提示符标记后,等待其可打印尾部再按 `inferred_idle` 结算的额外时长 |
生成的[配置目录](../../../docs/config-catalog.zh.md#deepseek-aidsh-terminal-bash)是每个字段的穷尽式真源,包括就绪计时(`pollIntervalMs`、`exactProbeAfterMs`、`idleSilenceMs`、`handoffGraceMs`、`promptTailGraceMs`)、终端尺寸(`rows`、`cols`)与 scrollback 上限(`scrollbackLines`、`scrollbackMaxBytes`)。
@@ -42,7 +42,8 @@ export interface Config {
* its printable tail has not arrived, before `inferred_idle` settles. The marker is written
* by the shell's own prompt function and the tail by the same render, so a missing tail is a
* delivery delay on a contended host rather than an absent prompt. Zero keeps the bound at
* `idleSilenceMs + handoffGraceMs`.
* `idleSilenceMs + handoffGraceMs`; any other value covers at least one `pollIntervalMs`, so a
* nonzero tolerance always contains a readiness poll.
*/
promptTailGraceMs?: number
/** Absolute bound for one send and the complete pwsh startup sequence. */
@@ -135,4 +136,7 @@ export function validateConfig(config: Config): asserts config is ResolvedConfig
if (resolved.handoffGraceMs < resolved.pollIntervalMs) {
throw new Error('terminal-bash: handoffGraceMs must be at least pollIntervalMs so one readiness poll runs inside the grace window')
}
if (resolved.promptTailGraceMs !== 0 && resolved.promptTailGraceMs < resolved.pollIntervalMs) {
throw new Error('terminal-bash: promptTailGraceMs must be zero or at least pollIntervalMs so a nonzero tolerance contains one readiness poll')
}
}
@@ -584,12 +584,14 @@ export class LocalPtySession implements TerminalBackendSession {
// on waiting for shell ownership instead of letting a child marker suppress
// readiness until the absolute timeout.
const handoffGrace = this.promptSeen ? this.config.handoffGraceMs : 0
// A seen marker whose printable tail has not arrived is the prompt function's own
// evidence that the shell reached its prompt: the tail is written by the same render,
// so its absence within the bound is a delivery delay rather than an absent prompt.
// The configured tolerance keeps the send on the exact path instead of charging it the
// silence tier; zero restores the plain `idleSilenceMs + handoffGraceMs` bound.
const tailGrace = this.promptSeen && !this.promptTextSeen ? this.config.promptTailGraceMs : 0
// A seen marker whose printable tail has not arrived yet is the prompt function's own
// evidence that the shell reached its prompt: the tail is written by the same render, so
// its absence within the bound is a delivery delay rather than an absent prompt. The
// configured tolerance holds the send on the exact path for that state alone: a tail that
// arrived and was then invalidated by later output can no longer complete, so it keeps the
// plain bound. Zero leaves the bound at `idleSilenceMs + handoffGraceMs`.
const tailPending = this.promptSeen && !this.promptTextSeen && CONTROLLED_PROMPT.startsWith(this.promptTail)
const tailGrace = tailPending ? this.config.promptTailGraceMs : 0
if (startupHasOutput && idleFor >= this.config.idleSilenceMs + handoffGrace + tailGrace) {
this.settleActive('inferred_idle')
}
@@ -37,6 +37,12 @@ describe('terminal-bash config', () => {
expect(() => { validateConfig(config({ promptTailGraceMs: 1.5 })) }).toThrow('promptTailGraceMs must be a non-negative safe integer')
})
it('accepts the prompt tail grace at zero or at least one readiness poll', () => {
expect(() => { validateConfig(config({ promptTailGraceMs: 5, pollIntervalMs: 10 })) }).toThrow('promptTailGraceMs must be zero or at least pollIntervalMs')
expect(() => { validateConfig(config({ promptTailGraceMs: 10, pollIntervalMs: 10 })) }).not.toThrow()
expect(() => { validateConfig(config({ promptTailGraceMs: 0, pollIntervalMs: 10 })) }).not.toThrow()
})
})
describe('terminal-bash dialect resolution', () => {
@@ -578,6 +578,53 @@ describe('LocalPtySession readiness and output', () => {
}
}, 5_000)
it('settles inferred_idle at the extended bound when the prompt tail never arrives', async () => {
vi.useFakeTimers()
const terminal = new FakeTerminal()
const session = new LocalPtySession(terminal, config({
idleSilenceMs: 50, handoffGraceMs: 10, promptTailGraceMs: 200, timeoutMs: 1_000,
}))
try {
await initialize(session, terminal)
const operation = session.startSend({ text: 'silent-render', submit: true })
await Promise.resolve()
await Promise.resolve()
let settled: string | undefined
void operation.done.then((result) => { settled = result.waitReason })
terminal.emitData('\x1b]133;D;0\x07')
await vi.advanceTimersByTimeAsync(70)
expect(settled).toBeUndefined()
await vi.advanceTimersByTimeAsync(200)
expect((await operation.done).waitReason).toBe('inferred_idle')
} finally {
await session.close('prompt tail expiry cleanup')
}
}, 5_000)
it('keeps the plain silence bound once later output invalidated the prompt tail', async () => {
vi.useFakeTimers()
const terminal = new FakeTerminal()
const session = new LocalPtySession(terminal, config({
idleSilenceMs: 50, handoffGraceMs: 10, promptTailGraceMs: 200, timeoutMs: 1_000,
}))
try {
await initialize(session, terminal)
const operation = session.startSend({ text: 'noisy-command', submit: true })
await Promise.resolve()
await Promise.resolve()
// The prompt arrives and is immediately followed by command output, so the tail is no
// longer completable and the tolerance has nothing left to wait for.
terminal.emitData('\x1b]133;D;0\x07dsh> ')
terminal.emitData('partial output')
await vi.advanceTimersByTimeAsync(70)
expect((await operation.done).waitReason).toBe('inferred_idle')
} finally {
await session.close('prompt tail invalidation cleanup')
}
}, 5_000)
it('captures prompt MOTD, writes submit explicitly, and settles exact stdin waits', async () => {
vi.useFakeTimers()
const terminal = new FakeTerminal()