# dsh-terminal-tool — 设计文档
| 项 | 值 |
|---|---|
| 包名 | `dsh-terminal-tool` |
| 插件 entry id | `terminal-tool` |
| 目标宿主 | DeepSeek Harness `>=0.2.1-rc.2 <0.3.0`(Windows / Desktop profile) |
| 契约基线 | 各第一方包 `lib/types/*.d.ts`(0.2.1-rc.2,npm 实数) |
| 文档状态 | 实现定稿(0.5.0):新增 `minimalMode` 与 `minimal-gitbash` 预设变体(§13) |
> **临时替代方案**:DSH 还没有内置的「终端 / Git Bash 选择」能力,Windows 上平台组合只提供 PowerShell。**在官方能力落地前,本插件是它的临时替代方案**:复用官方执行器与工具,在 `agent/created` 里把新会话的终端换成 Git Bash;Windows 受限档位下的 `confinedPolicy` 也属于这段临时接线,不改变宿主沙箱本身。**官方提供等价设置后即可停用本插件**——移除 profile 里的插件行并重启,新会话即回到官方终端工具。插件不改 DSH 安装目录、不改任何 preset、不接管全局 `ctx.shell`,配置只落当前 profile 的 patch 层,卸载无残留。
---
## 1. 目标与范围
### 1.1 要解决的问题
DSH 在 Windows 上只组合 PowerShell 执行器:`@deepseek-ai/dsh-base` 的补丁层用平台表达式
把 bash 家族整条关掉(`disabled: !!js process.platform === 'win32'`,覆盖 `bash-sandbox`
与 `tool-bash`),只留 `pwsh-sandbox` / `tool-pwsh`。结果是模型在 Windows 上只看得见
`pwsh` 工具,而用户希望默认用本机 Git Bash。官方尚无等价的「终端选择」设置,所以本插件
是**临时替代品**,目标是在官方能力落地前补上这段接线。
### 1.2 本插件的能力清单
| # | 能力 | 交付方式 |
|---|---|---|
| F1 | Windows 上把模型可见的终端工具从 `pwsh` 换成 `bash` | `tools.restrict({deny:['pwsh']})` + 注入官方 `bash` 工具 |
| F2 | 工具名 `bash`、POSIX 方言描述、`bash -c` 语义 | 直接复用 `@deepseek-ai/dsh-tool-bash` |
| F3 | `pwsh` 从工具目录消失(含 PTC 签名) | `restrict` 走注册表,PTC 渲染同源 |
| F4 | system prompt 的 PowerShell 段被压掉、bash 段到位 | `systemPrompt.section({name:'tool:pwsh', text:''})` + 官方工具自带 `tool:bash` 段 |
| F5 | 终端卡片 / 退出码 pill / 后台 `job_output`·`job_kill` | 官方工具 + 官方 `tool.call.toolview` 的 `bash` 渲染器(已存在) |
| F6 | 沙箱拒绝标记与 `sandbox_permissions` 升级链路 | 复用官方工具 + 我们执行器上报的 `sandbox` 事实 |
| F7 | 前台等满转后台(`promoteOnTimeout`) | 官方工具的 Config 默认值即为 true |
| F8 | 跟随会话当前目录(0.2.1+) | 官方工具的 `workingDirectory` 逻辑 |
| F9 | 设置里可选 `pwsh` / `bash`,可指定 Git Bash 路径 | 本插件 `export const Config`(volatile 字段)→ 第一方 `configForms` 服务 + 自写 `settings.general.item` 行(控件为官方 UI atoms) |
| F10 | 自动发现 Git Bash,硬排除 WSL | 本插件 `discover.ts` |
| F11 | 路径非法时安全降级(会话照旧 pwsh,不半坏) | 替换前置校验,失败只写诊断 |
| F12 | 只对新会话生效 | 每个 `agent/created` 读取一次配置快照 |
| F13 | 诊断日志 | `%TEMP%\dsh-terminal-tool.log`,`DSH_TERMINAL_TOOL_DIAG_FILE` 可改道 |
| F14 | 原生 Windows 程序中文输出乱码缓解 | 注入 `PYTHONIOENCODING=utf-8`(仅当未设置) |
| F15 | 受限沙箱策略:`unconfined` / `escalate` / `fail`,默认 `unconfined` | `src/confined.ts` 的 `planLaunch` + 执行器分支 |
| F16 | 极简模式:`minimalMode` 三值(默认 `oneshot`);`variant` 下注册 `minimal-gitbash` 预设变体并接持久 Git Bash 后端 | `src/config.ts` 的 `minimalMode` + `src/preset.ts` + `src/pty.ts`(`dsh-terminal-tool/pty` 入口),见 §13 |
### 1.3 明确不做
- 不接管全局 `ctx.shell`,不动 `dsh-base` / 任何 preset 快照,不改 DSH 安装目录。
- 不修改宿主沙箱实现,也不自行实现升级审批:`escalate` 的 `approveEscalation` 仍由第一方 `dsh-tool-bash` 负责,插件只把 MSYS 启动失败标成 `denied`。默认 `unconfined` 会绕过文件沙箱,但这是配置面的显式选择(见 2.4),不是隐式旁路。
- 不改写官方 `minimal` 预设,也不接管官方持久终端注册表。持久 shell 等价物以**新增预设** `minimal-gitbash` 交付(§13),官方定义只读、原样保留;没有会话引用时变体自行注销。
---
## 2. 设计原则:能复用就不重写
第一方在 0.2.1-rc.2 已经提供了这个功能需要的全部积木,缺的只是「在 Windows 上把它们换成
bash 那一套」的接线。因此本项目的每条行为都跑在第一方代码上,自写部分只包含第一方确实
没有的东西。
| 关注点 | 实现方式 |
|---|---|
| bash 工具本体(~65 KB) | **直接挂载官方 `@deepseek-ai/dsh-tool-bash`** |
| 沙箱升级 / 拒绝标记 | 官方工具自带 |
| 受限沙箱启动决策 | 自写 `src/confined.ts`(`planLaunch` / `isMsysStartupDenial`);审批仍归官方 `tool-bash` |
| 后台任务 / 转后台 / 会话 cwd | 官方工具自带 |
| 工具卡片渲染 | 官方 `tool.call.toolview` 的 `bash` 槽 |
| 执行器(argv 里那把 bash) | **继承官方 `LocalBashExecutor`,只重写 argv / env 与受限启动分支** |
| 类型 | 官方 `.d.ts`(devDependency,精确钉 `0.2.1-rc.2`) |
| 设置 UI | **自写客户端半区**(`src/client/`):向 `settings.general.item` 注册「终端工具」行;控件全部复用官方 `@deepseek-ai/dsh-client-ui-primitives`(`Button` / `Input` / `Menu` / `IconChevronDownOutlineRegular`) |
| 工具隐藏 / 提示词裁剪 | 官方 `tools.restrict()` / `systemPrompt.section()` |
| 自动发现 | 自写(`discover.ts`,第一方无等价物) |
| 每会话替换编排 | 自写(`replace.ts`,第一方无等价物) |
这套方案成立的前提有四条,均已在本机核实:
1. 第一方包**以 0.2.1-rc.2 发布到 npm 且带完整 `lib/types/*.d.ts`**(已用 `npm pack`
验证 20 个包),因此可以做真 TypeScript 类型检查,不需要 duck-typing 兜底。
2. 宿主 loader(`dsh-app-boot` 的 profile-resolution interception)**允许 profile 内插件
静态 `import '@deepseek-ai/dsh-*'`**,且已有活跃第三方插件实证(`dsh-connect-qoder-x`
首行即 `import { resolveRetryPolicy } from '@deepseek-ai/dsh-llm'`)。
3. 工具卡片渲染器 `tool.call.toolview` 的 `bash` 槽已由第一方提供,注册名为 `bash`
的工具自动复用官方卡片。
4. **Web shell 以冻结模块表(`PLATFORM_MODULES`)播种官方 UI atoms**:0.2.1-rc.2 起
`@deepseek-ai/dsh-client-ui-primitives` 和 `react` 一样由页面提供,插件 client bundle
只需 `require()`,既不必内联副本,也能拿到宿主样式表已经皮肤好的 CSS module 类名。
净效果:自写代码约 1.7 K 行 TS,且每一条行为都跑在第一方代码上,随官方升级而升级。
### 2.1 关键取舍:为什么仍然走 `agent/created` 替换
第一方「把 Windows 的终端换成 bash」有两条路:
- **路线 A(组合层)**:在 bundle patch 里 `disabled: true` 掉 `pwsh-sandbox`,
改为挂载 bash 执行器 + `tool-bash`。这是最「配置化」的做法,但代价是:
① `ctx.shell` 单实例,切换需要**重启**,无法运行期切换;
② 只影响 host 平面,preset 快照里逐行写死的 `tool-pwsh`/`tool-bash` 仍在每个 agent
的 scope 里生效,**四个官方 preset 不会自动跟随**;
③ 用户切换回 pwsh 也要重启。
- **路线 B(会话层,本项目采用)**:host 组合保持原样(官方 pwsh 执行器继续服务 hooks /
内部调用),在 `agent/created` 里对**新会话**做三件事:隐藏 `pwsh`、注入 `bash`、抹掉
PowerShell 提示词段。
选 B 的理由:它满足「一个开关、所有 preset 自动跟随、不碰 preset 文件、不接管
`ctx.shell`」这组用户可见需求;同时 `tools.restrict` /
`tools.register` / `systemPrompt.section` 都是 `@deepseek-ai/dsh-tools` /
`@deepseek-ai/dsh-system-prompt` 的公开 API(`register(definition): () => void`、
`restrict(filter): () => void` 均有 `restrict` 的作用域语义:只过滤继承层、不影响本层
注册)。代价是两条必然的行为约束:只对新会话生效、`minimal` preset 的持久 shell 需要单独处理(见 §13:它不是一个执行器替换能解决的面,改用预设变体交付)。
### 2.2 关键取舍:`isolate('shell')` 注入 bash 执行器
官方 `bash` 工具通过 `ctx.shell` 取执行器。要在「不接管全局 `ctx.shell`」的前提下让它拿到
Git Bash 执行器,本项目用 cordis 的一等服务隔离:
```js
// cordis Context.isolate(name, label)
const shadow = Object.create(this[symbols.isolate]);
shadow[name] = label ?? Symbol(name);
return this.extend({ [symbols.isolate]: shadow });
```
即:为每个 agent 建一个 `agentCtx.isolate('shell')` 的子上下文,在这个子树里挂我们的执行器
(它 `super(ctx, 'shell')` 注册为子树内的 `ctx.shell`),再在同一子树里挂官方 `tool-bash`。
子树外(host 全局、其它 agent)看到的仍然是官方 pwsh 执行器。机制与官方
`@deepseek-ai/dsh-experimental-terminal-bundle` 的 `isolate: { terminals: true }` 相同
(都是按名取一个全新影子),但隔离的服务名与被隔离的 bundle 不同:那边是 `terminals` 服务
与持久终端后端,这里是 `shell` 执行器。两者互不感知,也不共享影子。
> 实现细节:`isolate(name, label?)` 的 `label` 类型是 `symbol`,不传时 cordis 自己
> `Symbol(name)` 生成一个新 symbol,于是**每次调用得到全新的私有影子**——正是这里要的语义
> (「这个 agent 的执行器,别人看不见」)。因此不需要为每个 agent 拼 label,也没有跨会话
> 复用影子的问题;幂等由 `tools.get('bash', agent)` 探测保证(第二次 install 直接跳过)。
> 落地校验项:`tools.register` 的「注册到哪个 scope」由 ctx 的 agent 归属决定。
> 若从 agent 子树注册仍落到全局层,则第二个 agent 会撞重名;因此实现里**先
> `ctx.tools.get('bash', agent)` 探测**,已可见则跳过注册(设计为幂等),两条路径都安全。
### 2.3 客户端半区:复用官方 UI atoms,而不是自绘控件
设置行最初用手搓的胶囊按钮 / `` / `