@epoch-agent/plugin-terminal 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (4) hide show
  1. package/README.md +148 -14
  2. package/dist/index.d.ts +510 -135
  3. package/dist/index.js +825 -355
  4. package/package.json +3 -3
package/README.md CHANGED
@@ -1,17 +1,28 @@
1
1
  # @epoch-agent/plugin-terminal
2
2
 
3
- 终端插件。**五个工具**:`terminal` + 后台任务的 `task_list` / `task_output` /
4
- `task_wait` / `task_stop`。
3
+ 终端插件。**十个工具**,分三族:
4
+
5
+ | 族 | 工具 | 语义 |
6
+ | ---------------------- | ------------------------------------------------------------------------- | -------------------- |
7
+ | 一次性命令 | `terminal` | 起进程、跑完、收干净 |
8
+ | 作业控制(方案 36/49) | `task_list` / `task_output` / `task_wait` / `task_stop` | **kind 无关**,见下 |
9
+ | 持久会话(方案 49) | `shell_open` / `shell_send` / `shell_read` / `shell_list` / `shell_close` | 起来还要接着聊 |
10
+
11
+ ⚠️ **第二族 2026-08-20(方案 49 PR-3)起不认 kind 了。** 它们背后是**一张**住在
12
+ [infra](../../infra) 的作业表(`src/jobs.ts`),里面同时装着 `terminal({ background: true })`
13
+ 起的后台命令(`t1`)和 `shell_open` 开的持久会话(`s1`),将来还有后台子 agent。
14
+ 于是模型只学一套「列出来 / 读输出 / 停掉」,而不是每加一类作业就多学一套。
5
15
 
6
16
  - ✅ **做**:起进程、收输出(执行期间同时喂给 `ctx.onOutput`)、到点杀干净(含孙进程)、
7
- 危险命令拦截、workdir 边界校验、**三条执行路径各过一道进程沙箱**(2026-08-17,见下);
8
- **后台长任务**(不等它跑完就返回)
17
+ 危险命令拦截、workdir 边界校验、**每条执行路径各过一道进程沙箱**(2026-08-17,见下);
18
+ **后台长任务**(不等它跑完就返回);**持久 shell 会话**(`cd` / `venv` / REPL 留存)
9
19
  - ❌ **不做**:不自己实现沙箱(调 [infra](../../infra) 的 `confine()`)。危险命令表是
10
20
  **护栏**——它拦的是手滑,不是对抗,**上了沙箱之后这条一个字没松**。
11
- 后台任务**不能活过 epoch 进程**(刻意的,见下)
21
+ 后台任务和持久会话**都不能活过 epoch 进程**(刻意的,见下)。
22
+ 不做 `shell_signal`、不做 `shell_wait`、不做跨会话共享(判据见下)
12
23
  - **依赖**:`node-pty`。[protocol](../../protocol) 和 [infra](../../infra) 是 peer
13
24
 
14
- ## 沙箱:三条路径都包上了,但**只管写入**
25
+ ## 沙箱:三条路径都接上了,但**只管写入**,而且关得掉
15
26
 
16
27
  2026-08-16(方案 46 PR-1)之前,`code_exec` 有 OS 强制的隔离而 `terminal` **什么都没有** ——
17
28
  后者的调用频率高一个量级。现在三条执行路径在起进程之前各过一道 `confine()`:
@@ -33,6 +44,20 @@
33
44
  | `auto` / `default` / `acceptEdits` | `workspace-write` | 工作区 + 额外根 + 临时目录 + 工具链缓存 |
34
45
  | `bypass` | `danger-full-access` | 不设限 —— **但危险命令表照旧拦** |
35
46
 
