npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@epoch-agent/plugin-terminal

v0.25.0

Published

epoch-agent 终端命令执行插件

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 的作业表(src/jobs.ts),里面同时装着 terminal({ background: true }) 起的后台命令(t1)和 shell_open 开的持久会话(s1),将来还有后台子 agent。 于是模型只学一套「列出来 / 读输出 / 停掉」,而不是每加一类作业就多学一套。

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

沙箱:三条路径都接上了,但只管写入,而且关得掉

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 / bypass | danger-full-access | 不设限 —— 但危险命令表照旧拦 |

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

外加一个开关:sandbox.terminal: false(2026-08-21,方案 46 §11.3 第一条,见 CONFIGURATION.md)。 它落在 sandboxPolicyFor() 算出来的 SandboxPolicy.enabled 上, 所以上面那张表和它是两个轴:

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

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

  1. 只管写入。 读取和网络都不设限 —— 给终端上读白名单会让 git push (要读 ~/.ssh)和 pnpm install(要读 ~/.npmrc)当场废掉。 沙箱里的命令还能 ps 看见主机上别的进程。 ⚠️ auto / bypass 两档连写入都不挡(上面那张表),这两档下第 1 条不适用。
  2. 「三条路径都包上了」不等于「terminal 安全了」。 安全中心那份「不在沙箱里」的 点名清单现在是空的,它只说没有哪条路径看起来在沙箱里而实际不在 —— 第 1 条和第 3 条一个字都没变。那份清单也不认上面那个开关(它是一张静态的 工具名清单)—— 开关关着时唯一如实说话的地方是每条命令自己的那一行回执。
  3. Windows 上没有后端,所以那儿一次都没包上 —— 工具输出里会逐字说 「本次没有 OS 级隔离」。epoch doctor 的沙箱一节印的是实测值。

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 会话 一起列出来、逐行标出是哪一种([命令] / [会话]),task_output s1 读得到 会话的输出,task_stop s1 等价于 shell_close s1(走的是同一个 stopJob())。

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

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

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

给宿主的两个口子(listTasks / taskOutput,runtime 转给 server / TUI) 仍然只报后台命令那一类。 判据在 src/background.ts 的文件头:它们的落点是 BackgroundTaskInfo 那个线上形状,而把持久会话塞进去意味着 command 那一格 要填一个工作目录 —— 那是一句假话。宿主界面要不要显示持久会话是一次界面决定。

六条边界(「谁看得见」那一条是 2026-08-15 新加的,它以前的答案是「所有人」):

| 事情 | 取舍 | | --------------- | ---------------------------------------------------------------------------------------------------------------------- | | 活过一轮对话 | 会(任务表挂在会话上,不是一次工具调用) | | 谁看得见 | 只有起它的那个会话(2026-08-15 收窄,见下) | | 活过 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 的文件头。

为什么后台任务不能活过 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' })                     → 关掉(杀整棵树)

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.ts | PTY 那条路。它的进程组语义和另外两条相反 | | 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 时,工具返回 一条准确的提示:

[提示] cd 只在本次子 shell 内生效,下次调用仍在原工作目录。若后续命令需要在 X 下执行,请传 workdir 参数,或写成 "cd X && <命令>"。

之前这里返回的是「[工作目录已切换: 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 版进产品依赖,风险比这二十行大。

开发

pnpm --filter @epoch-agent/plugin-terminal test

两个真起进程、真杀、真查残留的端到端用例文件,改 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 连坐收掉。 两条都是实测出来的,别按「能跑就行」改回去——细节记在 验收记录 的「追加二」。