Merge pull request #726 from deepseek-harness/fix/pty-handoff-grace

fix(pty-local): make the foreground-handoff grace a config field
This commit is contained in:
Tianyi Cui
2026-07-28 01:26:27 +08:00
committed by GitHub
15 changed files with 82 additions and 23 deletions

View File

@@ -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/feature/2026-07-16-persistent-pty-sessions.md
2026-07-16-persistent-pty-sessions.md: 4fff1742721fa13ea11f1b8ec833e5e5b7e68df8
2026-07-16-persistent-pty-sessions.zh.md: 88e7a0bc4b8af21dc51b6a654a1c9f93760b3e81
2026-07-16-persistent-pty-sessions.md: 55ab262b89526911e16a3fec5dd624fc42fb2222
2026-07-16-persistent-pty-sessions.zh.md: 525d199298bc071c36444d689368317968186c9f

View File

@@ -72,13 +72,13 @@ With `run_in_background: true`, `dsh-tool-pty` registers the in-flight send on `
### Local readiness detection
The local backend first recognizes a private OSC prompt marker emitted by its controlled bash startup, then requires printable prompt text after that marker before declaring prompt readiness and runs three bounded fallback tiers. Carrying that state across data callbacks covers macOS delivery where the OSC marker and `PS1` arrive separately; the marker alone can no longer publish an empty MOTD. 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`, and `timeoutMs`.
The local backend first recognizes a private OSC prompt marker emitted by its controlled bash startup, then requires printable prompt text after that marker before declaring prompt readiness and runs three bounded fallback tiers. Carrying that state across data callbacks covers macOS delivery where the OSC marker and `PS1` arrive separately; the marker alone can no longer publish an empty MOTD. 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`.
On Linux, the inspector reads the shell's terminal foreground PGID from `/proc/<shellPid>/stat`, enumerates every process and thread in that process group, and probes their current syscalls. A positive Tier 1 result requires an observed stdin wait: direct `read(0)`, a permitted read of a `select`/`pselect6` or `poll`/`ppoll` argument containing fd 0, or an epoll interest list containing fd 0. Unreadable process memory and unrecognized syscalls are misses, never positive guesses. Architecture tables contain only syscall numbers defined by the corresponding Linux UAPI; unsupported architectures skip Tier 1.
On 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. Tier 3 returns `timeout` after `timeoutMs` so a foreground tool call cannot hold the agent indefinitely. The result preserves the distinction; callers may wait through `ctx.tasks`, signal the foreground group, or inspect from another session.
Tier 2 returns `inferred_idle` after `idleSilenceMs` without output. A sleeping or network-blocked command can therefore look ready. 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.tasks`, signal the foreground group, or inspect from another session.
Once a send settles under any tier, `PtySendOperation.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.
@@ -116,6 +116,7 @@ plugins:
pollIntervalMs: 50
exactProbeAfterMs: 150
idleSilenceMs: 3000
handoffGraceMs: 500
timeoutMs: 30000
disposeGraceMs: 3000
'@deepseek-ai/dsh-tool-pty':
@@ -154,7 +155,7 @@ The package ships concise tool guidance explaining persistent state, owner isola
## Verification
- Per-file coverage pins owner fencing, concurrent reservations, unpublished-spawn cancellation and awaited teardown, sandbox-mode change rejection, retriable lifecycle cleanup, readiness tiers, sanitizer carry state, complete UTF-8 bounds, task integration, schemas, and exact render intents.
- Per-file coverage pins owner fencing, concurrent reservations, unpublished-spawn cancellation and awaited teardown, sandbox-mode change rejection, retriable lifecycle cleanup, readiness tiers, the configured handoff grace holding the idle fallback past one poll and its rejection below `pollIntervalMs`, sanitizer carry state, complete UTF-8 bounds, task integration, schemas, and exact render intents.
- Linux process fixtures cover non-leader and non-main-thread stdin waits, zombie quiescence, unreadable process state, supported syscall tables, unsupported architectures, and false-positive rejection; macOS inspector logic is injected into the same unit suite.
- Real `node-pty` tests exercise shell state, shared sandbox policy, environment scrubbing, raw-mode foreground `SIGINT` after deliberately delayed child readiness under scenario-owned timing bounds, a TERM-ignoring descendant, and immediate post-disposal quiescence on supported hosts.
- A Loader-driven `cordis.yml` test mounts the real three-package composition. ACP and headless snapshots pin the six schemas, bounded results, and errors through opt-in overlays; TUI snapshots pin terminal and generic card presentation.
@@ -167,6 +168,8 @@ The package ships concise tool guidance explaining persistent state, owner isola
**Idle below Linux Tier 1 is heuristic.** Output silence cannot distinguish a prompt from sleep or network I/O. The typed result preserves uncertainty, and bounded timeout plus task waiting and signaling keep control with the model.
**The exact-versus-inferred boundary is a latency trade, not a solvable race.** Attribution depends on whether the kernel publishes the foreground handoff before or after the silence bound elapses, so any fixed grace is a scheduling bet. `handoffGraceMs` puts that bet in deployment configuration: raising it buys exact `stdin_read` attribution on a slow or loaded host at the cost of interactive return latency after a prompt marker, and lowering it does the reverse. Tests that must not depend on the winner assert the observable behavior — the next send runs — rather than the attribution.
**Persistent state can drift from the model's belief.** The model may forget its cwd or active REPL. Session summaries and retained output help recovery, but no prompt can make state persistence deterministic.
**A daemonized descendant can leave the captured tree.** A process that reparents before teardown is no longer discoverable from the `node-pty` root. The implementation accepts that cleanup gap instead of risking SID-wide signals to unrelated processes.

View File

@@ -72,13 +72,13 @@ UI 渲染契约精确且不携带位置信息。`terminal_send` 只为前台发
### 本地就绪检测
本地后端先识别受控 bash 启动时发出的私有 OSC prompt marker并且只有在该 marker 后出现可打印的 prompt 文本时才据此声明 prompt 就绪;除此之外,它还运行 3 个有界 fallback 层级。在 data callback 之间保留这项状态,可以适配 macOS 分开交付 OSC marker 与 `PS1` 的情况;单独的 marker 不会发布空 MOTD。marker 在输出到达模型前被移除,使两个平台上的普通 shell 命令都无需固定等待静默阈值。尚未发布的 startup 不会把零输出静默视为就绪timeout 会拒绝 spawn。若调用方取消在 startup 期间胜出,后端会关闭私有会话并原样抛出 `AbortSignal.reason`;尚不可观察的前台 PGID 不会再用查找错误覆盖取消原因。所有时间参数都是经校验的配置字段:`pollIntervalMs``exactProbeAfterMs``idleSilenceMs``timeoutMs`
本地后端先识别受控 bash 启动时发出的私有 OSC prompt marker并且只有在该 marker 后出现可打印的 prompt 文本时才据此声明 prompt 就绪;除此之外,它还运行 3 个有界 fallback 层级。在 data callback 之间保留这项状态,可以适配 macOS 分开交付 OSC marker 与 `PS1` 的情况;单独的 marker 不会发布空 MOTD。marker 在输出到达模型前被移除,使两个平台上的普通 shell 命令都无需固定等待静默阈值。尚未发布的 startup 不会把零输出静默视为就绪timeout 会拒绝 spawn。若调用方取消在 startup 期间胜出,后端会关闭私有会话并原样抛出 `AbortSignal.reason`;尚不可观察的前台 PGID 不会再用查找错误覆盖取消原因。所有时间参数都是经校验的配置字段:`pollIntervalMs``exactProbeAfterMs``idleSilenceMs``handoffGraceMs``timeoutMs`
在 Linux 上,检查器从 `/proc/<shellPid>/stat` 读取 shell 的终端前台 PGID枚举该进程组中的每个进程与线程并检查它们当前的 syscall。Tier 1 只有观察到 stdin 等待才返回正结果:直接 `read(0)`、获准读取且含 fd 0 的 `select`/`pselect6``poll`/`ppoll` 参数,或者含 fd 0 的 epoll interest list。无法读取的进程内存和未识别的 syscall 都是 miss绝不作为正向猜测。架构表只包含对应 Linux UAPI 定义的 syscall number不支持的架构跳过 Tier 1。
macOS 没有精确 syscall 层。任何前台进程组输出静默都会返回 `inferred_idle`,包括 Python 和 `gdb`;从 `ps` 推导的终端 PGID 只用于发送信号,不作为「只有 shell 才能 idle」的证明。纯进程检查逻辑可注入并在 Linux 上完成 unit 覆盖率,同时由 macOS CI job 驱动真实 PTY 和进程表路径。
Tier 2 在持续 `idleSilenceMs` 没有输出后返回 `inferred_idle`,因此 sleep 或网络阻塞的命令可能看似 ready。Tier 3 在 `timeoutMs` 后返回 `timeout`,避免前台工具调用无限占住 agent。结果保留这些区别调用方可以通过 `ctx.tasks` 等待、向前台组发信号,或从另一个会话排查。
Tier 2 在持续 `idleSilenceMs` 没有输出后返回 `inferred_idle`,因此 sleep 或网络阻塞的命令可能看似 ready。如果此前已经见过 prompt markerTier 2 会再等待 `handoffGraceMs`,使恰好落在静默边界上的 bash 前台交接仍然以精确的 `stdin_read` 归因结束,而不是退到较弱的推断;该宽限是由部署方拥有的配置字段,并被校验为至少覆盖一个 `pollIntervalMs`——短于轮询周期的宽限装不下一次就绪轮询,因此不可能改变任何结果。它只约束见过 marker 的 send代价是这一种情况的交互返回延迟而不是每一次 send。Tier 3 在 `timeoutMs` 后返回 `timeout`,避免前台工具调用无限占住 agent。结果保留这些区别调用方可以通过 `ctx.tasks` 等待、向前台组发信号,或从另一个会话排查。
一次 send 在任一层级 settle 之后,`PtySendOperation.append` 就不再接受输出,此后子进程的输出不会再进入那个已 settle 的 operation它仍然会进入 scrollback以及此时恰好处于活跃状态的任何 send。因此等待自己所启动的 operation 上出现标记的测试,必须把 `idleSilenceMs``timeoutMs` 设得高于子进程自身的启动耗时;否则在负载较高的 macOS runner 上,解释器启动会在标记打印之前就结束这次 send。
@@ -116,6 +116,7 @@ plugins:
pollIntervalMs: 50
exactProbeAfterMs: 150
idleSilenceMs: 3000
handoffGraceMs: 500
timeoutMs: 30000
disposeGraceMs: 3000
'@deepseek-ai/dsh-tool-pty':
@@ -154,7 +155,7 @@ plugins:
## 验证
- 每文件覆盖率固定 owner 隔离、并发预留、未发布 spawn 的取消与等待式 teardown、沙箱模式变更拒绝、可重试的生命周期清理、就绪层级、sanitizer carry state、完整 UTF-8 结果上限、task 集成、schema 和精确 render intent。
- 每文件覆盖率固定 owner 隔离、并发预留、未发布 spawn 的取消与等待式 teardown、沙箱模式变更拒绝、可重试的生命周期清理、就绪层级、配置化交接宽限把 idle fallback 顶过一次轮询以及低于 `pollIntervalMs` 时的拒绝、sanitizer carry state、完整 UTF-8 结果上限、task 集成、schema 和精确 render intent。
- Linux 进程 fixture 覆盖非 leader 与非主线程的 stdin 等待、僵尸进程静止性、不可读进程状态、受支持的 syscall 表、不支持的架构和误报拒绝;同一单元测试套件通过注入覆盖 macOS 检查器逻辑。
- 真实 `node-pty` 测试在受支持宿主上覆盖 shell 状态、共享沙箱策略、环境清洗、在由场景掌控的时间界限内先有意延迟子进程就绪,再对 raw mode 前台进程发送 `SIGINT`、忽略 `SIGTERM` 的子进程,以及 dispose 返回后立即完全停稳。
- Loader 驱动的 `cordis.yml` 测试挂载真实三包组合。ACP 与 headless 快照通过 opt-in overlay 固定 6 个 schema、有界结果和错误TUI 快照固定 terminal 与 generic 卡片展示。
@@ -167,6 +168,8 @@ plugins:
**Linux Tier 1 之外的 idle 都是启发式结果。**输出静默无法区分 prompt、sleep 和网络 I/O。类型化结果保留不确定性有界 timeout、task 等待与信号让模型仍能掌握控制权。
**精确归因与推断归因的边界是延迟取舍,不是可消除的竞态。**归因取决于内核在静默上限到达之前还是之后发布前台交接,因此任何固定宽限都是一次调度上的赌注。`handoffGraceMs` 把这个赌注交给部署配置:调大它可以在慢速或高负载主机上换到精确的 `stdin_read` 归因,代价是见过 prompt marker 之后的交互返回延迟;调小则相反。不应依赖胜负结果的测试断言可观察行为——下一次 send 能正常执行——而不是断言归因路径。
**持久状态可能偏离模型认知。**模型可能忘记 cwd 或活跃 REPL。会话摘要和保留输出有助恢复但任何 prompt 都无法让状态持久化变成确定行为。
**daemonized 子进程可能离开捕获树。**在 teardown 前 reparent 的进程无法再从 `node-pty` 根进程发现。实现接受这个清理缺口,不冒险按 SID 向无关进程发送信号。

View File

@@ -877,6 +877,11 @@ export interface Config {
exactProbeAfterMs?: number
/** Silence duration that yields `inferred_idle`. */
idleSilenceMs?: number
/**
* Extra wait beyond `idleSilenceMs`, once a prompt marker was seen, for the shell to
* regain the foreground before `inferred_idle` settles; at least one `pollIntervalMs`.
*/
handoffGraceMs?: number
/** Absolute send wait bound. */
timeoutMs?: number
/** Grace before teardown escalates to `SIGKILL`. */

View File

@@ -14,6 +14,7 @@
pollIntervalMs: 10
exactProbeAfterMs: 20
idleSilenceMs: 250
handoffGraceMs: 250
timeoutMs: 2000
disposeGraceMs: 500
- id: tool-pty

View File

@@ -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
README.md: 0ac80db3571a1c9a8c472e12a675eba17031cf5e
README.zh.md: d6f7c3639bcff570dd9d7be8615bfeadc222f2b3
README.md: abc7d9bc81b6ec43bed6277de7f42ddf5728c7a8
README.zh.md: 7a99a303ac3b6c83e2acd0f8154e9b313ef673d1

View File

@@ -8,7 +8,7 @@ Local Linux/macOS `node-pty` backend for `ctx.pty`; loading it on another platfo
The plugin injects `pty`, `sandbox`, and `sandboxPolicy`, then registers the configured backend type (`shell`). `danger-full-access` starts the shell directly; confined modes wrap the exact shell argv through `ctx.sandbox`. The effective session mode is resolved at spawn. 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 local-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.
Linux readiness combines a foreground-verified private bash prompt marker, foreground-process-group syscall inspection, silence fallback, and absolute timeout. macOS uses the verified prompt marker plus silence/timeout because it has no `/proc` syscall surface. A marker is not ready until printable prompt text arrives, including when the OSC marker and `PS1` are split across data callbacks; when bash prints the marker before the kernel publishes its return to the foreground process group, polling retains the candidate through one final poll after 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. Unrecognized or unreadable process state is never a positive exact-idle signal. 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 even when its foreground process group is not observable yet; if that close fails, `PtyBackendCleanupError` separately preserves the cleanup failure for registry disposal. Incomplete terminal-control sequences are bounded by `maxReadBytes` and discarded through their terminator after crossing that limit; a trailing carriage return is carried across callbacks so split CRLF becomes one newline.
Linux readiness combines a foreground-verified private bash prompt marker, foreground-process-group syscall inspection, silence fallback, and absolute timeout. macOS uses the verified prompt marker plus silence/timeout because it has no `/proc` syscall surface. A marker is not ready until printable prompt text arrives, including when the OSC marker and `PS1` are split across data callbacks; when bash prints the marker before the kernel 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; that grace must cover at least one `pollIntervalMs` and is rejected at load otherwise. An interactive child that inherits `PROMPT_COMMAND` therefore cannot suppress inferred-idle readiness until the absolute timeout. Unrecognized or unreadable process state is never a positive exact-idle signal. 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 even when its foreground process group is not observable yet; if that close fails, `PtyBackendCleanupError` separately preserves the cleanup failure for registry disposal. Incomplete terminal-control sequences are bounded by `maxReadBytes` and discarded through their terminator after crossing that limit; a trailing carriage return is carried across callbacks so split CRLF becomes one newline.
Send cancellation resolves the current foreground process group and delivers a real `SIGINT`; it never emulates interruption by writing `\x03`, so raw-mode programs remain cancellable. Close sends `SIGTERM` to descendants, waits, then sends `SIGKILL` to the union of captured survivors and newly scanned descendants so reparenting cannot hide a process from teardown. It verifies that every retained identity is gone or, on Linux, a non-executing zombie before stopping the shell; zombie entries are quiescent and are reaped as the shell exits. A survivor failure does not cache a permanently rejected close; a later close retries the teardown.

View File

@@ -8,7 +8,7 @@
该插件注入 `pty``sandbox``sandboxPolicy`,然后注册所配置的后端类型(`shell`)。`danger-full-access` 会直接启动 shell受限模式则通过 `ctx.sandbox` 包装确切的 shell argv。系统在 spawn 时解析会话的实际模式。当某个所有者存在开放的 PTY 或正在进行 spawn 时,如果配置变更会得到不同的实际模式,系统会在对应 `sandbox/mode` 事件提交前拒绝该变更。该限制绑定到确切所有者,因此即使本地提供方重新加载并保留现有会话,它仍然有效。更改模式前,请等待创建结算并关闭会话,避免以更宽权限打开的终端在权限降级后继续存在。
Linux 的就绪检测结合以下机制:由前台状态验证的私有 bash 提示符标记、前台进程组 syscall 检查、静默回退和绝对超时。macOS 没有 `/proc` syscall 接口,因此使用经过验证的提示符标记以及静默/超时。当可打印的提示符文本尚未到达时,即使 OSC 标记和 `PS1` 被拆到多个数据回调中,系统也不会把标记视为就绪。如果 bash 在内核发布其重新取得前台进程组的状态前打印标记,轮询会将该候选状态保留到普通静默上限之后的最后一次轮询,使恰好同时发生的前台交接有机会胜出。因此,继承 `PROMPT_COMMAND` 的交互式子进程无法持续压制推断空闲就绪,最多只能延续到绝对超时。无法识别或读取的进程状态绝不会作为精确空闲的正向信号。尚未发布的启动过程中,回退路径要求已经观察到输出;零输出静默不能发布空会话,超时则拒绝 spawn。取消操作会关闭尚未发布的 shell并以调用方提供的确切中止原因拒绝即使当时还无法观察其前台进程组。如果关闭失败`PtyBackendCleanupError` 会单独保留清理失败,供注册表释放资源时处理。未完成的终端控制序列受 `maxReadBytes` 限制;超过上限后,系统会丢弃内容直到其终止符。末尾的回车会跨回调保留,使拆分的 CRLF 合并为一个换行。
Linux 的就绪检测结合以下机制:由前台状态验证的私有 bash 提示符标记、前台进程组 syscall 检查、静默回退和绝对超时。macOS 没有 `/proc` syscall 接口,因此使用经过验证的提示符标记以及静默/超时。当可打印的提示符文本尚未到达时,即使 OSC 标记和 `PS1` 被拆到多个数据回调中,系统也不会把标记视为就绪。如果 bash 在内核发布其重新取得前台进程组的状态前打印标记,轮询会在普通静默上限之后再保留该候选状态 `handoffGraceMs`,使恰好同时发生的前台交接有机会胜出;该宽限至少要覆盖一个 `pollIntervalMs`,否则加载时即被拒绝。因此,继承 `PROMPT_COMMAND` 的交互式子进程无法持续压制推断空闲就绪,最多只能延续到绝对超时。无法识别或读取的进程状态绝不会作为精确空闲的正向信号。尚未发布的启动过程中,回退路径要求已经观察到输出;零输出静默不能发布空会话,超时则拒绝 spawn。取消操作会关闭尚未发布的 shell并以调用方提供的确切中止原因拒绝即使当时还无法观察其前台进程组。如果关闭失败`PtyBackendCleanupError` 会单独保留清理失败,供注册表释放资源时处理。未完成的终端控制序列受 `maxReadBytes` 限制;超过上限后,系统会丢弃内容直到其终止符。末尾的回车会跨回调保留,使拆分的 CRLF 合并为一个换行。
取消发送时,系统会解析当前前台进程组并发送真正的 `SIGINT`;它绝不会通过写入 `\x03` 模拟中断,因此原始模式程序仍可取消。关闭操作先向后代发送 `SIGTERM` 并等待,再向已捕获的存活进程与新扫描到的后代之并集发送 `SIGKILL`,防止进程通过重新设定父进程而逃避清理。系统确认每个保留的进程身份都已消失;在 Linux 上,非执行中的僵尸进程也视为完全停稳,并会随 shell 退出而回收。如果仍有进程存活,失败结果不会缓存成永久拒绝的关闭操作;后续关闭仍会重试清理。

View File

@@ -26,6 +26,11 @@ export interface Config {
exactProbeAfterMs?: number
/** Silence duration that yields `inferred_idle`. */
idleSilenceMs?: number
/**
* Extra wait beyond `idleSilenceMs`, once a prompt marker was seen, for the shell to
* regain the foreground before `inferred_idle` settles; at least one `pollIntervalMs`.
*/
handoffGraceMs?: number
/** Absolute send wait bound. */
timeoutMs?: number
/** Grace before teardown escalates to `SIGKILL`. */
@@ -48,6 +53,7 @@ export const Config: z<Config> = z.object({
pollIntervalMs: z.number().default(50),
exactProbeAfterMs: z.number().default(150),
idleSilenceMs: z.number().default(3_000),
handoffGraceMs: z.number().default(500),
timeoutMs: z.number().default(30_000),
disposeGraceMs: z.number().default(3_000),
})
@@ -69,4 +75,7 @@ export function validateConfig(config: Config): asserts config is ResolvedConfig
if (resolved.maxReadBytes > resolved.scrollbackMaxBytes) {
throw new Error('pty-local: maxReadBytes must not exceed scrollbackMaxBytes')
}
if (resolved.handoffGraceMs < resolved.pollIntervalMs) {
throw new Error('pty-local: handoffGraceMs must be at least pollIntervalMs so one readiness poll runs inside the grace window')
}
}

View File

@@ -331,11 +331,12 @@ export class LocalPtySession implements PtyBackendSession {
// A prompt candidate can race bash's foreground handoff, but an interactive
// child also inherits PROMPT_COMMAND. Silence therefore remains the bound
// on waiting for shell ownership instead of letting a child marker suppress
// readiness until the absolute timeout. One final poll lets a foreground
// handoff coincident with that boundary win before the fallback settles.
// readiness until the absolute timeout. When a prompt marker was seen, the
// configured grace holds the fallback past the silence bound so polls in
// that window can observe the foreground handoff and settle as stdin_read.
const idleFor = Date.now() - this.lastOutputAt
const handoffGrace = this.promptSeen ? this.config.pollIntervalMs : 0
if (startupHasOutput && idleFor >= this.config.idleSilenceMs && idleFor - this.config.idleSilenceMs >= handoffGrace) {
const handoffGrace = this.promptSeen ? this.config.handoffGraceMs : 0
if (startupHasOutput && idleFor >= this.config.idleSilenceMs + handoffGrace) {
this.settleActive('inferred_idle')
return
}

View File

@@ -6,7 +6,7 @@ function config(overrides: Partial<Config> = {}): Config {
return {
backendType: 'shell', shellPath: '/bin/bash', shellArgs: [], rows: 40, cols: 160,
scrollbackLines: 100, scrollbackMaxBytes: 1024, maxReadBytes: 512,
pollIntervalMs: 10, exactProbeAfterMs: 20, idleSilenceMs: 100, timeoutMs: 1000,
pollIntervalMs: 10, exactProbeAfterMs: 20, idleSilenceMs: 100, handoffGraceMs: 50, timeoutMs: 1000,
disposeGraceMs: 100,
...overrides,
}
@@ -24,4 +24,9 @@ describe('pty-local config', () => {
expect(() => { validateConfig(config({ rows: 1.5 })) }).toThrow('rows')
expect(() => { validateConfig(config({ maxReadBytes: 2048 })) }).toThrow('must not exceed')
})
it('rejects a handoff grace shorter than one readiness poll', () => {
expect(() => { validateConfig(config({ handoffGraceMs: 9, pollIntervalMs: 10 })) }).toThrow('handoffGraceMs must be at least pollIntervalMs')
expect(() => { validateConfig(config({ handoffGraceMs: 10, pollIntervalMs: 10 })) }).not.toThrow()
})
})

View File

@@ -33,7 +33,7 @@ function config(): ResolvedConfig {
return {
backendType: 'shell', shellPath: '/bin/bash', shellArgs: [], rows: 24, cols: 80,
scrollbackLines: 10, scrollbackMaxBytes: 100, maxReadBytes: 50,
pollIntervalMs: 10, exactProbeAfterMs: 20, idleSilenceMs: 50, timeoutMs: 100,
pollIntervalMs: 10, exactProbeAfterMs: 20, idleSilenceMs: 50, handoffGraceMs: 10, timeoutMs: 100,
disposeGraceMs: 10,
}
}

View File

@@ -41,7 +41,7 @@ function stubAgent(ctx: Context, rawId: string): Agent {
async function harness(
mode: 'danger-full-access' | 'workspace-write',
timing: { idleSilenceMs?: number; timeoutMs?: number } = {},
timing: { idleSilenceMs?: number; handoffGraceMs?: number; timeoutMs?: number } = {},
) {
const root = mkdtempSync(join(tmpdir(), 'dsh-pty-local-'))
roots.push(root)
@@ -55,6 +55,7 @@ async function harness(
pollIntervalMs: 10,
exactProbeAfterMs: 20,
idleSilenceMs: timing.idleSilenceMs ?? 250,
handoffGraceMs: timing.handoffGraceMs ?? 250,
timeoutMs: timing.timeoutMs ?? 2_000,
disposeGraceMs: 500,
scrollbackLines: 100,
@@ -80,6 +81,15 @@ async function waitForOutput(operation: PtySendOperation, expected: string, time
expect(output).toContain(expected)
}
// A send the test interrupts settles when bash returns to its prompt, so the
// kernel may publish the foreground handoff on either side of the silence
// bound. `handoffGraceMs` widens the window that wins the exact attribution but
// cannot remove the race on a loaded host, so these settles assert that the
// session became usable again, not which readiness tier observed it.
function expectReadyForNextSend(waitReason: string): void {
expect(['stdin_read', 'inferred_idle']).toContain(waitReason)
}
describe('pty-local real shell', () => {
it('persists cwd and environment across sends, scrubs secrets, and closes', async () => {
const previous = process.env.DSH_TEST_SECRET
@@ -123,7 +133,7 @@ describe('pty-local real shell', () => {
const foreground = ctx.pty.startSend(agent, created.sessionId, { text: 'sleep 60', submit: true })
await new Promise(resolve => setTimeout(resolve, 50))
expect((await ctx.pty.signal(agent, created.sessionId, 'SIGINT')).delivered).toBe(true)
expect((await foreground.done).waitReason).toBe('stdin_read')
expectReadyForNextSend((await foreground.done).waitReason)
const background = ctx.pty.startSend(agent, created.sessionId, {
text: 'sh -c \'trap "" TERM; sleep 60\' & echo CHILD=$!',
@@ -159,13 +169,13 @@ describe('pty-local real shell', () => {
await waitForOutput(foreground, ready, 15_000)
controller.abort()
const result = await foreground.done
expect(result.waitReason).toBe('stdin_read')
expectReadyForNextSend(result.waitReason)
const after = await ctx.pty.startSend(agent, created.sessionId, {
text: 'echo AFTER_SIGINT',
submit: true,
}).done
expect(after.viewport).toContain('AFTER_SIGINT')
expect(after.waitReason).toBe('stdin_read')
expectReadyForNextSend(after.waitReason)
await ctx.pty.kill(agent, created.sessionId)
}, 20_000)
})

View File

@@ -88,7 +88,7 @@ function config(overrides: Partial<ResolvedConfig> = {}): ResolvedConfig {
return {
backendType: 'shell', shellPath: '/bin/bash', shellArgs: [], rows: 24, cols: 80,
scrollbackLines: 10, scrollbackMaxBytes: 128, maxReadBytes: 64,
pollIntervalMs: 10, exactProbeAfterMs: 20, idleSilenceMs: 50, timeoutMs: 100,
pollIntervalMs: 10, exactProbeAfterMs: 20, idleSilenceMs: 50, handoffGraceMs: 10, timeoutMs: 100,
disposeGraceMs: 20,
...overrides,
}
@@ -307,6 +307,27 @@ describe('LocalPtySession readiness and output', () => {
expect((await operation.done).waitReason).toBe('stdin_read')
})
it('holds the idle fallback for the configured handoff grace, not one poll', async () => {
vi.useFakeTimers()
const terminal = new FakeTerminal()
const inspector = new FakeInspector()
const session = new LocalPtySession(terminal.asPty(), inspector, config({ handoffGraceMs: 40 }))
await initialize(session, terminal)
const operation = session.startSend({ text: 'run', submit: true })
let settled = false
void operation.done.then(() => { settled = true })
inspector.pgid = 789
terminal.emitData('\x1b]133;D;0\x07dsh> ')
// One poll past the silence bound would already have settled inferred_idle.
await vi.advanceTimersByTimeAsync(70)
expect(settled).toBe(false)
inspector.pgid = 456
await vi.advanceTimersByTimeAsync(10)
expect((await operation.done).waitReason).toBe('stdin_read')
})
it('falls back to inferred idle when a foreground child emits an inherited prompt marker', async () => {
vi.useFakeTimers()
const terminal = new FakeTerminal()

View File

@@ -71,6 +71,7 @@ suite('terminal real Loader composition through cordis.yml', () => {
' pollIntervalMs: 10',
' exactProbeAfterMs: 20',
' idleSilenceMs: 250',
' handoffGraceMs: 250',
' timeoutMs: 2000',
' disposeGraceMs: 500',
"- name: '@deepseek-ai/dsh-tool-pty'",