Merge remote-tracking branch 'origin/master' into HEAD

This commit is contained in:
Tianyi Cui
2026-08-17 11:39:33 +08:00
18 changed files with 145 additions and 40 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 packages/shell/tool-bash-persistent/README.md
README.md: 37c259443cba566350d8ec7963857d5f6ce7396c
README.zh.md: 8f3cf4e4d20aad1537b48644cee1a9d5706f4b3c
README.md: 606920d087b42344f34b70103e167b0046d3cfd5
README.zh.md: dd88db87cb617fb2f4d35ada9550d0142e32e979

View File

@@ -33,7 +33,7 @@ Prefix-stable while the configured description and schema remain unchanged.
#### What the model sees
Commands share one shell per Agent, so cwd, exported variables, activated environments, functions, and background jobs persist across calls. Results exclude private completion markers and the shell prompt. A nonzero wrapped command appends `[exit code: N]`; a shell that exits before reporting that status instead appends `[shell exited: code N]`, `[shell killed by signal: SIG]`, or `[shell exited]` when the backend supplies neither, then resets and tells the model that the next call starts fresh. Long output keeps the earliest retained prefix plus a clipping notice. If the PTY has already dropped that prefix, the result says so explicitly instead of presenting a tail as complete output. Timeout returns bounded partial output, closes the uncertain shell, and reports the reset.
Commands share one shell per Agent, so cwd, exported variables, activated environments, functions, and background jobs persist across calls. Results exclude private completion markers. When the shell reads stdin again without having printed the completion marker — after `exec`, an interrupt, or an interactive foreground child whose stdin wait the provider proves — the call returns the captured partial output, which can end with the backend's own prompt text. A nonzero wrapped command appends `[exit code: N]`; a shell that exits before reporting that status instead appends `[shell exited: code N]`, `[shell killed by signal: SIG]`, or `[shell exited]` when the backend supplies neither, then resets and tells the model that the next call starts fresh. Long output keeps the earliest retained prefix plus a clipping notice. If the PTY has already dropped that prefix, the result says so explicitly instead of presenting a tail as complete output. Timeout returns bounded partial output, closes the uncertain shell, and reports the reset.
#### Token effect
@@ -46,5 +46,6 @@ Append-only tool results follow the reusable request prefix.
## Known Limitations and Deferred Work
- The tool requires an owning Agent and a real PTY backend.
- An interactive foreground child (for example a REPL) returns early with partial output only where the subprocess provider proves its stdin wait; elsewhere the call runs to `timeoutMs`.
- Explicit `exit` and timeout discard shell state. Cancellation also resets and discards the result, even when a complete status marker is already observable; the next call starts a fresh shell.
- Environment facts such as network access and package mirrors belong in the configured `description`, not this package's default.

View File

