# @epoch-agent/plugin-terminal 中文文档

规范地址：https://npmcn.com/package/%40epoch-agent/plugin-terminal/

版本：6\.2\.0
npm 元数据更新时间：2026-11-13T08:27:20.004Z
中文译文更新时间：2026-11-13T19:50:35.582Z
软件包许可：Apache-2\.0

本文由上游 README 翻译，可能存在偏差，请结合原文核对 API、命令与配置。版权与许可归原作者所有。

原始 npm 页面：https://www.npmjs.com/package/%40epoch-agent/plugin-terminal
原文对照：https://npmcn.com/package/%40epoch-agent/plugin-terminal/?lang=original
项目主页：https://github.com/Ddbor/epoch-agent#readme
源码仓库：https://github.com/Ddbor/epoch-agent

## 关键信息（来自 npm 元数据）

- 包体积（解压后）：146 kB，5 个文件
- 模块格式：ES Module
- TypeScript 类型：包内自带
- Node.js 要求：\>=22\.0\.0
- 运行依赖：3 个
- 首次发布：2026-08-19T10:31:56.365Z
- 最新版本发布：2026-11-13T08:27:19.772Z
- 近 30 天下载量：4317（2026-10-14 至 2026-11-12，npm 官方下载统计）
- 已发布版本数：57（近 12 个月 57 个）

## 中文简介

epoch-agent 终端命令执行插件

## 安装

```sh
npm install @epoch-agent/plugin-terminal
```

也可以使用：`pnpm add @epoch-agent/plugin-terminal`、`yarn add @epoch-agent/plugin-terminal`、`bun add @epoch-agent/plugin-terminal`

## README

# @epoch-agent/plugin-terminal

终端插件。**十个工具**，分三族：

| 族 | 工具 | 语义 |
| --- | --- | --- |
| 一次性命令 | `terminal` | 起进程、跑完、收干净 |
| 作业控制（方案 36/49） | `task_list` / `task_output` / `task_wait` / `task_stop` | **kind 无关**，见下 |
| 持久会话（方案 49） | `shell_open` / `shell_send` / `shell_read` / `shell_list` / `shell_close` | 起来还要接着聊 |

