@epoch-agent/plugin-terminal

中文文档
6.2.0发布于 2026/11/13Apache-2.0TypeScriptES Module近 30 天下载 4317

epoch-agent 终端命令执行插件

中文使用文档
文档目录17 节
  1. 沙箱:三条路径都接上了,但 只管写入 ,而且关得掉
  2. 后台任务: pnpm build 三分钟不用干等
  3. 作业控制器是 kind 无关的(方案 49 PR-3,2026-08-20)
  4. 任务表按会话分(2026-08-15)
  5. 为什么后台任务不能活过 epoch
  6. 持久 shell 会话: cd / venv / REPL 留存(方案 49,2026-08-20)
  7. 源码
  8. 参数
  9. 三条执行路径
  10. 超过 50 KB:落盘,不再杀进程(方案 47)
  11. 后台进程谁来收
  12. cd 不会留存,而且工具会明说
  13. annotations 四个都到顶
  14. 平台差异
  15. 换 shell(只有 Windows 有这回事)
  16. macOS 上要先给 node-pty 的 spawn-helper 补执行位
  17. 开发

@epoch-agent/plugin-terminal

终端插件。十个工具,分三族:

族工具语义
一次性命令terminal起进程、跑完、收干净
作业控制(方案 36/49)task_list / task_output / task_wait / task_stopkind 无关,见下
持久会话(方案 49)shell_open / shell_send / shell_read / shell_list / shell_close起来还要接着聊

⚠️ 第二族 2026-08-20(方案 49 PR-3)起不认 kind 了。 它们背后是一张住在 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 的 confine())。危险命令表是 护栏——它拦的是手滑,不是对抗,上了沙箱之后这条一个字没松。 后台任务和持久会话都不能活过 epoch 进程(刻意的,见下)。 不做 shell_signal、不做 shell_wait、不做跨会话共享(判据见下)
  • 依赖:node-pty。protocol 和 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 一并删了。

边界按权限档位推导,用户不多学一个概念:

权限档位沙箱模式能写到哪
planread-only哪都不行(/dev/null 除外)
default / acceptEditsworkspace-write工作区 + 额外根 + 临时目录 + 工具链缓存
autodanger-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)。 它落在 sandboxPolicyFor() 算出来的 SandboxPolicy.enabled 上, 所以上面那张表和它是两个轴:

  • 关掉开关不改 mode —— default 档关掉之后报的仍然是 workspace-write + 「没包上,原因是开关关着」,不是 danger-full-access。 这一行就是「最宽那一档不是这个开关的替代品」的判据:它连审批一起放开, 这个开关一点审批都不动(方案 46 §2.3「我们不做二维」)
  • 关着的时候工具输出那一行逐字说清是配置关的,和「这个平台没有后端」 是两句不同的话 —— 说错那句的代价是用户按着一句可操作的谎去改档位
  • 用例:__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(阶梯 / 成对 / 词表),入口在 core 的 ToolExecutor.resolveEscalation()(审批), 生效在 sandbox.ts 的 sandboxPolicyFor()(mode 覆盖 + bypass: 'escalated')
  • 永远只批这一次:那条决定不进审批缓存,宿主也画不出「永久允许」
  • ⚠️ shell_open 的粒度是整个会话(边界在开的那一刻定下), 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 文件头, 对外的完整清单在 docs/TOOLS.md)。 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。

后台那条同理有一条要验的:sandbox-exec 会不会破坏 infra 那条 detached: false 的决定(Ctrl+C 靠内核发给整个前台进程组)。 实测没有 —— 包与不包,整棵树的 pgid 都等于我们自己,而且 sandbox-exec 不 fork 一层(套上 profile 之后直接 exec,同一个 pid)。真发一次 SIGINT 查残留的用例在 __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_* 四个不认 kindshell_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
输出单任务 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 的文件头。

真删会话会把它们全停掉(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 的文件头: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 §十二。

三样明确不做: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会话表:起 PTY、环形缓冲、空闲回收、登记进 infra
shell/tools.ts五个工具的契约(参数 / 权限 / 危险命令拦截)
shell/notice.ts给模型读的输出措辞(整份进 i18n 的 SKIP_FILES)
shell/limits.ts四条上限 + 契约类型。下沉成叶子,避开 import 环

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

参数

terminal 的 command / background / pty / stdin / workdir,以及 shell_* 那五个的参数,逐个说明和默认值在 docs/TOOLS.md —— 那份是工具清单的唯一真源。

「在工作区内」(workdir 的判定)用的是 infra 的 isInWorkspace() —— 全仓唯一一份,权限层和 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。

源码按关注点分(2026-08-17 从一个 649 行的 exec.ts 拆开,判据在各自的文件头):

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

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

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

超过 50 KB:落盘,不再杀进程(方案 47)

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

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

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

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

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

后台进程谁来收

三条路径起的长期进程(后台命令、一次性 PTY、持久 shell 会话)全部登记在 infra 的同一张长期子进程表里 —— 它是这个所有者名下所有长期子进程的 唯一真源。收它的是 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 四个都到顶

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

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

平台差异

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

Windows 控制台的代码页解码也在 infra(createStreamDecoder)。

换 shell(只有 Windows 有这回事)

配 shell: powershell(或 pwsh,见 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 版进产品依赖,风险比这二十行大。

开发

npx vitest run --project=plugin-terminal

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

文件平台覆盖
kill-paths-e2e.test.tsWindows + macOS超时 / 中断 / PTY 三条终止路径
process-tree-e2e.test.ts仅 POSIX中断 / 输出超限,用 sh -c 'sleep & wait' 造树
persistent-shell.test.tsWindows + 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 连坐收掉。 两条都是实测出来的,别按「能跑就行」改回去——细节记在 验收记录 的「追加二」。

版本与发布记录

@epoch-agent/plugin-terminal 共发布 57 个版本,首次发布于 2026/08/19;过去 12 个月发布了 57 个版本。

版本发布日期距今
6.2.0latest2026/11/13今天
6.1.02026/11/112 天前
6.0.02026/11/112 天前
5.0.02026/11/103 天前
4.1.12026/11/067 天前
4.1.02026/11/067 天前

关于 @epoch-agent/plugin-terminal 的常见问题

@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 和提交记录判断。