Просмотр исходного кода

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

Tianyi Cui 1 месяц назад
Родитель
Сommit
c15f2f4d25

+ 6 - 0
.agents/notes/implemented/bug-fix/2026-08-15-persistent-bash-keeps-controlled-prompt.i18n.yaml

@@ -0,0 +1,6 @@
+# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
+# 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/bug-fix/2026-08-15-persistent-bash-keeps-controlled-prompt.md
+2026-08-15-persistent-bash-keeps-controlled-prompt.md: 9ee71f2adc0d473c5490dbe2b29ce55c8377e275
+2026-08-15-persistent-bash-keeps-controlled-prompt.zh.md: 48b2cc323e745a184cefc9603d461fe8fb27e15e

+ 35 - 0
.agents/notes/implemented/bug-fix/2026-08-15-persistent-bash-keeps-controlled-prompt.md

@@ -0,0 +1,35 @@
+# Agent Note: Persistent bash keeps the backend's controlled prompt
+
+Status: implemented
+
+English | [中文](2026-08-15-persistent-bash-keeps-controlled-prompt.zh.md)
+
+## Problem
+
+`dsh-tool-bash-persistent` initialized its shell with `stty -echo; PS1='__DSH_PERSISTENT_BASH_PROMPT__ '`, overwriting the `PS1` that `dsh-terminal-bash` sets in the spawn environment. The backend's prompt readiness requires the printable tail after the OSC `133;D` marker to exactly equal the controlled prompt ([design](../feature/2026-07-16-persistent-pty-sessions.md)), so after initialization no send could ever settle through it. `PROMPT_COMMAND` survived the override, so the marker kept arriving and every send paid the silence tier plus handoff grace — 3.5 s per tool call under production defaults, 7.2 s for the first call because the initialization send degraded too, and an extra 3.5 s tail after every long command. macOS has no exact stdin-wait tier, and on Linux the exact probe cannot observe a sub-poll-interval command leaving its stdin wait, so the degradation applied to effectively every call. Package tests masked it by configuring `idleSilenceMs: 100`.
+
+The override existed to give the tool a known prompt for two consumers: a viewport-suffix fallback that detected "shell at a prompt without the end marker", and cosmetic stripping of prompt text from partial output.
+
+## Decision
+
+The backend owns its prompt protocol and repairs it itself: the controlled `PROMPT_COMMAND` re-asserts `PS1` after printing the marker, so any in-shell prompt override — this tool's former initialization, a model command, a sourced script — lasts zero prompts. This also protects providers that cannot report foreground state, where the exact prompt text is the only readiness evidence.
+
+The tool stops overwriting `PS1` (initialization is `stty -echo` alone) and replaces its viewport-suffix fallback with the seam's existing signal: a send that settles as `stdin_read` without the end marker in scrollback returns the captured partial output. The private prompt constant and its stripping are deleted; partial output may now end with the backend's own prompt text, which the tool cannot and should not know.
+
+## Alternatives considered
+
+**Fix only the tool, leaving `PROMPT_COMMAND` unchanged.** Rejected because the seam would stay silently fragile: any later consumer or model command that touches `PS1` reintroduces the 3.5 s degradation with no failing signal, and providers without foreground inspection lose their only readiness factor.
+
+**Import the controlled prompt into the tool.** Rejected because the prompt is one provider's protocol constant; a Consumer matching it would couple the tool to `dsh-terminal-bash` specifically, and any other mounted backend would break it again.
+
+**Drop the prompt-text factor from backend readiness.** Rejected because for providers whose `inspectForeground` reports nothing, marker-plus-text is the defense against command output that embeds the raw OSC marker sequence; weakening it trades a fast path for a false-settle risk.
+
+**Widen `handoffGraceMs`/`idleSilenceMs` tuning instead.** Rejected because no silence value fixes a dead fast path; it only rebalances how much every call overpays.
+
+## Consequences
+
+Measured on darwin with production defaults: raw sends settle in ~86 ms with the controlled prompt intact versus ~3540 ms after an override; tool calls drop from 7180/3560/3566 ms to 355/88/91 ms for spawn+init+echo, echo, and pwd.
+
+The `stdin_read` fallback is behavior, not only cosmetics: after `exec`, an interrupt, or an interactive foreground child whose stdin wait the provider proves (the Linux exact tier), the call now returns captured partial output instead of spinning to the command deadline. Where no provider proves the wait (macOS), an interactive child still runs to `timeoutMs` — recorded as a known limitation in the tool README. Partial output can carry the backend's trailing prompt; complete marker-delimited output is byte-identical to before, which the keyless jsonrpc-agent snapshots confirm.
+
+The loader-composition suite now sets `idleSilenceMs` above the send bound, so silence can settle nothing and every case fails if prompt readiness regresses; a real-PTY case overrides `PS1` in-shell and requires the next send to settle as `stdin_read` with the healed prompt. The self-repair cannot survive a command that overwrites `PROMPT_COMMAND` itself; the silence tier remains the bound there, unchanged from the prior design.