47
+ **外加一个开关**:`sandbox.terminal: false`(2026-08-21,方案 46 §11.3 第一条,见
48
+ [CONFIGURATION.md](../../../docs/CONFIGURATION.md#关掉-terminal-的-os-沙箱))。
49
+ 它落在 `sandboxPolicyFor()` 算出来的 `SandboxPolicy.enabled` 上,
50
+ 所以**上面那张表和它是两个轴**:
51
+
52
+ - 关掉开关**不改 `mode`** —— `default` 档关掉之后报的仍然是
53
+ `workspace-write` + 「没包上,原因是开关关着」,不是 `danger-full-access`。
54
+ 这一行就是「`bypass` 不是这个开关的替代品」的判据:`bypass` 连审批一起放开,
55
+ 这个开关一点审批都不动(方案 46 §2.3「我们不做二维」)
56
+ - 关着的时候工具输出那一行逐字说清是**配置**关的,和「这个平台没有后端」
57
+ 是两句不同的话 —— 说错那句的代价是用户按着一句可操作的谎去改档位
58
+ - 用例:[`__tests__/sandbox-switch.test.ts`](__tests__/sandbox-switch.test.ts)(这一层)
59
+ 和 `runtime/__tests__/sandbox-switch-e2e.test.ts`(从 yaml 走到工具输出)
60
+
36
61
  ⚠️ **三件必须说清楚的事**,不然「terminal 有沙箱了」会被读成「terminal 安全了」:
37
62
 
38
63
  1. **只管写入。** 读取和网络都**不设限** —— 给终端上读白名单会让 `git push`
@@ -40,7 +65,8 @@
40
65
  沙箱里的命令还能 `ps` 看见主机上别的进程。
41
66
  2. **「三条路径都包上了」不等于「terminal 安全了」。** 安全中心那份「不在沙箱里」的
42
67
  点名清单现在是空的,它只说**没有哪条路径看起来在沙箱里而实际不在** ——
43
- 第 1 条和第 3 条一个字都没变。
68
+ 第 1 条和第 3 条一个字都没变。那份清单也**不认上面那个开关**(它是一张静态的
69
+ 工具名清单)—— 开关关着时唯一如实说话的地方是每条命令自己的那一行回执。
44
70
  3. **Windows 上没有后端**,所以那儿一次都没包上 —— 工具输出里会逐字说
45
71
  「本次没有 OS 级隔离」。`epoch doctor` 的沙箱一节印的是实测值。
46
72
 
@@ -73,6 +99,27 @@ task_list() → 看全部
73
99
  永远不返回,而且老的后台分支是 `stdio: 'ignore'`,`listening on :3000` 那一行
74
100
  根本不存在。
75
101
 
102
+ ### 作业控制器是 kind 无关的(方案 49 PR-3,2026-08-20)
103
+
104
+ 上面那四个工具**不只看得见后台命令**:`task_list` 把后台命令和持久 shell 会话
105
+ 一起列出来、**逐行标出是哪一种**(`[命令]` / `[会话]`),`task_output s1` 读得到
106
+ 会话的输出,`task_stop s1` 等价于 `shell_close s1`(走的是同一个 `stopJob()`)。
107
+
108
+ | 这一轮变了什么 | 这一轮**没**变什么 |
109
+ | ---------------------------- | ------------------------------------------------------- |
110
+ | 两张作业表合成一张,住 infra | 两族的**语义**分界线(后台不管了 / 持久要接着聊) |
111
+ | `task_*` 四个不认 kind | `shell_send` 这种只有持久会话才有的动作,照旧在那一族里 |
112
+ | 环形缓冲 / 溢出落盘只剩一份 | 三条上限的**取值**(后台 8 个 / 会话 4 个)各是各的 |
113
+ | 工具名 —— **一个都没改** | `listTasks()` 这个给宿主的口子仍然只报后台命令,见下 |
114
+
115
+ 一个例外:`task_wait` 遇到持久会话会**立刻**返回一句「它没有『结束』这个状态」,
116
+ 而不是干等到超时 —— 那是方案 §五「不做 `shell_wait`」的理由说给模型听一遍。
117
+
118
+ **给宿主的两个口子(`listTasks` / `taskOutput`,runtime 转给 server / TUI)
119
+ 仍然只报后台命令那一类。** 判据在 `src/background.ts` 的文件头:它们的落点是
120
+ `BackgroundTaskInfo` 那个线上形状,而把持久会话塞进去意味着 `command` 那一格
121
+ 要填一个工作目录 —— 那是一句假话。宿主界面要不要显示持久会话是一次界面决定。
122
+
76
123
  六条边界(「谁看得见」那一条是 2026-08-15 新加的,它以前的答案是「所有人」):
77
124
 
78
125
  | 事情 | 取舍 |
@@ -132,9 +179,69 @@ A 会话每轮被告知一批它没起过的命令在跑,而一句 `task_stop
132
179
  > 权限判定 —— 同一条命令前台要确认,后台也要。四个 task 工具本身不执行命令
133
180
  > (`operation: 'file_read'`),所以取一次输出不会弹确认框。用例守着这两条。
134
181
 
182
+ ## 持久 shell 会话:`cd` / `venv` / REPL 留存(方案 49,2026-08-20)
183
+
184
+ ```
185
+ shell_open({ workdir }) → 开一个,返回 s1
186
+ shell_send({ id: 's1', input: 'cd src\n' }) → 发输入,等 wait 毫秒后回这段输出
187
+ shell_read({ id: 's1', since }) → 增量读(游标语义同 task_output)
188
+ shell_list() → 看全部
189
+ shell_close({ id: 's1' }) → 关掉(杀整棵树)
190
+ ```
191
+
192
+ `terminal` 三条路径**都是一次性的**,所以 `cd` 不留存、`source venv/bin/activate`
193
+ 不留存、`export` 不留存、进了 REPL 就出不来 —— 这一族答的就是这个。
194
+
195
+ **为什么是新工具族而不是 `terminal` 的第四种模式**(2026-08-14 拍板):`terminal`
196
+ 已经有三种语义,而它们的超时、输出上限、返回值、审批口径各不相同。加第四种,
197
+ 工具描述会长成一张真值表 —— 而模型最容易在这种工具上选错档。名字用 `shell_*`
198
+ 不用 `terminal_*`:`terminal` 本身已经是一个工具,`terminal_open` 并列会让模型
199
+ 以为前者是后者的一个模式。
200
+
201
+ | 事情 | 取舍 |
202
+ | --------------- | ------------------------------------------------------------------------- |
203
+ | 活过一轮对话 | **会** |
204
+ | 谁看得见 | **只有开它的那个会话**(同后台任务) |
205
+ | 活过 epoch 进程 | **不会**,见下面那一节 —— 而且这一族比后台任务更依赖我们自己的清理代码 |
206
+ | 同时几个 | **每个会话 4 个**(一个 PTY 比一个管道进程重),超了拒绝并列出在跑的 |
207
+ | 空闲 | **10 分钟**没动就回收;之后 `shell_send` 明说「已被回收,里面的东西没了」 |
208
+ | 输出 | 单会话 256KB **环形**缓冲(丢头保尾)+ 溢出全文落 artifact,单次读 8KB |
209
+ | 沙箱 | 和 `terminal` **同一个** `confineShell()`、同一张边界表、同一个开关 |
210
+ | 工作区边界 | 和 `terminal` **同一条** `isInWorkspace()` 判定,越界在起进程之前就被拒 |
211
+
212
+ ⚠️ **换行要自己带。** `input: 'pwd'` 只是把三个字符打上去,`'pwd\n'` 才会执行。
213
+ 刻意不替调用方补:补了的话 `'\x03'`(Ctrl+C)会变成「Ctrl+C 加一个回车」。
214
+ 写 `\n` 两个平台都对 —— 带进来的换行会被 `asKeystrokes()` 翻译成那个平台上真正的
215
+ 回车键(**Windows 的 ConPTY 只认 `\r`**)。**只翻译换行、不追加**,
216
+ 所以上面那条 Ctrl+C 的走法没变;判据和实测在
217
+ [VERIFY_RECORD-49](../../../docs/verify/VERIFY_RECORD-49-persistent-shell.md) §十二。
218
+
219
+ 三样明确不做:**`shell_signal`**(往 PTY 里写 `\x03` 就是 Ctrl+C,比给进程发信号更准;
220
+ 而「发给进程组还是直接子进程」是个没答的问题,不做就不必答)、**`shell_wait`**
221
+ (持久会话没有「结束」这个状态,等什么?`shell_send` 的 `wait` 覆盖了「等一下再读」)、
222
+ **跨会话共享**。
223
+
224
+ `shell?` 参数(方案 §1.2 的表里列着)**这一轮没做**:POSIX 上永远 `/bin/sh`
225
+ (`getDefaultShell()` 的既有决定),Windows 上跟 `config.shell` 走 —— 加一个
226
+ per-call 覆盖等于让「哪个 shell 在跑」有第二个说了算的地方,而模型要换 shell
227
+ 直接 `shell_send('powershell\n')` 就行。
228
+
229
+ ### 源码
230
+
231
+ | 文件 | 管什么 |
232
+ | -------------------------------------------- | ------------------------------------------------- |
233
+ | [`shell/sessions.ts`](src/shell/sessions.ts) | 会话表:起 PTY、环形缓冲、空闲回收、登记进 infra |
234
+ | [`shell/tools.ts`](src/shell/tools.ts) | 五个工具的契约(参数 / 权限 / 危险命令拦截) |
235
+ | [`shell/notice.ts`](src/shell/notice.ts) | 给模型读的输出措辞(整份进 i18n 的 `SKIP_FILES`) |
236
+ | [`shell/limits.ts`](src/shell/limits.ts) | 四条上限 + 契约类型。下沉成叶子,避开 import 环 |
237
+
238
+ 错误文案走 `t()`,key 在 `locales/{zh,en}.yaml` 的 `shell:` 一节 ——
239
+ 和「给模型读的输出」分两处,判据在 `shell/notice.ts` 的文件头。
240
+
135
241
  ## 参数
136
242
 
137
- `command` / `background` / `pty` / `stdin` / `workdir`,**逐个说明和默认值在
243
+ `terminal` 的 `command` / `background` / `pty` / `stdin` / `workdir`,以及
244
+ `shell_*` 那五个的参数,**逐个说明和默认值在
138
245
  [docs/TOOLS.md](../../../docs/TOOLS.md#终端与后台任务)** —— 那份是工具清单的唯一真源。
139
246
 
140
247
  「在工作区内」(`workdir` 的判定)用的是 infra 的
@@ -145,8 +252,8 @@ A 会话每轮被告知一批它没起过的命令在跑,而一句 `task_stop
145
252
  ## 三条执行路径
146
253
 
147
254
  管道(默认)走 `child_process.spawn`,PTY(`pty: true`)走 `node-pty`,
148
- 后台(`background: true`)走 infra 的 `startLongLivedProcess()` 并登记进任务表,
149
- 只回一个任务 id。三条各自的超时和输出上限见
255
+ 后台(`background: true`)走 infra 的长期子进程表(`processTableFor(sessionId).start()`)
256
+ 并登记进任务表,只回一个任务 id。三条各自的超时和输出上限见
150
257
  [docs/TOOLS.md](../../../docs/TOOLS.md#终端与后台任务)。
151
258
 
152
259
  源码按**关注点**分(2026-08-17 从一个 649 行的 `exec.ts` 拆开,判据在各自的文件头):
@@ -193,10 +300,33 @@ SIGTERM → SIGKILL,但另外还有一个「发完 kill 最多再等多久就
193
300
 
194
301
  ## 后台进程谁来收
195
302
 
196
- `background: true` 起的进程登记在 [infra](../../infra) 的 pid 表里(那是所有长期子进程的
197
- 唯一真源),PTY 会话记在 `pty.ts` 的模块级表里,两边由 [runtime](../../runtime)
198
- `dispose()` `cleanupBackgroundProcesses()` 统一收掉 —— 所以宿主退出时它们不会留在
199
- 机器上,前提是宿主**调了** `dispose()`(能 `await` 就 `await`,收进程树是异步的)。
303
+ 三条路径起的长期进程(后台命令、一次性 PTY、持久 shell 会话)**全部登记在
304
+ [infra](../../infra) 的同一张长期子进程表里** —— 它是这个所有者名下所有长期子进程的
305
+ 唯一真源。收它的是 [runtime](../../runtime) `dispose()`,所以宿主退出时它们不会
306
+ 留在机器上,前提是宿主**调了** `dispose()`(能 `await` 就 `await`,收进程树是异步的)。
307
+
308
+ 「一张表」不是巧合,是判据:两张都登记的话,「登记漏了」这件事就没有任何一条用例
309
+ 挡得住,而方案 49 验收 12 量的正是它(摘掉那一行 →
310
+ `__tests__/persistent-shell.test.ts` 最后一组当场红)。一次性 PTY 2026-08-20
311
+ (方案 60 §三)也并了进来,此前它在 `pty.ts` 里另有一张私表。
312
+
313
+ ⚠️ **那张表不是「全进程一张」**(2026-08-20,方案 60):它按 `sessionId` 分给各个
314
+ runtime,外加一张进程级兜底表(没有会话可言的那些,比如 plugin-lsp 的 server 池)。
315
+ 起进程时走 `processTableFor(ctx.sessionId)`,**不要**走 `startLongLivedProcess()` /
316
+ `trackForeignProcess()` 那两个自由函数 —— 它们进的是兜底表,而兜底表要等**最后一个**
317
+ runtime 把引用计数减到 0 才收:于是这个会话自己的 runtime dispose 掉之后进程还在跑,
318
+ 一直挂到整个 epoch 进程收摊(`runtime.schedules.fire()` 今天就会造出两个 runtime
319
+ 并存)。**收得晚,不是被别的 runtime 收走** —— 后者是方案 60 修掉的那个旧行为。
320
+ `cleanupBackgroundProcesses(table)` 现在收的是**指定的那一张**。
321
+
322
+ ⚠️ **这一族比后台任务更依赖那次清理。** 后台进程和我们在同一个前台进程组,
323
+ Ctrl+C 时内核直接发给整组,我们的清理代码一行不跑也不残留。**PTY 没有这个保底** ——
324
+ `forkpty()` 里 `setsid()` 过,它是另一个会话的组长。所以:
325
+
326
+ | 退出方式 | 靠谁 |
327
+ | --------------- | ------------------------------------------------------- |
328
+ | Ctrl+C / 正常退 | **靠那张表**(`dispose()` 等它收完再 exit) |
329
+ | `kill -9 epoch` | 靠内核:pty **主设备**随进程关闭,从设备那侧收到 SIGHUP |
200
330
 
201
331
  > 2026-08-17 之前这里收的是**三**张表:多出来的那张属于 `runBackground()`,
202
332
  > 一个从方案 36 起就零调用方的函数。它连着那张表一起删了。
@@ -280,6 +410,10 @@ pnpm --filter @epoch-agent/plugin-terminal test
280
410
  | -------------------------- | --------------- | ----------------------------------------------- |
281
411
  | `kill-paths-e2e.test.ts` | Windows + macOS | 超时 / 中断 / PTY 三条终止路径 |
282
412
  | `process-tree-e2e.test.ts` | 仅 POSIX | 中断 / 输出超限,用 `sh -c 'sleep & wait'` 造树 |
413
+ | `persistent-shell.test.ts` | Windows + macOS | 持久会话:真 PTY、真发输入、真查 tracked 表 |
414
+
415
+ ⚠️ **`persistent-shell.test.ts` 把进程语言钉成 `zh`**(`setLang('zh')`):那一族的
416
+ 错误文案走 catalog,而 `t()` 跟进程语言走 —— 不钉的话断言会跟着开发机的 locale 变。
283
417
 
284
418
  前者造树用 `node spawner.cjs`、查残留用命令行标记(POSIX `pgrep -f`、Windows CIM),
285
419
  所以两个平台通吃;后者依赖 shell 方言,Windows 上整个 skip。