diff --git a/docs/config-catalog.md b/docs/config-catalog.md
index 873b17f38b..5a2b0cdaf5 100644
--- a/docs/config-catalog.md
+++ b/docs/config-catalog.md
@@ -3832,7 +3832,7 @@ export interface Config {
## `@deepseek-ai/dsh-tool-bash-persistent`
- `inject`: `tools` · `terminals` · `workingDirectory`
-- `source`: [`packages/shell/tool-bash-persistent/src/index.ts:445`](../packages/shell/tool-bash-persistent/src/index.ts)
+- `source`: [`packages/shell/tool-bash-persistent/src/index.ts:447`](../packages/shell/tool-bash-persistent/src/index.ts)
```ts config-catalog
/** Configuration for the persistent Bash tool. */
diff --git a/docs/config-catalog.zh.md b/docs/config-catalog.zh.md
index b820344e40..a95c58cbf7 100644
--- a/docs/config-catalog.zh.md
+++ b/docs/config-catalog.zh.md
@@ -3834,7 +3834,7 @@ export interface Config {
## `@deepseek-ai/dsh-tool-bash-persistent`
- `inject`: `tools` · `terminals` · `workingDirectory`
-- `source`: [`packages/shell/tool-bash-persistent/src/index.ts:445`](../packages/shell/tool-bash-persistent/src/index.ts)
+- `source`: [`packages/shell/tool-bash-persistent/src/index.ts:447`](../packages/shell/tool-bash-persistent/src/index.ts)
```ts config-catalog
/** Configuration for the persistent Bash tool. */
diff --git a/packages/shell/tool-bash-persistent/README.i18n.yaml b/packages/shell/tool-bash-persistent/README.i18n.yaml
index ab0611f17b..cc6225da8c 100644
--- a/packages/shell/tool-bash-persistent/README.i18n.yaml
+++ b/packages/shell/tool-bash-persistent/README.i18n.yaml
@@ -39,8 +39,8 @@
en: 857e036f41c913ac
zh: e9f383f7ee384c59
/deepseek-ai-dsh-tool-bash-persistent/understand-the-implementation/command-flow:
- en: 3b7bf7c20b266c31
- zh: 85a325ef65fa7ac7
+ en: c511f75009ba3743
+ zh: b3b722e00b2910b1
/deepseek-ai-dsh-tool-bash-persistent/further-exploration:
en: 42c387ccaab7e64c
zh: e58ec4ea60cf7711
@@ -72,8 +72,8 @@
en: ba6a48888c3361e5
zh: 3a8f648053555dc9
/deepseek-ai-dsh-tool-bash-persistent/known-limitations-and-deferred-work:
- en: dc5a908f2b2da040
- zh: 3afceaabcf324771
+ en: b12111850d752f0b
+ zh: 92b855387ec187ff
/deepseek-ai-dsh-tool-bash-persistent/known-limitations-and-deferred-work/dev-note:
en: e67f633dc6fe7773
zh: 3bb0c20c93f16f98
diff --git a/packages/shell/tool-bash-persistent/README.md b/packages/shell/tool-bash-persistent/README.md
index 22ea00bed1..4bbea6bf1a 100644
--- a/packages/shell/tool-bash-persistent/README.md
+++ b/packages/shell/tool-bash-persistent/README.md
@@ -85,7 +85,7 @@ This section explains the design decisions behind the tool and points at the cod
### Command flow
-A first command spawns the shell through `ctx.terminals.spawn`, disables input echo (`stty -echo`), and waits for readiness. Each command is then wrapped into one physical line — a printf of the start marker, the command body escaped with `$'…'`, and a printf of the end marker plus `$?` — so embedded newlines cannot leak terminal prompts into the result. The tool polls the scrollback in 1,000-line pages until the end marker appears, extracts the span, and renders it with any status marker. A timeout aborts the deadline, captures the partial output, and resets the shell.
+A first command spawns the shell through `ctx.terminals.spawn`, disables input echo and history expansion (`stty -echo; set +H`), and waits for readiness. Each command is then wrapped into one physical line — a printf of the start marker, the command body escaped with `$'…'`, and a printf of the end marker plus `$?` — so embedded newlines cannot leak terminal prompts into the result. With history expansion off, `!` sequences reach bash unchanged instead of being rewritten or rejecting the whole line. The tool polls the scrollback in 1,000-line pages until the end marker appears, extracts the span, and renders it with any status marker. A timeout aborts the deadline, captures the partial output, and resets the shell.
@@ -148,6 +148,7 @@ These limits define when the tool is a poor fit or needs special care. They are
- **An interactive foreground child 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.
+- **Raw control characters other than newline and carriage return reach readline as typed keys** — a literal TAB, ESC, or DEL in the command is consumed as an editing key, so content that needs them, such as a Makefile heredoc, should be written with a file tool or produced by `printf` escapes.
### Dev Note
diff --git a/packages/shell/tool-bash-persistent/README.zh.md b/packages/shell/tool-bash-persistent/README.zh.md
index c51bb4a00f..6b4877e177 100644
--- a/packages/shell/tool-bash-persistent/README.zh.md
+++ b/packages/shell/tool-bash-persistent/README.zh.md
@@ -85,7 +85,7 @@ kind: "package-reference"
### 命令流程
-首条命令通过 `ctx.terminals.spawn` 生成 shell,禁用输入回显(`stty -echo`),并等待就绪。随后每条命令都包装成一行物理文本——printf 起始标记、用 `$'…'` 转义的命令体、printf 结束标记加 `$?`——因此内嵌换行无法把终端提示符泄漏进结果。工具以 1,000 行一页轮询 scrollback,直到出现结束标记,提取区间并连同任何状态标记一起渲染。超时会中止截止时间、捕获部分输出并重置 shell。
+首条命令通过 `ctx.terminals.spawn` 生成 shell,禁用输入回显和历史展开(`stty -echo; set +H`),并等待就绪。随后每条命令都包装成一行物理文本——printf 起始标记、用 `$'…'` 转义的命令体、printf 结束标记加 `$?`——因此内嵌换行无法把终端提示符泄漏进结果。关闭历史展开后,`!` 序列原样到达 bash,不会被改写,也不会使整行被拒绝。工具以 1,000 行一页轮询 scrollback,直到出现结束标记,提取区间并连同任何状态标记一起渲染。超时会中止截止时间、捕获部分输出并重置 shell。
@@ -148,6 +148,7 @@ kind: "package-reference"
- **交互式前台子进程只在子进程提供方证明其 stdin 等待时才提前返回部分输出**——否则调用一直运行到 `timeoutMs`。
- **显式 `exit` 与超时会丢弃 shell 状态**——取消同样重置并丢弃结果,即使完整状态标记已经可观察;下一次调用启动全新 shell。
- **网络访问与包镜像等环境事实属于配置的 `description`**——而不是本包的默认描述。
+- **除换行与回车外,原始控制字符会作为按键到达 readline**——命令中的字面 TAB、ESC 或 DEL 会被当作编辑按键消耗,因此需要这些字符的内容(例如 Makefile heredoc)应通过文件工具写入,或用 `printf` 转义生成。
### 开发备注
diff --git a/packages/shell/tool-bash-persistent/src/index.ts b/packages/shell/tool-bash-persistent/src/index.ts
index 4f5d65736e..94de7ccf09 100644
--- a/packages/shell/tool-bash-persistent/src/index.ts
+++ b/packages/shell/tool-bash-persistent/src/index.ts
@@ -267,10 +267,12 @@ 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.
+ // Suppress input echo and history expansion, which would otherwise
+ // rewrite `!` sequences or discard the whole wrapper line before bash
+ // parses it. 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',
+ text: 'stty -echo; set +H',
submit: true,
signal: combinedSignal,
})
diff --git a/packages/shell/tool-bash-persistent/tests/loader-composition.spec.ts b/packages/shell/tool-bash-persistent/tests/loader-composition.spec.ts
index f30e87b978..cbcb431f03 100644
--- a/packages/shell/tool-bash-persistent/tests/loader-composition.spec.ts
+++ b/packages/shell/tool-bash-persistent/tests/loader-composition.spec.ts
@@ -160,6 +160,14 @@ suite('persistent Bash through a real cordis.yml Loader composition', () => {
))
expect(heredoc).toBe('alpha\nbeta\n[Command finished with exit code 0]')
+ // Interactive bash applies history expansion to typed input before parsing;
+ // it must neither rewrite `!` sequences nor drop the whole wrapper line.
+ const history = text(await execute(
+ 'history-expansion',
+ "echo '' > page.html; cat page.html; echo 'it'; echo hi!!",
+ ))
+ expect(history).toBe('\nit\nhi!!\n[Command finished with exit code 0]')
+
const pipeline = text(await execute(
'pipeline',
'{ sleep 0.1; printf "delayed\\n"; } | cat',