+ 35 - 0
.agents/notes/implemented/bug-fix/2026-08-15-persistent-bash-keeps-controlled-prompt.zh.md

@@ -0,0 +1,35 @@
+# Agent Note: 持久 bash 保留后端的受控提示符
+
+Status: implemented
+
+[English](2026-08-15-persistent-bash-keeps-controlled-prompt.md) | 中文
+
+## Problem
+
+`dsh-tool-bash-persistent` 用 `stty -echo; PS1='__DSH_PERSISTENT_BASH_PROMPT__ '` 初始化其 shell,覆盖了 `dsh-terminal-bash` 在 spawn 环境中设定的 `PS1`。后端的提示符就绪检测要求 OSC `133;D` 标记之后的可打印尾部与受控提示符完全相等([设计](../feature/2026-07-16-persistent-pty-sessions.md)),因此初始化之后任何 send 都无法经由该路径结算。`PROMPT_COMMAND` 未被覆盖,标记仍持续到达,于是每次 send 都要支付静默层加交接宽限——生产默认值下每次工具调用 3.5 秒;首次调用 7.2 秒,因为初始化 send 同样退化;每条长命令结束后还要多等 3.5 秒。macOS 没有精确 stdin 等待层,而 Linux 的精确探测无法观察到在一个轮询周期内完成的命令脱离其 stdin 等待,因此退化实际覆盖了几乎每次调用。包测试把 `idleSilenceMs` 配成 100 毫秒,掩盖了该问题。
+
+这个覆盖存在的目的是给工具一个已知提示符,服务两个消费点:用视口后缀检测「shell 已回到提示符但没有结束标记」的回退判定,以及从部分输出中剥离提示符文本的美化。
+
+## Decision
+
+后端拥有自己的提示符协议并自行修复:受控 `PROMPT_COMMAND` 在打印标记后重新设定 `PS1`,因此任何 shell 内的提示符覆盖——本工具从前的初始化、模型命令、被 source 的脚本——都存活不到下一个提示符。这同时保护了无法报告前台状态的提供方:在那里,确切的提示符文本是唯一的就绪证据。
+
+工具不再覆盖 `PS1`(初始化只剩 `stty -echo`),并用 seam 已有的信号替换其视口后缀回退:一次以 `stdin_read` 结算而 scrollback 中没有结束标记的 send,返回已捕获的部分输出。私有提示符常量及其剥离逻辑删除;部分输出现在可能以后端自己的提示符文本结尾,工具无法也不应知道该文本。
+
+## Alternatives considered
+
+**只改工具,不动 `PROMPT_COMMAND`。** 被拒绝:seam 仍然静默脆弱——之后任何触碰 `PS1` 的消费方或模型命令都会在没有失败信号的情况下重新引入 3.5 秒退化,且无前台检查的提供方失去唯一的就绪因子。
+
+**把受控提示符导入工具。** 被拒绝:提示符是单个提供方的协议常量;Consumer 匹配它就把工具与 `dsh-terminal-bash` 具体耦合,换任何其他后端都会再次损坏。
+
+**从后端就绪检测中去掉提示符文本因子。** 被拒绝:对 `inspectForeground` 无法报告任何信息的提供方而言,标记加文本是对抗「命令输出中嵌入原始 OSC 标记序列」的防御;削弱它是拿误结算风险换快速路径。
+
+**改为调大 `handoffGraceMs`/`idleSilenceMs`。** 被拒绝:任何静默值都修不好已死的快速路径,只是重新分配每次调用多付多少。
+
+## Consequences
+
+darwin 上以生产默认值实测:受控提示符完好时裸 send 约 86 毫秒结算,覆盖后约 3540 毫秒;工具调用从 7180/3560/3566 毫秒(spawn+init+echo、echo、pwd)降至 355/88/91 毫秒。
+
+`stdin_read` 回退是行为而不只是美化:在 `exec`、中断,或提供方能证明其 stdin 等待的交互式前台子进程(Linux 精确层)之后,调用现在返回已捕获的部分输出,而不是空转到命令期限。没有提供方证明该等待时(macOS),交互式子进程仍会运行到 `timeoutMs`——已记入工具 README 的已知限制。部分输出可能带有后端的尾部提示符;由标记界定的完整输出与之前逐字节相同,无密钥 jsonrpc-agent 快照确认了这一点。
+
+loader 组合套件现在把 `idleSilenceMs` 设在 send 上限之上,静默无法结算任何 send,提示符就绪一旦回归,每个用例都会失败;一个真实 PTY 用例在 shell 内覆盖 `PS1`,并要求下一次 send 以 `stdin_read` 结算且提示符已修复。自我修复无法在 `PROMPT_COMMAND` 本身被覆盖的命令后存活;那里静默层仍是边界,与先前设计一致。