@@ -33,7 +33,7 @@
#### 模型所见
每个 Agent 的命令共享一个 shell因此 cwd、导出的环境变量、已激活环境、函数和后台任务会跨调用保留。结果不包含私有完成标记 shell 提示符。经封装的命令以非零状态结束时,结果会追加 `[exit code: N]`;若 shell 在报告该状态前退出,则改为追加 `[shell exited: code N]``[shell killed by signal: SIG]`,或在后端既未提供退出码也未提供信号时追加 `[shell exited]`;随后重置 shell并告知模型下次调用从新 shell 开始。长输出保留仍可读取的最早前缀并追加截断提示;若 PTY 已丢弃真正的开头,结果会明确说明,而不是把尾部伪装成完整输出。超时返回有界的部分输出、关闭状态不确定的 shell并报告该重置。
每个 Agent 的命令共享一个 shell因此 cwd、导出的环境变量、已激活环境、函数和后台任务会跨调用保留。结果不包含私有完成标记。当 shell 在未打印完成标记的情况下再次读取 stdin 时——例如 `exec`、中断,或提供方能证明其 stdin 等待的交互式前台子进程——调用返回已捕获的部分输出,其末尾可能带有后端自己的提示符文本。经封装的命令以非零状态结束时,结果会追加 `[exit code: N]`;若 shell 在报告该状态前退出,则改为追加 `[shell exited: code N]``[shell killed by signal: SIG]`,或在后端既未提供退出码也未提供信号时追加 `[shell exited]`;随后重置 shell并告知模型下次调用从新 shell 开始。长输出保留仍可读取的最早前缀并追加截断提示;若 PTY 已丢弃真正的开头,结果会明确说明,而不是把尾部伪装成完整输出。超时返回有界的部分输出、关闭状态不确定的 shell并报告该重置。
#### Token 影响
@@ -46,5 +46,6 @@
## 已知限制与延后工作
- 工具需要拥有它的 Agent 和真实 PTY 后端。
- 交互式前台子进程(例如 REPL只有在进程管理提供方能证明其 stdin 等待时才会提前返回部分输出;否则调用会一直运行到 `timeoutMs`
- 显式 `exit` 与超时会丢弃 shell 状态。取消同样会重置 shell 并丢弃结果,即使已经能观察到完整状态标记也是如此;下次调用创建新 shell。
- 网络访问、软件包镜像等环境事实应写入配置的 `description`,而非包默认描述。

View File

@@ -7,7 +7,7 @@ import { randomUUID } from 'node:crypto'
import type { Context } from '@deepseek-ai/cordis'
import z from '@deepseek-ai/schemastery'
import type { Agent } from '@deepseek-ai/dsh-agent'
import type { TerminalReadResult, TerminalSendResult, TerminalSessionId } from '@deepseek-ai/dsh-terminal'
import type { TerminalReadResult, TerminalSessionId } from '@deepseek-ai/dsh-terminal'
import { deadline, timeoutOf } from '@deepseek-ai/dsh-timeout'
import { defineTool } from '@deepseek-ai/dsh-tools'
@@ -15,7 +15,6 @@ import { defineTool } from '@deepseek-ai/dsh-tools'
const TRUNCATED_MESSAGE = '<response clipped><NOTE>To save on context only part of this file has been shown to you. You should retry this tool after you have searched inside the file with `grep -n` in order to find the line numbers of what you are looking for.</NOTE>'
const LOST_PREFIX_MESSAGE = '<response clipped><NOTE>The beginning of this command output was dropped by the terminal scrollback limit. The following text is the earliest retained output.</NOTE>\n'
const SHELL_RESET_MESSAGE = 'The persistent bash shell was reset; the next bash call starts from the workspace with a fresh current directory and environment.'
const SHELL_PROMPT = '__DSH_PERSISTENT_BASH_PROMPT__ '
const TIMEOUT_CODE = 'PERSISTENT_BASH_TIMEOUT'
// One page is enough to find a just-emitted completion marker; the full
// scrollback is assembled only when a command settles or needs partial output.
@@ -82,12 +81,8 @@ function wrapCommand(command: string, marker: CommandMarkers): string {
return `printf '%s\\n' ${quoteForBash(marker.start)}; eval -- ${quoteForBash(command)}; __dsh_persistent_bash_status=$?; printf '%s%s\\n' ${quoteForBash(marker.end)} "$__dsh_persistent_bash_status"`
}
function stripPrompt(text: string): string {
let result = text.replace(/\r?\n$/, '')
while (result.endsWith(SHELL_PROMPT)) {
result = result.slice(0, -SHELL_PROMPT.length)
}
return result.endsWith('\n') ? result.slice(0, -1) : result
function trimTrailingNewline(text: string): string {
return text.replace(/\r?\n$/, '')
}
function commandOutput(
@@ -101,18 +96,12 @@ function commandOutput(
const startMarker = text.lastIndexOf(marker.start, end)
const start = startMarker < 0 ? 0 : startMarker + marker.start.length
return {
text: stripPrompt(text.slice(start, end).replace(/^\r?\n/, '')),
text: trimTrailingNewline(text.slice(start, end).replace(/^\r?\n/, '')),
incomplete: startMarker < 0,
exitCode: Number(status),
}
}
function promptCompleted(result: TerminalSendResult): boolean {
return result.viewport.endsWith(SHELL_PROMPT)
|| result.viewport.endsWith(`${SHELL_PROMPT}\r\n`)
|| result.viewport.endsWith(`${SHELL_PROMPT}\n`)
}
function partialOutput(
snapshot: RetainedOutput,
marker: CommandMarkers,
@@ -122,7 +111,7 @@ function partialOutput(
const startMarker = snapshot.text.lastIndexOf(marker.start)
if (startMarker >= 0) {
return {
text: stripPrompt(snapshot.text.slice(startMarker + marker.start.length).replace(/^\r?\n/, '')),
text: trimTrailingNewline(snapshot.text.slice(startMarker + marker.start.length).replace(/^\r?\n/, '')),
incomplete: false,
}
}
@@ -133,7 +122,7 @@ function partialOutput(
const fallbackEnd = afterStart.lastIndexOf(marker.end)
const beforeEnd = fallbackEnd < 0 ? afterStart : afterStart.slice(0, fallbackEnd)
return {
text: stripPrompt(beforeEnd.replaceAll(SHELL_PROMPT, '')),
text: trimTrailingNewline(beforeEnd),
incomplete: fallbackTruncated || fallbackStart < 0,
}
}
@@ -243,8 +232,10 @@ function persistentShells(ctx: Context, config: ResolvedConfig): PersistentShell
live.delete(owner)
}, 'tool-bash-persistent owner cache cleanup')
}
// Echo suppression only: the prompt stays the backend's own, so the
// backend's prompt-based readiness detection keeps working.
const setup = ctx.terminals.startSend(owner, spawned.sessionId, {
text: `stty -echo; PS1=${quoteForBash(SHELL_PROMPT)}`,
text: 'stty -echo',
submit: true,
signal: combinedSignal,
})
@@ -339,7 +330,11 @@ async function executeCommand(
SHELL_RESET_MESSAGE,
].filter(part => part.length > 0).join('\n')
}
if (promptCompleted(result)) {
// The shell reads stdin again (its prompt, or a foreground child's own
// read) without having printed the end marker — e.g. `exec`, an interrupt,
// or an interactive child. Return what was captured instead of spinning
// until the command deadline.
if (result.waitReason === 'stdin_read') {
const snapshot = retainedScrollback(ctx, owner, id, latest)
return renderCaptured(
partialOutput(snapshot, marker, fallback, fallbackTruncated),

View File

@@ -84,7 +84,10 @@ suite('persistent Bash through a real cordis.yml Loader composition', () => {
' config:',
' pollIntervalMs: 10',
' exactProbeAfterMs: 20',
' idleSilenceMs: 100',
// The silence tier is pushed beyond the send bound, so no send below can
// settle as inferred_idle: every case proves the controlled-prompt fast
// path that the production defaults (3.5s silence) would otherwise mask.
' idleSilenceMs: 30000',
' handoffGraceMs: 100',
' scrollbackLines: 20000',
' timeoutMs: 2000',
@@ -154,6 +157,12 @@ suite('persistent Bash through a real cordis.yml Loader composition', () => {
expect(large).toContain('<response clipped>')
expect(large).not.toContain('beginning of this command output was dropped')
// `exec` replaces the wrapper before its end marker prints; the seam's
// stdin_read readiness is what returns the replacement shell's prompt
// instead of spinning until the tool deadline.
const execed = text(await execute('exec-replacement', 'exec bash --noprofile --norc -i'))
expect(execed).toBe('dsh> ')
const exited = text(await execute('exit', 'exit'))
expect(exited).toContain('next bash call starts from the workspace')
expect(text(await execute('after-exit', 'printf "%s\\n" "$PWD"'))).toBe(root)

View File

@@ -100,7 +100,7 @@ type StubMode =
| 'paged-scrollback'
class StubPtySession implements TerminalBackendSession {
readonly motd = '__DSH_PERSISTENT_BASH_PROMPT__ '
readonly motd = 'stub> '
readonly pid = 123
statusValue: TerminalSessionStatus = { kind: 'running' }
scrollback = this.motd
@@ -325,7 +325,7 @@ describe('tool-bash-persistent', () => {
expect(ctx.tools.get('bash')).toBeUndefined()
})
it('handles inferred idle, prompt fallback, shell exit, clipping, and cleanup', async () => {
it('handles inferred idle, stdin_read fallback, shell exit, clipping, and cleanup', async () => {
const { ctx, owner, stub, fiber } = await setup({
backendType: 'stub',
maxOutputChars: 10,
@@ -338,18 +338,16 @@ describe('tool-bash-persistent', () => {
session.mode = 'incremental-fallback'
session.scrollback = ''
expect(text(await call(ctx, owner, 'incremental fallback'))).toBe('increment')
expect(text(await call(ctx, owner, 'incremental fallback'))).toContain('increment')
session.mode = 'prompt-only'
const promptFallback = text(await call(ctx, owner, 'bad {'))
expect(promptFallback).toContain('bash: synt')
expect(promptFallback).not.toContain('DSH_PERSISTENT_BASH_PROMPT')
session.mode = 'prompt-crlf'
session.scrollback = ''
const crlfPromptFallback = text(await call(ctx, owner, 'bad {'))
expect(crlfPromptFallback).toContain('bash: synt')
expect(crlfPromptFallback).not.toContain('DSH_PERSISTENT_BASH_PROMPT')
session.mode = 'end-only'
session.scrollback = ''
@@ -435,7 +433,7 @@ describe('tool-bash-persistent', () => {
expect(text(await call(ctx, owner, 'paged output'))).toBe('hello from stub')
})
it('sanitizes a prompt fallback reached after multiple polling rounds', async () => {
it('returns a stdin_read fallback reached after multiple polling rounds', async () => {
const { ctx, owner, stub } = await setup({ backendType: 'stub', maxOutputChars: 1_000 })
await call(ctx, owner, 'warm up')
const session = stub.sessions[0]!
@@ -444,7 +442,8 @@ describe('tool-bash-persistent', () => {
const result = text(await call(ctx, owner, 'bad {'))
expect(result).toContain('partial syntax output')
expect(result).toContain('bash: syntax error')
expect(result).not.toContain('DSH_PERSISTENT_BASH_PROMPT')
// The backend owns the prompt text, so the fallback retains it verbatim.
expect(result.endsWith('stub> ')).toBe(true)
expect(result).not.toContain('DSH_PERSISTENT_BASH_START')
})

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 packages/terminal/terminal-bash/README.md
README.md: 36e725fd4ce86be09755768a9e21ccb66d025251
README.zh.md: 080f45eb03dfdeece91269a3253aeb3fb280864d
README.md: 72f5b57335febe40b36de85e7df9b6df4bf7cb10
README.zh.md: 48c051564130d283717828de35d625d65e825052

View File

@@ -8,7 +8,7 @@ Persistent shell backend for `ctx.terminals` over `ctx.subprocess.spawnTerminal`
The plugin injects `pty`, `sandboxPolicy`, and `subprocess`, then registers the configured backend type (`shell`). `danger-full-access` starts the shell directly without requiring a sandbox provider; confined modes require a same-world `ctx.sandbox` and wrap the exact shell argv through it, failing before spawn when none is mounted. At spawn, one `ctx.sandboxPolicy.resolve({ session })` call supplies both the effective mode and the session workspace root; the same root is the default shell cwd when the caller omits one. A change to a different effective mode is rejected before its `sandbox/mode` event commits while that owner has an open PTY or a spawn in progress; the fence is attached to the exact owner and therefore outlives a provider reload that retains existing sessions. Wait for creation to settle and close the sessions before changing modes, so a terminal opened with wider access cannot survive a downgrade.
Readiness combines a foreground-verified private bash prompt marker, provider-reported foreground stdin-wait facts, silence fallback, and absolute timeout. A marker is not ready until the printable tail after the latest owned marker exactly equals the controlled `PS1`, including when the OSC marker and prompt are split across data callbacks; echoed input or output following an earlier prompt therefore cannot settle the current send. Prompt and silence evidence collected before the provider write, including while pre-write foreground inspection is pending, is discarded at the write boundary. When bash prints the marker before the terminal provider publishes its return to the foreground process group, polling retains the candidate for `handoffGraceMs` past the ordinary silence bound so a coincident handoff can win. An interactive child that inherits `PROMPT_COMMAND` therefore cannot suppress inferred-idle readiness until the absolute timeout. Unknown foreground state is never a positive exact-idle signal. A foreground group's stdin wait that existed before a send is likewise not post-write readiness: the same group must be observed outside that wait before a later wait can settle the send, while a changed foreground group is new evidence. During unpublished startup, a fallback requires observed output; zero-output silence cannot publish an empty session, and timeout rejects the spawn. Cancellation closes the unpublished shell and rejects with the caller's exact abort reason; `TerminalBackendCleanupError` separately preserves a cleanup failure. The caller's signal is forwarded for terminal allocation and readiness initialization; after publication the handle owns its lifetime. Incomplete terminal-control sequences are bounded by `maxReadBytes` and discarded through their terminator after crossing that limit; malformed UTF-8 terminal output uses replacement characters, and a trailing carriage return is carried across callbacks so split CRLF becomes one newline.
Readiness combines a foreground-verified private bash prompt marker, provider-reported foreground stdin-wait facts, silence fallback, and absolute timeout. A marker is not ready until the printable tail after the latest owned marker exactly equals the controlled `PS1`, including when the OSC marker and prompt are split across data callbacks; echoed input or output following an earlier prompt therefore cannot settle the current send. The controlled `PROMPT_COMMAND` re-asserts that `PS1` before every prompt, so an in-shell prompt override cannot degrade later sends to silence readiness. Prompt and silence evidence collected before the provider write, including while pre-write foreground inspection is pending, is discarded at the write boundary. When bash prints the marker before the terminal provider publishes its return to the foreground process group, polling retains the candidate for `handoffGraceMs` past the ordinary silence bound so a coincident handoff can win. An interactive child that inherits `PROMPT_COMMAND` therefore cannot suppress inferred-idle readiness until the absolute timeout. Unknown foreground state is never a positive exact-idle signal. A foreground group's stdin wait that existed before a send is likewise not post-write readiness: the same group must be observed outside that wait before a later wait can settle the send, while a changed foreground group is new evidence. During unpublished startup, a fallback requires observed output; zero-output silence cannot publish an empty session, and timeout rejects the spawn. Cancellation closes the unpublished shell and rejects with the caller's exact abort reason; `TerminalBackendCleanupError` separately preserves a cleanup failure. The caller's signal is forwarded for terminal allocation and readiness initialization; after publication the handle owns its lifetime. Incomplete terminal-control sequences are bounded by `maxReadBytes` and discarded through their terminator after crossing that limit; malformed UTF-8 terminal output uses replacement characters, and a trailing carriage return is carried across callbacks so split CRLF becomes one newline.
Send cancellation marks queued input as canceled before asking the terminal handle to signal the current foreground process group with a real `SIGINT`; if asynchronous pre-write inspection later settles, it cannot execute that input. If a provider write is already in flight, signalling waits for it to settle; a rejected write sends no signal. The canceled send retains its slot until the write and foreground signalling settle, so a successor cannot receive either late bytes or that signal. A provider write or signal that never settles therefore retains the slot indefinitely; closing the session (`terminal_close`) is the recovery. The absolute deadline remains armed while cancellation waits. A signal failure is a terminal transport failure and rejects the active send. Cancellation never emulates interruption by writing `\x03`, so raw-mode programs remain cancellable. Close rejects new public signals, stops readiness polling, and awaits the handle's provider-owned complete-session termination before settling the active send as `session_exit`.

View File

@@ -8,7 +8,7 @@
该插件注入 `pty``sandboxPolicy``subprocess`,然后注册所配置的后端类型(`shell`)。`danger-full-access` 无需沙箱提供方即可直接启动 shell受限模式要求同一执行世界中存在 `ctx.sandbox`,并通过它包装确切的 shell argv未挂载时会在 spawn 前失败。spawn 时,一次 `ctx.sandboxPolicy.resolve({ session })` 调用会同时给出实际模式与会话工作区根目录;调用方省略 cwd 时,同一根目录也是 shell 的默认 cwd。当某个所有者存在开放的 PTY 或正在进行 spawn 时,如果配置变更会得到不同的实际模式,系统会在对应 `sandbox/mode` 事件提交前拒绝该变更。该限制绑定到确切所有者,因此即使提供方重新加载并保留现有会话,它仍然有效。更改模式前,请等待创建完成并关闭会话,避免以更宽权限打开的终端在权限降级后继续存在。
就绪检测结合以下机制:由前台状态验证的私有 bash 提示符标记、提供方报告的前台 stdin 等待事实、静默回退和绝对超时。只有最新自有标记之后的可打印尾部与受控 `PS1` 完全相等,标记才算就绪;即使 OSC 标记和提示符被拆到多个数据回调中也一样。因此,较早提示符之后的回显输入或输出无法使当前 send 完成。提供方写入前收集的提示符与静默证据,包括写入前前台检查仍在等待时收集的证据,都会在写入边界丢弃。如果 bash 在终端提供方发布其重新取得前台进程组的状态前打印标记,轮询会在普通静默上限之后再保留该候选状态 `handoffGraceMs`,使恰好同时发生的前台交接有机会胜出。因此,继承 `PROMPT_COMMAND` 的交互式子进程无法一直抑制推断空闲就绪直至绝对超时。未知的前台状态绝不会作为精确空闲的正向信号。同样,一次 send 之前就已存在的前台进程组 stdin 等待并不代表写入后就绪:必须先观察到同一进程组脱离该等待,之后再次进入等待才能使该次 send 完成;前台进程组发生变化则构成新的证据。尚未发布的启动过程中,回退路径要求已经观察到输出;零输出静默不能发布空会话,超时则拒绝 spawn。取消操作会关闭尚未发布的 shell并以调用方提供的确切中止原因拒绝`TerminalBackendCleanupError` 会单独保留清理失败。调用方的 signal 会转发给终端分配与就绪初始化;发布后,句柄负责其生命周期。未完成的终端控制序列受 `maxReadBytes` 限制;超过上限后,系统会丢弃内容直到其终止符。格式错误的 UTF-8 终端输出使用替换字符;末尾的回车会跨回调保留,使拆分的 CRLF 合并为一个换行。
就绪检测结合以下机制:由前台状态验证的私有 bash 提示符标记、提供方报告的前台 stdin 等待事实、静默回退和绝对超时。只有最新自有标记之后的可打印尾部与受控 `PS1` 完全相等,标记才算就绪;即使 OSC 标记和提示符被拆到多个数据回调中也一样。因此,较早提示符之后的回显输入或输出无法使当前 send 完成。受控 `PROMPT_COMMAND` 会在每次输出提示符前重新设定该 `PS1`,因此在 shell 内覆盖提示符不会使后续 send 退化到静默就绪。提供方写入前收集的提示符与静默证据,包括写入前前台检查仍在等待时收集的证据,都会在写入边界丢弃。如果 bash 在终端提供方发布其重新取得前台进程组的状态前打印标记,轮询会在普通静默上限之后再保留该候选状态 `handoffGraceMs`,使恰好同时发生的前台交接有机会胜出。因此,继承 `PROMPT_COMMAND` 的交互式子进程无法一直抑制推断空闲就绪直至绝对超时。未知的前台状态绝不会作为精确空闲的正向信号。同样,一次 send 之前就已存在的前台进程组 stdin 等待并不代表写入后就绪:必须先观察到同一进程组脱离该等待,之后再次进入等待才能使该次 send 完成;前台进程组发生变化则构成新的证据。尚未发布的启动过程中,回退路径要求已经观察到输出;零输出静默不能发布空会话,超时则拒绝 spawn。取消操作会关闭尚未发布的 shell并以调用方提供的确切中止原因拒绝`TerminalBackendCleanupError` 会单独保留清理失败。调用方的 signal 会转发给终端分配与就绪初始化;发布后,句柄负责其生命周期。未完成的终端控制序列受 `maxReadBytes` 限制;超过上限后,系统会丢弃内容直到其终止符。格式错误的 UTF-8 终端输出使用替换字符;末尾的回车会跨回调保留,使拆分的 CRLF 合并为一个换行。
取消发送时,系统会先把排队输入标记为已取消,再要求终端句柄向当前前台进程组发送真正的 `SIGINT`;异步写入前检查即使随后结算,也无法执行该输入。如果提供方写入已在途,信号发送会等待其结算;写入被拒绝时不会发送信号。已取消的 send 会保留其位置,直到写入与前台信号发送都结算,因此后继 send 不会收到延迟字节或该信号。因此,永不结算的提供方写入或信号会无限期保留该位置;恢复手段是关闭会话(`terminal_close`)。取消等待期间,绝对 deadline 仍保持启用。信号发送失败是终端传输失败,会拒绝活跃 send。取消绝不会通过写入 `\x03` 模拟中断,因此,即使程序运行在 raw 模式下,也仍可取消。关闭操作会拒绝新的公开信号、停止就绪轮询,并等待由句柄提供方负责的完整会话终止,然后才把活跃 send 结算为 `session_exit`

View File

@@ -60,7 +60,10 @@ function childEnvironment(spec: TerminalBackendSpawnSpec): Record<string, string
PAGER: 'cat',
GIT_PAGER: 'cat',
PS1: CONTROLLED_PROMPT,
PROMPT_COMMAND: 'printf "\\033]133;D;%s\\007" "$?"',
// Re-asserting PS1 after the marker keeps prompt readiness working when a
// command overwrote the shell variable: bash runs PROMPT_COMMAND before
// rendering each prompt, so an override never survives to the next prompt.
PROMPT_COMMAND: `printf "\\033]133;D;%s\\007" "$?"; PS1='${CONTROLLED_PROMPT}'`,
BASH_SILENCE_DEPRECATION_WARNING: '1',
DSH_SHELL: '1',
DSH_SESSION_ID: spec.owner.id,

View File

@@ -208,6 +208,7 @@ describe('BashTerminalBackend startup rollback', () => {
graceMs: 10,
env: {
TERM: 'dumb', PAGER: 'cat', GIT_PAGER: 'cat', PS1: 'dsh> ', BASH_SILENCE_DEPRECATION_WARNING: '1',
PROMPT_COMMAND: 'printf "\\033]133;D;%s\\007" "$?"; PS1=\'dsh> \'',
DSH_SHELL: '1', DSH_SESSION_ID: 'agent', DSH_PTY_SESSION_ID: 'pty-1',
},
})

View File

@@ -137,6 +137,26 @@ describe('terminal-bash real shell', () => {
}
}, 10_000)
it('restores the controlled prompt after an in-shell PS1 override', async () => {
// The silence tier is pushed beyond every assertion below, so each settle
// proves prompt-based readiness survives the override rather than the
// inferred_idle fallback absorbing a broken prompt.
const { ctx, agent } = await harness('danger-full-access', {
idleSilenceMs: 5_000,
timeoutMs: 8_000,
})
const created = await ctx.terminals.spawn(agent, { type: 'shell' })
const override = ctx.terminals.startSend(agent, created.sessionId, { text: 'PS1=broken-prompt', submit: true })
expect((await override.done).waitReason).toBe('stdin_read')
const after = ctx.terminals.startSend(agent, created.sessionId, { text: 'printf "healed=[%s]\\n" "$PS1"', submit: true })
const result = await after.done
expect(result.waitReason).toBe('stdin_read')
expect(result.viewport).toContain('healed=[dsh> ]')
await ctx.terminals.kill(agent, created.sessionId)
}, 20_000)
it('wraps the exact shell argv under confined policy and unregisters on reload', async () => {
const { ctx, root, agent, fiber, sandbox } = await harness('workspace-write')
const created = await ctx.terminals.spawn(agent, { type: 'shell' })