⚠️ **第二族 2026-08-20（方案 49 PR-3）起不认 kind 了。** 它们背后是**一张**住在 [infra](https://npmcn.com/package/infra) 的作业表（`src/jobs.ts`），里面同时装着 `terminal({ background: true })` 起的后台命令（`t1`）、`shell_open` 开的持久会话（`s1`），以及后台子 agent （`a1`，由 core 的 `delegate_task({ background: true })` 登记）。 于是模型只学一套「列出来 / 读输出 / 停掉」，而不是每加一类作业就多学一套。

-   ✅ **做**：起进程、收输出（执行期间同时喂给 `ctx.onOutput`）、到点杀干净（含孙进程）、 危险命令拦截、workdir 边界校验、**每条执行路径各过一道进程沙箱**（2026-08-17，见下）； **后台长任务**（不等它跑完就返回）；**持久 shell 会话**（`cd` / `venv` / REPL 留存）
-   ❌ **不做**：不自己实现沙箱（调 [infra](https://npmcn.com/package/infra) 的 `confine()`）。危险命令表是 **护栏**——它拦的是手滑，不是对抗，**上了沙箱之后这条一个字没松**。 后台任务和持久会话**都不能活过 epoch 进程**（刻意的，见下）。 不做 `shell_signal`、不做 `shell_wait`、不做跨会话共享（判据见下）
-   **依赖**：`node-pty`。[protocol](https://npmcn.com/package/protocol) 和 [infra](https://npmcn.com/package/infra) 都是 `dependencies` （本包**没有 `peerDependencies`** —— 2026-11-03 核）

## 沙箱：三条路径都接上了，但**只管写入**，而且关得掉

2026-08-16（方案 46 PR-1）之前，`code_exec` 有 OS 强制的隔离而 `terminal` **什么都没有** —— 后者的调用频率高一个量级。现在三条执行路径在起进程之前各过一道 `confine()`：

| 路径 | 插入点 | 落地 |
| --- | --- | --- |
| 管道（默认） | `exec.ts` 的 `runCommand()` | PR-1（2026-08-16） |
| 后台（`background`） | **`background.ts` 的 `startTask()`** | PR-4（2026-08-17） |
| PTY（`pty`） | `pty.ts` 的 `runCommandPty()` | PR-5（2026-08-17） |

⚠️ 后台那一行的插入点是 `startTask()`，**不是** `exec.ts` 里那个 `runBackground()` —— 后者从方案 36 起就没有任何调用方，2026-08-17 一并删了。

边界按**权限档位**推导，用户不多学一个概念：

| 权限档位 | 沙箱模式 | 能写到哪 |
| --- | --- | --- |
| `plan` | `read-only` | 哪都不行（`/dev/null` 除外） |
| `default` / `acceptEdits` | `workspace-write` | 工作区 + 额外根 + 临时目录 + 工具链缓存 |
| `auto` | `danger-full-access` | 不设限 —— **但危险命令表照旧拦** |

⚠️ **`auto` 那一格 2026-10-08 从 `workspace-write` 改成 `danger-full-access`。** 不是「越松越好」：那一档的**文件工具早就不看工作区了**（工作区外的读写它都放行）， 留着沙箱只会让同一档里 `file_write('~/x')` 落得下去、而 `echo > ~/x` 被 OS 挡下 —— 而菜单上那一档写的是「工作区外也一样」。改完之后它和当时的 `bypass` 在**这一轴上** 相同，差别收窄成审批那一格（`bypass` 连「判不了」的都不问，`auto` 照问）。

⚠️ **2026-10-27 那两档合并了**（用户拍的「五档并四档」）：`auto` 现在就是 「审批轴 = `never`」那一档，也连「判不了」的都不问。所以这张表只剩一行 `danger-full-access`，而上面那句「差别收窄成审批那一格」描述的是**合并前的现场**。

**外加一个开关**：`sandbox.terminal: false`（2026-08-21，方案 46 §11.3 第一条，见 [CONFIGURATION.md](https://npmcn.com/docs/CONFIGURATION.md#%E5%85%B3%E6%8E%89-terminal-%E7%9A%84-os-%E6%B2%99%E7%AE%B1)）。 它落在 `sandboxPolicyFor()` 算出来的 `SandboxPolicy.enabled` 上， 所以**上面那张表和它是两个轴**：

-   关掉开关**不改 `mode`** —— `default` 档关掉之后报的仍然是 `workspace-write` + 「没包上，原因是开关关着」，不是 `danger-full-access`。 这一行就是「最宽那一档不是这个开关的替代品」的判据：它连审批一起放开， 这个开关一点审批都不动（方案 46 §2.3「我们不做二维」）
-   关着的时候工具输出那一行逐字说清是**配置**关的，和「这个平台没有后端」 是两句不同的话 —— 说错那句的代价是用户按着一句可操作的谎去改档位
-   用例：[`__tests__/sandbox-switch.test.ts`](https://npmcn.com/package/%40epoch-agent/plugin-terminal/__tests__/sandbox-switch.test.ts)（这一层） 和 `runtime/__tests__/sandbox-switch-e2e.test.ts`（从 yaml 走到工具输出）

**第三档：这一次的升权门**（2026-10-27，方案 65 §2.1）。上面那张表是**会话级**的 边界；在那之前，一条命令被 `workspace-write` 挡下之后模型能建议的只有「用户自己跑一遍」 或「整个会话切到 `auto`」。现在它可以带 `sandbox_permissions` + `justification` 请求放开**这一次**：

-   判据全在 infra 的 [escalation.ts](https://npmcn.com/package/infra/src/sandbox/escalation.ts)（阶梯 / 成对 / 词表），入口在 core 的 `ToolExecutor.resolveEscalation()`（审批）， 生效在 [sandbox.ts](https://npmcn.com/package/%40epoch-agent/plugin-terminal/src/sandbox.ts) 的 `sandboxPolicyFor()`（`mode` 覆盖 + `bypass: 'escalated'`）
-   **永远只批这一次**：那条决定不进审批缓存，宿主也画不出「永久允许」
-   ⚠️ **`shell_open` 的粒度是整个会话**（边界在开的那一刻定下）， `terminal` 的三条路径才是「这一条命令／这个后台任务」
-   用例：[`__tests__/sandbox-escalation.test.ts`](https://npmcn.com/package/%40epoch-agent/plugin-terminal/__tests__/sandbox-escalation.test.ts) 和 `core/__tests__/sandbox-escalation.test.ts`（审批那一半）

⚠️ **三件必须说清楚的事**，不然「terminal 有沙箱了」会被读成「terminal 安全了」：

1.  **只管写入。** 读取和网络都**不设限** —— 给终端上读白名单会让 `git push` （要读 `~/.ssh`）和 `pnpm install`（要读 `~/.npmrc`）当场废掉。 沙箱里的命令还能 `ps` 看见主机上别的进程。 ⚠️ **`auto` 那一档连写入都不挡**（上面那张表；合并前 `auto` / `bypass` 两档 都是这样），这一档下第 1 条不适用。
2.  **「三条路径都包上了」不等于「terminal 安全了」。** 安全中心那份「不在沙箱里」的 点名清单现在是空的，它只说**没有哪条路径看起来在沙箱里而实际不在** —— 第 1 条和第 3 条一个字都没变。那份清单也**不认上面那个开关**（它是一张静态的 工具名清单）—— 开关关着时唯一如实说话的地方是每条命令自己的那一行回执。
3.  **Windows 上 2026-11-11 起有后端了，但它弱一档、而且只覆盖两条路**：受限令牌 + 完整性标签，包住管道和后台，`pty: true` 那条递不进令牌所以不在内；读和网络不设限。 `enforcement` 恒为 `partial`，工具输出那一行会带着它（判据在 [infra 的 windows-acl/index.ts](https://npmcn.com/package/infra/src/sandbox/windows-acl/index.ts) 文件头， 对外的完整清单在 [docs/TOOLS.md](https://npmcn.com/docs/TOOLS.md#windows%E5%8F%97%E9%99%90%E4%BB%A4%E7%89%8C--%E5%AE%8C%E6%95%B4%E6%80%A7%E6%A0%87%E7%AD%BE2026-11-11-%E6%8E%A5%E4%B8%8A%E5%A6%82%E5%AE%9E%E8%AF%B4%E6%98%AF-partial)）。 `epoch doctor` 的沙箱一节印的是实测值。

> ⚠️ **工具输出（回给模型读的那一份）2026-11-05 起一律英文**，不跟用户界面语言走： 上面那行回执、截断与落盘提示、`task_*` / `shell_*` 的状态行、沙箱与失败说明都是。 判据同 `core/src/code-mode/notice.ts` 的文件头；工具 `description` 本来就是英文。

PTY 那条是最后接的，而且方案里**允许的结论是「不上沙箱，如实说明」**： `sandbox-exec` 包一层之后 tty 分配 / 窗口尺寸 / 信号转发都可能坏，坏法还很阴 （「交互式命令看起来跑起来了，但 Ctrl+C 不响应」）。三样在 macOS + 真 Seatbelt 上 拿裸 PTY 做对照逐条实测过，结果完全一致才接的，用例在 [`__tests__/sandbox-pty.test.ts`](https://npmcn.com/package/%40epoch-agent/plugin-terminal/__tests__/sandbox-pty.test.ts)。

后台那条同理有一条要验的：`sandbox-exec` 会不会破坏 [infra](https://npmcn.com/package/infra) 那条 `detached: false` 的决定（Ctrl+C 靠内核发给整个前台进程组）。 实测**没有** —— 包与不包，整棵树的 pgid 都等于我们自己，而且 `sandbox-exec` **不 fork 一层**（套上 profile 之后直接 `exec`，同一个 pid）。真发一次 SIGINT 查残留的用例在 [`__tests__/sandbox-process-tree-e2e.test.ts`](https://npmcn.com/package/%40epoch-agent/plugin-terminal/__tests__/sandbox-process-tree-e2e.test.ts)。

失败分两种说，对应两个错误码：**2008** = 被沙箱挡下（命令跑了、动作被拒）， **2009** = 沙箱自己没起来（**命令根本没跑，别去改命令**）。

## 后台任务：`pnpm build` 三分钟不用干等

```
terminal({ command: 'pnpm dev', background: true })   → 立即返回 t1
task_output({ id: 't1', since })                      → 增量取输出
task_wait({ id: 't1', timeoutMs })                    → 等它结束（**超时不杀它**；用户按停止当场返回）
task_stop({ id: 't1' })                               → 停掉（杀整棵树）
task_list()                                           → 看全部
```

这条路真正解锁的是「**起服务 → 连上去验证 → 停掉**」：在这之前起服务那条命令 永远不返回，而且老的后台分支是 `stdio: 'ignore'`，`listening on :3000` 那一行 根本不存在。

### 作业控制器是 kind 无关的（方案 49 PR-3，2026-08-20）

上面那四个工具**不只看得见后台命令**：`task_list` 把后台命令、持久 shell 会话 和后台子 agent（`a1`，2026-10-26 接的第三类）一起列出来、**逐行标出是哪一种** （`[命令]` / `[会话]` / `[子 agent]`），`task_output s1` 读得到会话的输出， `task_stop s1` 等价于 `shell_close s1`（走的是同一个 `stopJob()`）。

| 这一轮变了什么 | 这一轮**没**变什么 |
| --- | --- |
| 两张作业表合成一张，住 infra | 两族的**语义**分界线（后台不管了 / 持久要接着聊） |
| `task_*` 四个不认 kind | `shell_send` 这种只有持久会话才有的动作，照旧在那一族里 |
| 环形缓冲 / 溢出落盘只剩一份 | 三条上限的**取值**（后台 8 个 / 会话 4 个）各是各的 |
| 工具名 —— **一个都没改** | 宿主口子从 2026-08-26 起报全部 kind（见下） |

第三类（**后台子 agent**，2026-10-26）用的是同一套：它由 core 的 `delegate_task({ background: true })` 登记，`terminates: true`（所以 `task_wait a1` 有意义），`task_stop a1` 收的是一个 `AbortController` 而不是进程 —— 子 agent 不是子进程，退出时那张按 pid 记的表摸不到它。 停止回执也按 kind 分口径：子任务被收了，但它已经起过的后台命令归在父会话名下， 不会跟着没（判据在 `job-notice.ts` 的 `stoppedNotice`）。

⚠️ **动作口子只排除持久会话**（`actionableJob`）：命令和子 agent 都能从 `task_stop` / `task_wait` 走，会话不行 —— 它有自己的 `shell_close` / 空闲回收。

一个例外：`task_wait` 遇到持久会话会**立刻**返回一句「它没有『结束』这个状态」， 而不是干等到超时 —— 那是方案 §五「不做 `shell_wait`」的理由说给模型听一遍。

另一个例外：用户按停止时它**当场**返回（2026-09-29）。判据和「超时不杀它」逐字 同款 —— 只是**我们不等了**，那条作业照旧在跑。在这一笔之前它是唯一一个愿意干等 5 分钟却不看 `ctx.signal` 的工具（`terminal` 前台命令 100ms 轮询、`delegate_task` 走 `linkAbort`、MCP / LSP 各接各的），于是用户按终止之后界面一直挂在「正在终止…」 上，直到那个 `timeoutMs` 走满 —— 看起来就是卡死。

**给宿主的两个口子（`listTasks` / `taskOutput`，runtime 转给 server / TUI） 报的是全部 kind**（2026-08-26 起：方案 62 把可见性放宽了，持久会话也要上宿主界面）。 可见性和生命周期是两件事：列表 / 读输出不问 kind，而 `task_stop` / `task_wait` 照旧不认持久会话（它有自己的动作口子）。

六条边界（「谁看得见」那一条是 2026-08-15 新加的，它以前的答案是「所有人」）：

| 事情 | 取舍 |
| --- | --- |
| 活过一轮对话 | **会**（任务表挂在会话上，不是一次工具调用） |
| 谁看得见 | **只有起它的那个会话**（2026-08-15 收窄，见下；后台子 agent 记在**派活**的那个会话名下） |
| 活过 epoch 进程 | **不会**，且这是刻意的 —— 见下 |
| 同时几个 | **每个会话** 8 个，超了拒绝并列出**这个会话**在跑的 |
| `epoch -p` 里 | 跑完给一段摘要（`--json` 里是 `backgroundTasks` 字段），随后一并终止。见 [docs/HEADLESS.md](https://npmcn.com/docs/HEADLESS.md) |
| 输出 | 单任务 256KB **环形**缓冲（丢头保尾）+ 溢出时全文落 artifact，`task_output` 单次 8KB |

环形缓冲丢头保尾，和前台命令的「**头尾都留**」正好相反：后台任务里最有价值的 永远是最后几行（构建结果、报错栈），而一次性命令的头部是「跑的是什么命令、 什么配置」、尾部是结论，两头都要。滚出去多少会在 `task_output` 里明说，不静默。

一溢出，全文就同时写进 `~/.epoch/artifacts/<会话 id>/`（方案 47）， 于是滚出缓冲的开头不再是真的没了 —— 但**预览仍然取尾部**， 落盘只是让被丢的头留下来，不是反转上面那个决定。落盘是懒的： 没溢出过的任务一个文件都不产生。

### 任务表按会话分（2026-08-15）

一条后台任务归**起它的那个会话**（`ToolContext.sessionId`）。别的会话 `task_list` 里看不到它，拿它的 id 也取不到输出、停不掉 —— 和「没有这个任务」 同一句话。

在这之前那张表是**进程级**的：一个进程里两个会话看到的是同一批任务，于是 A 会话每轮被告知一批它没起过的命令在跑，而一句 `task_stop t1` 能停掉 B 会话 的构建。当时那么写的理由是「plugin 拿不到会话对象」—— 拿不到_对象_是真的， 但会话 _id_ 一直在工具契约上。

两条跟着变的：

-   **8 个的名额按会话算**（原来是进程级）。判据是那句话本身：满了时给的建议是 「先用 `task_stop` 停掉一个」，而别的会话的任务这个会话够不着 —— 一条做不到的建议比没有建议更坏。代价是进程里的总数变成 `8 × 活跃会话数`
-   **`/resume` 换会话 id 时那一格跟着搬**，`delegate_task` 的子任务跑完之后把它 起的后台任务**归还给派活的会话** —— 否则它会变成谁都看不见、谁也停不掉的孤儿

会话被**冷却**（web 的 LRU，`live: false`）时这批任务**一个字都不动**：会话可以 被释放，而进程还在跑是一个仍然成立的事实，且这张表是唯一还能停掉它的把手。 完整判据（含进程退出时的清理为什么不受影响）在 [src/background.ts](https://npmcn.com/package/%40epoch-agent/plugin-terminal/src/background.ts) 的文件头。

**真删会话会把它们全停掉**（2026-10-29）。在这之前删掉一段会话 = 它那几个 后台任务从此**没有任何界面看得见**，而进程一直跑到 epoch 退出 —— Web 的任务 面板要先打开那个会话，模型的 `task_stop` 要在那个会话里才有 `sessionId`。 现在 `SessionControl.delete` 会调 infra 的 `stopSessionJobs(sessionId)`：逐个 `stop`、忘掉那一格，然后才去删 `artifacts/<sid>/`（顺序反了的话，那几格溢出过 的全文在 POSIX 上会写进一个已经从目录树里消失的 inode，在 Windows 上直接 删不掉）。

⚠️ **冷却和真删是两件事，判据是「这段会话还会回来吗」**：冷却要保住那个把手 （它会回来），真删没必要（连会话都没了）。

### 为什么后台任务不能活过 epoch

因为它们和前台命令一样 **`detached: false`**。理由在 [infra/child-process.ts](https://npmcn.com/package/infra/src/child-process.ts) 的文件头：epoch 装了 `installSignalHandlers`，Ctrl+C 时那个监听器直接 `dispose(); process.exit(0)`， **没有任何一行我们的清理代码有机会跑完**。不残留靠的是内核 —— 子进程和我们在 同一个前台进程组，终端把 SIGINT 发给整组。

这条对长期任务**更重要**：它们活得久，撞上 Ctrl+C 的概率高得多。代价就是 「起个服务然后退出 epoch，服务还在」做不到 —— 写进了工具描述。

> ⚠️ **后台不是权限旁路。** `background: true` 走的是**同一条**危险命令表和 权限判定 —— 同一条命令前台要确认，后台也要。四个 task 工具本身不执行命令 （`operation: 'file_read'`），所以取一次输出不会弹确认框。用例守着这两条。

## 持久 shell 会话：`cd` / `venv` / REPL 留存（方案 49，2026-08-20）

```
shell_open({ workdir })                       → 开一个，返回 s1
shell_send({ id: 's1', input: 'cd src\n' })   → 发输入，等 wait 毫秒后回这段输出
shell_read({ id: 's1', since })               → 增量读（游标语义同 task_output）
shell_list()                                  → 看全部
shell_close({ id: 's1' })                     → 关掉（杀整棵树）
```

⚠️ **`shell_open` 等 shell 打出第一段输出（提示符）再返回**（上限 250ms，见 `sessions.ts` 的 `READY_WAIT_MS`）。不等的话，紧接着的 `shell_send` 会落在两种 时序上：提示符先到 → 输入行被回显在提示符后面（`\x1b[?1034hsh-3.2$ pwd`）； 输入先到 → 只有输入行本身。同一段输出两种形态，模型看到的世界就不确定， 评测录像也会因此「隔一次过期一次」。

`terminal` 三条路径**都是一次性的**，所以 `cd` 不留存、`source venv/bin/activate` 不留存、`export` 不留存、进了 REPL 就出不来 —— 这一族答的就是这个。

**为什么是新工具族而不是 `terminal` 的第四种模式**（2026-08-14 拍板）：`terminal` 已经有三种语义，而它们的超时、输出上限、返回值、审批口径各不相同。加第四种， 工具描述会长成一张真值表 —— 而模型最容易在这种工具上选错档。名字用 `shell_*` 不用 `terminal_*`：`terminal` 本身已经是一个工具，`terminal_open` 并列会让模型 以为前者是后者的一个模式。

| 事情 | 取舍 |
| --- | --- |
| 活过一轮对话 | **会** |
| 谁看得见 | **只有开它的那个会话**（同后台任务） |
| 活过 epoch 进程 | **不会**，见下面那一节 —— 而且这一族比后台任务更依赖我们自己的清理代码 |
| 同时几个 | **每个会话 4 个**（一个 PTY 比一个管道进程重），超了拒绝并列出在跑的 |
| 空闲 | **10 分钟**没动就回收；之后 `shell_send` 明说「已被回收，里面的东西没了」 |
| 输出 | 单会话 256KB **环形**缓冲（丢头保尾）+ 溢出全文落 artifact，单次读 8KB |
| 沙箱 | 和 `terminal` **同一个** `confineShell()`、同一张边界表、同一个开关 |
| 工作区边界 | 和 `terminal` **同一条** `isInWorkspace()` 判定，越界在起进程之前就被拒 |

⚠️ **换行要自己带。** `input: 'pwd'` 只是把三个字符打上去，`'pwd\n'` 才会执行。 刻意不替调用方补：补了的话 `'\x03'`（Ctrl+C）会变成「Ctrl+C 加一个回车」。 写 `\n` 两个平台都对 —— 带进来的换行会被 `asKeystrokes()` 翻译成那个平台上真正的 回车键（**Windows 的 ConPTY 只认 `\r`**）。**只翻译换行、不追加**， 所以上面那条 Ctrl+C 的走法没变；判据和实测在 [VERIFY\_RECORD-49](https://npmcn.com/docs/verify/VERIFY_RECORD-49-persistent-shell.md) §十二。

三样明确不做：**`shell_signal`**（往 PTY 里写 `\x03` 就是 Ctrl+C，比给进程发信号更准； 而「发给进程组还是直接子进程」是个没答的问题，不做就不必答）、**`shell_wait`** （持久会话没有「结束」这个状态，等什么？`shell_send` 的 `wait` 覆盖了「等一下再读」）、 **跨会话共享**。

`shell?` 参数（方案 §1.2 的表里列着）**这一轮没做**：POSIX 上永远 `/bin/sh` （`getDefaultShell()` 的既有决定），Windows 上跟 `config.shell` 走 —— 加一个 per-call 覆盖等于让「哪个 shell 在跑」有第二个说了算的地方，而模型要换 shell 直接 `shell_send('powershell\n')` 就行。

### 源码

| 文件 | 管什么 |
| --- | --- |
| [`shell/sessions.ts`](https://npmcn.com/package/%40epoch-agent/plugin-terminal/src/shell/sessions.ts) | 会话表：起 PTY、环形缓冲、空闲回收、登记进 infra |
| [`shell/tools.ts`](https://npmcn.com/package/%40epoch-agent/plugin-terminal/src/shell/tools.ts) | 五个工具的契约（参数 / 权限 / 危险命令拦截） |
| [`shell/notice.ts`](https://npmcn.com/package/%40epoch-agent/plugin-terminal/src/shell/notice.ts) | 给模型读的输出措辞（整份进 i18n 的 `SKIP_FILES`） |
| [`shell/limits.ts`](https://npmcn.com/package/%40epoch-agent/plugin-terminal/src/shell/limits.ts) | 四条上限 + 契约类型。下沉成叶子，避开 import 环 |

错误文案走 `t()`，key 在 `locales/{zh,en}.yaml` 的 `shell:` 一节 —— 和「给模型读的输出」分两处，判据在 `shell/notice.ts` 的文件头。

## 参数

`terminal` 的 `command` / `background` / `pty` / `stdin` / `workdir`，以及 `shell_*` 那五个的参数，**逐个说明和默认值在 [docs/TOOLS.md](https://npmcn.com/docs/TOOLS.md#%E7%BB%88%E7%AB%AF%E4%B8%8E%E5%90%8E%E5%8F%B0%E4%BB%BB%E5%8A%A1)** —— 那份是工具清单的唯一真源。

「在工作区内」（`workdir` 的判定）用的是 infra 的 [`isInWorkspace()`](https://npmcn.com/package/infra/src/workspace.ts) —— 全仓唯一一份，权限层和 plugin-file 共用（本插件里原来有一份逐字相同的私有实现）。`ctx.extraRoots`（`epoch --add-dir`） 里的目录同样算工作区内，`ctx.grantedPaths`（**权限层这一次放行的**路径，2026-10-08） 也一并当根用 —— 后者是「你刚在审批框里点了允许」那一档，判据在 protocol 的 `ToolContext.grantedPaths` 上。

> ⚠️ **`grantedPaths` 只进这道判定，不进沙箱**：沙箱那份可写根仍只认 `extraRoots`（`sandbox.ts` 的 `sandboxPolicyFor`）。批准读一个目录不该顺手 把沙箱的写也放开。

## 三条执行路径

管道（默认）走 `child_process.spawn`，PTY（`pty: true`）走 `node-pty`， 后台（`background: true`）走 infra 的长期子进程表（`processTableFor(sessionId).start()`） 并登记进任务表，只回一个任务 id。三条各自的超时和输出上限见 [docs/TOOLS.md](https://npmcn.com/docs/TOOLS.md#%E7%BB%88%E7%AB%AF%E4%B8%8E%E5%90%8E%E5%8F%B0%E4%BB%BB%E5%8A%A1)。

源码按**关注点**分（2026-08-17 从一个 649 行的 `exec.ts` 拆开，判据在各自的文件头）：

| 文件 | 管什么 |
| --- | --- |
| [`exec.ts`](https://npmcn.com/package/%40epoch-agent/plugin-terminal/src/exec.ts) | 管道那条路 + 退出清理。主题是**别留下孤儿进程** |
| [`pty.ts`](https://npmcn.com/package/%40epoch-agent/plugin-terminal/src/pty.ts) | PTY 那条路。它的进程组语义和另外两条**相反** |
| [`background.ts`](https://npmcn.com/package/%40epoch-agent/plugin-terminal/src/background.ts) | 后台任务表（状态 / 环形缓冲 / 落盘 / 停等） |
| [`sandbox.ts`](https://npmcn.com/package/%40epoch-agent/plugin-terminal/src/sandbox.ts) | 把 argv 包进沙箱 + 算出要上报的东西。三条路共用 |
| [`sandbox-notice.ts`](https://npmcn.com/package/%40epoch-agent/plugin-terminal/src/sandbox-notice.ts) | 把上面那份**说成给模型读的一行话** |
| [`limits.ts`](https://npmcn.com/package/%40epoch-agent/plugin-terminal/src/limits.ts) | 输出上限和两句共用措辞。下沉成叶子，避开 import 环 |

前两条路径在执行**期间**把每一块 stdout / stderr 交给 `ctx.onOutput`（宿主那边最终 表现为 `tool-output-delta`）。后台路径没有「执行期间」可言——起完就返回 PID，一个字 都不发。交出去的是**原始字节解码后的文本**，不做任何加工：合并窗口、剥 ANSI、 `\r` 折叠、总量封顶都在 [core](https://npmcn.com/package/core) 的 `tool-output-pump.ts` 里，这样三个宿主 看到的是同一种节奏。宿主不接这条通道时行为与改动前完全一致。

上表的 50 KB 上限管的是**返回给模型的** `output`，和实时通道的封顶是两回事—— 实时那条是纯展示，跑满了就闭嘴，不影响最终结果。

### 超过 50 KB：落盘，不再杀进程（方案 47）

改造前超 50 KB 是**截断 + 把进程杀掉**，理由是「反正也留不住」。落盘之后这个理由 不成立了：命令跑完，全文进 `~/.epoch/artifacts/<会话 id>/`，回给模型的是头 2000 + 尾 2000 字符加一句取回提示（措辞和阈值都在 [spill.ts](https://npmcn.com/package/%40epoch-agent/plugin-terminal/src/spill.ts)）。 这修的是一个很具体的坏结果：`pnpm test` 输出 300 KB 时**测试跑到一半被杀**， 而那恰好是最需要看最后几行的场景。

还剩一条 **10 MB** 的硬上限，超了才恢复截断 + 杀进程 —— 一条命令吐 10 MB 到 stdout 不是一次正常的命令输出。三条路径共用同一句措辞（`OVER_CAPTURE_NOTICE`）。

⚠️ **这一整套要成立，`terminal` 必须声明 `governsOwnOutput: true`**（2026-09-08 补的 一格，在 [index.ts](https://npmcn.com/package/%40epoch-agent/plugin-terminal/src/index.ts) 的工具定义上）。少了它，引擎的输出预算会落在 `spillOutput()` **之后**：刚拼好的「头 2000 + 省略标记 + 尾 2000 + 落盘路径」被按 预算再切一遍，省略标记、尾部和那条取回路径一起没了 —— 而上面那句「最需要看最后几行」 正是这一整套的动机。判据在 `EpochTool.governsOwnOutput` 的 JSDoc 上； ⚠️ **失败路径不吃这个豁免**（那条路上 `spillOutput()` 一次都没被调到）。

落盘一律 **best-effort**：写不进去就退回纯截断、如实说明，**不把一次成功的调用变成 失败**。真正写盘的那几行在 [infra 的 artifact-store](https://npmcn.com/package/infra/src/artifact-store.ts) （`0700` 目录、`0600` 文件、`open(path,'wx')` 独占写，所以预埋 symlink 劫持不了写入）—— 多模态那条落盘路径走的是同一份实现。

三条路径的进程组语义各不相同，逐条写在 `exec.ts` / `pty.ts` 的函数注释里。 `exec.ts` 的主题只有一个：**别留下孤儿进程**。`killProcessTree` 走 SIGTERM → SIGKILL，但另外还有一个「发完 kill 最多再等多久就带着已有输出返回」的 上限——处在不可中断睡眠的进程连 SIGKILL 都不理，不设上限的话用户按了 Esc 之后 这次调用会永远挂着。

## 后台进程谁来收

三条路径起的长期进程（后台命令、一次性 PTY、持久 shell 会话）**全部登记在 [infra](https://npmcn.com/package/infra) 的同一张长期子进程表里** —— 它是这个所有者名下所有长期子进程的 唯一真源。收它的是 [runtime](https://npmcn.com/package/runtime) 的 `dispose()`，所以宿主退出时它们不会 留在机器上，前提是宿主**调了** `dispose()`（能 `await` 就 `await`，收进程树是异步的）。

「一张表」不是巧合，是判据：两张都登记的话，「登记漏了」这件事就没有任何一条用例 挡得住，而方案 49 验收 12 量的正是它（摘掉那一行 → `__tests__/persistent-shell.test.ts` 最后一组当场红）。一次性 PTY 2026-08-20 （方案 60 §三）也并了进来，此前它在 `pty.ts` 里另有一张私表。

⚠️ **那张表不是「全进程一张」**（2026-08-20，方案 60）：它按 `sessionId` 分给各个 runtime，外加一张进程级兜底表（没有会话可言的那些，比如 plugin-lsp 的 server 池）。 起进程时走 `processTableFor(ctx.sessionId)`，**不要**走 `startLongLivedProcess()` / `trackForeignProcess()` 那两个自由函数 —— 它们进的是兜底表，而兜底表要等**最后一个** runtime 把引用计数减到 0 才收：于是这个会话自己的 runtime dispose 掉之后进程还在跑， 一直挂到整个 epoch 进程收摊（`runtime.schedules.fire()` 今天就会造出两个 runtime 并存）。**收得晚，不是被别的 runtime 收走** —— 后者是方案 60 修掉的那个旧行为。 `cleanupBackgroundProcesses(table)` 现在收的是**指定的那一张**。

⚠️ **这一族比后台任务更依赖那次清理。** 后台进程和我们在同一个前台进程组， Ctrl+C 时内核直接发给整组，我们的清理代码一行不跑也不残留。**PTY 没有这个保底** —— `forkpty()` 里 `setsid()` 过，它是另一个会话的组长。所以：

| 退出方式 | 靠谁 |
| --- | --- |
| Ctrl+C / 正常退 | **靠那张表**（`dispose()` 等它收完再 exit） |
| `kill -9 epoch` | 靠内核：pty **主设备**随进程关闭，从设备那侧收到 SIGHUP |

> 2026-08-17 之前这里收的是**三**张表：多出来的那张属于 `runBackground()`， 一个从方案 36 起就零调用方的函数。它连着那张表一起删了。

这些表是**模块级**的（和 `applyShell` 一样，跟着进程走而不是跟着 runtime 走）。 同一个 Node 进程里建多个 runtime 时，先 dispose 的那个会把别人的后台进程一起收掉。 CLI / TUI / server 都是一进程一 runtime，碰不到；嵌入宿主要开多 runtime 就得知道这件事。

## `cd` 不会留存，而且工具会明说

命令是在一次性子 shell 里跑的，退出后 cwd 根本没变。所以检测到裸 `cd` 时，工具返回 一条**准确**的提示：

> `[note] cd only takes effect inside this subshell; the next call still runs in the original working directory. If the following commands have to run in X, pass the workdir parameter, or write it as "cd X && <command>".`

之前这里返回的是「\[工作目录已切换: X\]」——谎报会让模型基于错误的 cwd 假设继续拼 相对路径。**这个插件不维护会话级工作目录。**

## annotations 四个都到顶

```ts
{ readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: true }
```

任意 shell 命令能删数据、能联网，两个方向都到头。`openWorldHint: true` 同时让 [core](https://npmcn.com/package/core) 把输出包进 `<tool_output untrusted="true">`——`curl` 抓回来的东西 和 `web_fetch` 抓的没有区别。

## 平台差异

Windows 默认走 `cmd.exe /d /s /c "<命令>"` + `windowsVerbatimArguments`，POSIX 走 `$SHELL -c`。判定在 [infra](https://npmcn.com/package/infra) 的 `platform.ts`，两处：`shellSpawnArgs` （spawn 用）和 `shellPtyArgs`（node-pty 用）。**不能合并**——node-pty 在 Windows 上 自己有一套转义，数组形式会被二次转义，实测直接「系统找不到指定的路径」，所以 Windows 上给字符串、POSIX 上给数组。

Windows 控制台的代码页解码也在 infra（`createStreamDecoder`）。

### 换 shell（只有 Windows 有这回事）

配 `shell: powershell`（或 `pwsh`，见 [cli](https://npmcn.com/package/cli) 的配置表）之后，这个插件 **一行都不用改**——两个拼参数的函数就是为此存在的抽象边界。但 PowerShell 那条路 和 cmd 完全不是一回事，两点值得知道：

-   **退出码要靠一段收尾语句才准。** PowerShell 5.1 不传播被调命令的退出码， 实测 `-Command "cmd /c exit 3"` 拿到的是 `1`。infra 在命令后面接了一段同时读 `$?` 和 `$LASTEXITCODE` 的收尾语句，`exitCode` 才是真的
-   **危险命令表仍是 cmd / POSIX 口味的。** `command-safety.ts` 里有 PowerShell 的 常见破坏性写法（`Remove-Item -Recurse`、`iwr | iex`、`Start-Process -Verb RunAs` 等），但 `isReadOnlyCommand` 的只读白名单认的是 `ls` / `cat` / `git status` 这类。 于是 `Get-ChildItem` 不在白名单里 → **更常弹权限确认**。方向是安全的那一边， 但用户会明显感觉到多问

POSIX 上这个设置无效，永远 `/bin/sh` —— macOS 走的就是这一支。

### macOS 上要先给 node-pty 的 `spawn-helper` 补执行位

node-pty 1.1.0 发到 npm 的 tarball 里，`prebuilds/darwin-*/spawn-helper` 记的模式是 `-rw-r--r--`——**没有执行位**。而 POSIX 分支不是自己 `fork()`，是 `posix_spawnp()` 这个小可执行文件，于是 macOS 上每一次 PTY 执行都直接 `Error: posix_spawnp failed.`。npm 解包时会按自己的规则重设权限所以看不见， **pnpm 忠实照抄 tarball 的模式，就炸**（2026-08-08 CI 的 macOS 腿实测）。

`pty-helper.ts` 在起第一个 PTY 之前把它 `chmod 0o755`，幂等、只跑一次、 Windows 跳过。放运行时而不是 postinstall：装到用户机器上的是发布出去的包， 我们的 postinstall 管不到别人怎么装。目录只读时 chmod 会失败，那句失败原因会接在 node-pty 的报错后面一起返回，而不是让人对着 `posix_spawnp failed.` 猜。

上游是 microsoft/node-pty#850，修在 #866，只进了 1.2.0-beta 线；`latest` 至今还是 1.1.0。**不为这个升 beta** —— native 模块的 beta 版进产品依赖，风险比这二十行大。

## 开发

```bash
npx vitest run --project=plugin-terminal
```

两个真起进程、真杀、真查残留的端到端用例文件，**改 kill 逻辑必须跑**：

| 文件 | 平台 | 覆盖 |
| --- | --- | --- |
| `kill-paths-e2e.test.ts` | Windows + macOS | 超时 / 中断 / PTY 三条终止路径 |
| `process-tree-e2e.test.ts` | 仅 POSIX | 中断 / 输出超限，用 `sh -c 'sleep & wait'` 造树 |
| `persistent-shell.test.ts` | Windows + macOS | 持久会话：真 PTY、真发输入、真查 tracked 表 |

⚠️ **`persistent-shell.test.ts` 把进程语言钉成 `zh`**（`setLang('zh')`）：那一族的 错误文案走 catalog，而 `t()` 跟进程语言走 —— 不钉的话断言会跟着开发机的 locale 变。

前者造树用 `node spawner.cjs`、查残留用命令行标记（POSIX `pgrep -f`、Windows CIM）， 所以两个平台通吃；后者依赖 shell 方言，Windows 上整个 skip。

⚠️ **PTY 那条用例的曾孙必须 `detached: true`**，否则它在 Windows 上是空的： ConPTY 一关，挂在那个伪控制台上的后代会被系统连坐收掉，我们的树遍历一个字都用不上。 同理 Windows 上 node 起的非 detached 子进程会被 libuv 的全局 Job Object 连坐收掉。 两条都是实测出来的，别按「能跑就行」改回去——细节记在 [验收记录](https://npmcn.com/docs/verify/VERIFY_RECORD-16-process-tree.md) 的「追加二」。

## 常见问题

### @epoch-agent/plugin-terminal 是什么？

epoch-agent 终端命令执行插件

### 如何安装 @epoch-agent/plugin-terminal？

在项目目录运行 npm install @epoch-agent/plugin-terminal，或使用 pnpm add / yarn add / bun add。

### @epoch-agent/plugin-terminal 支持 TypeScript 吗？

支持。包内自带 TypeScript 类型声明（package\.json 声明了 types 入口），安装后即可获得类型提示，无需再安装 @types 包。

### @epoch-agent/plugin-terminal 支持 ESM 还是 CommonJS？

以 ES Module 形式发布，推荐使用 import 引入。在 CommonJS 项目中，Node\.js 20\.19\+ / 22\.12\+ 可以直接 require\(\) 不含顶层 await 的 ES Module，更早的版本需要改用动态 import\(\)。

### @epoch-agent/plugin-terminal 需要什么版本的 Node\.js？

package\.json 的 engines 字段要求 Node\.js \>=22\.0\.0。版本不符时 npm 默认只给出警告，开启 engine-strict 后会拒绝安装。

### @epoch-agent/plugin-terminal 可以商用吗？

@epoch-agent/plugin-terminal 使用 Apache-2\.0 许可。宽松许可：允许商业使用、修改和再分发，并附带明确的专利授权；分发时需保留许可与 NOTICE 文件，修改过的文件要注明改动。以上为概括，不构成法律意见，请以许可原文为准。

### @epoch-agent/plugin-terminal 的下载量有多少？

根据 npm 官方下载统计，@epoch-agent/plugin-terminal 在 2026/10/14 – 2026/11/12 这 30 天内被下载 4,317 次，日均约 144 次。下载量会把 CI 构建、镜像同步和作为其他包依赖的安装一并计入，适合横向比较热度，不能直接等同于使用人数。

### @epoch-agent/plugin-terminal 还在维护吗？

最新版本 6\.2\.0 发布于 2026/11/13（今天）；至今共发布 57 个版本，其中过去 12 个月发布了 57 个；首次发布于 2026/08/19。发布频率只是参考信号：功能稳定的工具可能很少需要更新，建议结合源码仓库的 issue 和提交记录判断。