+ 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: a2023dedc4d94e49695303bcf094f88dd99efb39
-config-catalog.zh.md: 5bc1e5efdc5301158a63eb465de5db113f084845
+config-catalog.md: 45e617809a7f1d2b54ed657cbc4c53d5b453e6a9
+config-catalog.zh.md: 5217fd51827f1a61b1db3c2a66aceeb514179c70

+ 1 - 1
docs/config-catalog.md

@@ -2370,7 +2370,7 @@ export interface Config {
 }
 ```
 
-Source: [`packages/shell/tool-bash-persistent/src/index.ts:405`](../packages/shell/tool-bash-persistent/src/index.ts)
+Source: [`packages/shell/tool-bash-persistent/src/index.ts:400`](../packages/shell/tool-bash-persistent/src/index.ts)
 
 <a id="deepseek-aidsh-tool-fs"></a>
 

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

@@ -2372,7 +2372,7 @@ export interface Config {
 }
 ```
 
-来源:[`packages/shell/tool-bash-persistent/src/index.ts:405`](../packages/shell/tool-bash-persistent/src/index.ts)
+来源:[`packages/shell/tool-bash-persistent/src/index.ts:400`](../packages/shell/tool-bash-persistent/src/index.ts)
 
 <a id="deepseek-aidsh-tool-fs"></a>
 

+ 2 - 2
packages/shell/tool-bash-persistent/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/shell/tool-bash-persistent/README.md
-README.md: 37c259443cba566350d8ec7963857d5f6ce7396c
-README.zh.md: 8f3cf4e4d20aad1537b48644cee1a9d5706f4b3c
+README.md: 606920d087b42344f34b70103e167b0046d3cfd5
+README.zh.md: dd88db87cb617fb2f4d35ada9550d0142e32e979

+ 2 - 1
packages/shell/tool-bash-persistent/README.md

@@ -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.

+ 2 - 1
packages/shell/tool-bash-persistent/README.zh.md

@@ -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`,而非包默认描述。

+ 14 - 19
packages/shell/tool-bash-persistent/src/index.ts

@@ -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),

+ 10 - 1
packages/shell/tool-bash-persistent/tests/loader-composition.spec.ts

@@ -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)

+ 6 - 7
packages/shell/tool-bash-persistent/tests/tools.spec.ts

@@ -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')
   })
 

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

Разница между файлами не показана из-за своего большого размера
+ 0 - 1
packages/terminal/terminal-bash/README.md


Разница между файлами не показана из-за своего большого размера
+ 0 - 1
packages/terminal/terminal-bash/README.zh.md


+ 4 - 1
packages/terminal/terminal-bash/src/index.ts

@@ -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,

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

@@ -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',
       },
     })

+ 20 - 0
packages/terminal/terminal-bash/tests/local.spec.ts

@@ -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' })

Некоторые файлы не были показаны из-за большого количества измененных файлов