@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 安全了」:
- 只管写入。 读取和网络都不设限 —— 给终端上读白名单会让
git push(要读~/.ssh)和pnpm install(要读~/.npmrc)当场废掉。 沙箱里的命令还能ps看见主机上别的进程。 ⚠️auto/bypass两档连写入都不挡(上面那张表),这两档下第 1 条不适用。 - 「三条路径都包上了」不等于「terminal 安全了」。 安全中心那份「不在沙箱里」的 点名清单现在是空的,它只说没有哪条路径看起来在沙箱里而实际不在 —— 第 1 条和第 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 连坐收掉。
两条都是实测出来的,别按「能跑就行」改回去——细节记在
验收记录 的「追加二」。
