@epoch-agent/plugin-terminal 0.1.0 → 0.3.1
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.
- package/README.md +148 -14
- package/dist/index.d.ts +510 -135
- package/dist/index.js +825 -355
- package/package.json +3 -3
package/dist/index.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { BackgroundTaskInfo, EpochTool, EpochPlugin } from '@epoch-agent/protocol';
|
|
2
|
-
import { SandboxMode, IsolationBackend, SandboxEnforcement, FailureVerdict, SandboxPolicy } from '@epoch-agent/infra';
|
|
2
|
+
import { SandboxMode, IsolationBackend, SandboxEnforcement, FailureVerdict, ProcessTable, SandboxPolicy } from '@epoch-agent/infra';
|
|
3
3
|
|
|
4
4
|
/**
|
|
5
5
|
* 把一次 shell 调用包进沙箱,并算出这一次要**如实上报**的东西(方案 46 §四)。
|
|
@@ -40,12 +40,17 @@ interface SandboxOutcome {
|
|
|
40
40
|
/**
|
|
41
41
|
* 没包上的原因,包上了就是 `null`。
|
|
42
42
|
*
|
|
43
|
-
*
|
|
44
|
-
* `
|
|
43
|
+
* 四档是四句不同的话,不能合成一个「没有沙箱」:
|
|
44
|
+
* `config-disabled` 是**用户把 `sandbox.terminal` 关了**(方案 46 §11.3 第一条),
|
|
45
|
+
* `mode-disabled` 是**用户选了 `bypass` 档**,
|
|
45
46
|
* `no-backend` 是**平台限制**(Windows / 没装 bwrap),
|
|
46
47
|
* `not-requested` 是**调用方没传 policy**(今天只有用例会这样)。
|
|
48
|
+
*
|
|
49
|
+
* 前两档看着都是「用户自己选的」,但它们是两个正交的轴,分开的理由写在
|
|
50
|
+
* infra 的 `Confinement.reason` 上:说错那一句的代价是用户按着一句
|
|
51
|
+
* 可操作的谎去改档位,而改完照样没有沙箱。
|
|
47
52
|
*/
|
|
48
|
-
skipped: 'mode-disabled' | 'no-backend' | 'not-requested' | null;
|
|
53
|
+
skipped: 'config-disabled' | 'mode-disabled' | 'no-backend' | 'not-requested' | null;
|
|
49
54
|
/**
|
|
50
55
|
* 非零退出时这次失败属于哪一种(方案 46 §五)。
|
|
51
56
|
*
|
|
@@ -94,96 +99,135 @@ interface SandboxOutcome {
|
|
|
94
99
|
/**
|
|
95
100
|
* 清理这个插件起过的长期子进程 [Hermes]。杀的是整棵树而不是组长。
|
|
96
101
|
*
|
|
97
|
-
* 调用方是 `runtime` 的 `dispose()`(2026-08-08 接上;在那之前它零调用方,
|
|
98
|
-
* `terminal(background: true)` 起的进程在宿主退出后是全留着的)。
|
|
99
102
|
* 因为要 `pgrep`(POSIX)/ `taskkill`(Windows)收进程树,它只能是 async ——
|
|
100
|
-
*
|
|
101
|
-
*
|
|
102
|
-
*
|
|
103
|
-
*
|
|
104
|
-
*
|
|
105
|
-
*
|
|
106
|
-
*
|
|
107
|
-
*
|
|
108
|
-
*
|
|
109
|
-
*
|
|
110
|
-
*
|
|
111
|
-
*
|
|
103
|
+
* 这正是 2026-08-08 之前它一直零调用方的原因(`dispose()` 那时是同步的,于是
|
|
104
|
+
* `terminal(background: true)` 起的进程在宿主退出后全留着)。
|
|
105
|
+
*
|
|
106
|
+
* ⚠️ **今天它在生产路径上又是零调用方了,而这一次是对的。** 方案 60 之后
|
|
107
|
+
* `runtime` 的 `dispose()` 收表走的是 `OwnedProcessTable.release()`(那一下同时
|
|
108
|
+
* 管兜底表的引用计数,这个函数管不了),不再经过这里。留着它是因为它是这个包
|
|
109
|
+
* **发出去的公开 API**(`index.ts` 有 re-export),嵌入宿主可以拿它收自己那张表;
|
|
110
|
+
* 仓库内今天只有 `runtime/__tests__/background-cleanup.test.ts` 在调。
|
|
111
|
+
*
|
|
112
|
+
* ## ⚠️ 2026-08-20(方案 60):**收哪一张表现在是参数说了算**
|
|
113
|
+
*
|
|
114
|
+
* 这里原来收的是「所有表」,而那是一条真 bug:同一个进程里两个 runtime 并存时
|
|
115
|
+
* (`runtime.schedules.fire()` 会造出来,`buildRuntime()` 的失败路径更早),
|
|
116
|
+
* 先走的那个会把另一个正在跑的后台进程和 PTY 一起收掉,**一个字不报**。
|
|
117
|
+
* 判据全文在 [infra/child-process.ts](../../../infra/src/child-process.ts) 的
|
|
118
|
+
* `ProcessTable` 上。
|
|
119
|
+
*
|
|
120
|
+
* 两张半表也在同一轮并成了一张:一次性 PTY 那份私表([pty.ts](./pty.js))
|
|
121
|
+
* 现在起 pty 的那一刻就登记进同一张长期子进程表,不再需要第二次收尾。
|
|
122
|
+
*
|
|
123
|
+
* @param table 收哪一张。**不给 = 收进程级兜底表那一张** —— 那是给
|
|
124
|
+
* 「明确不属于任何 runtime」的调用方(用例的清场、plugin-lsp 的 server 池)
|
|
125
|
+
* 留的语义,和这个函数发出去时的含义一致。**不带参数那条路走的是
|
|
126
|
+
* `processFallbackTable().killAll()`,不是 `killAllTrackedProcesses()`** ——
|
|
127
|
+
* 后者收所有表,这里一张都不多收。自己管着一张表的嵌入宿主传自己那张。
|
|
112
128
|
*/
|
|
113
|
-
declare function cleanupBackgroundProcesses(): Promise<void>;
|
|
129
|
+
declare function cleanupBackgroundProcesses(table?: ProcessTable): Promise<void>;
|
|
114
130
|
|
|
115
131
|
/**
|
|
116
|
-
*
|
|
132
|
+
* 后台命令这一**类作业**的产出方(方案 36 起,2026-08-20 方案 49 PR-3 下沉)。
|
|
117
133
|
*
|
|
118
|
-
* 一条 `terminal(background: true)`
|
|
119
|
-
*
|
|
134
|
+
* 一条 `terminal(background: true)` 起的命令在共用作业表里有一格:状态、退出码、
|
|
135
|
+
* 一段环形输出缓冲、以及一个「结束了」的 promise。
|
|
120
136
|
*
|
|
121
|
-
* ##
|
|
137
|
+
* ## ✅ 2026-08-20:那张表**不在这个文件里了**
|
|
122
138
|
*
|
|
123
|
-
*
|
|
124
|
-
*
|
|
125
|
-
*
|
|
126
|
-
*
|
|
127
|
-
*
|
|
139
|
+
* 改造前这里有一整张 `Map<sessionId, Map<taskId, Task>>`,自带环形缓冲、溢出
|
|
140
|
+
* 落盘、`since` 游标、按会话分区、停掉 —— 而 `shell/sessions.ts`(持久会话)
|
|
141
|
+
* 有**逐字同款的第二份**。方案 §三 的判据是「后台跑着的东西不管是什么,
|
|
142
|
+
* 都用同一套控制」,于是整张表下沉进了
|
|
143
|
+
* [infra/jobs.ts](../../../infra/src/jobs.ts),两份并成一份。
|
|
128
144
|
*
|
|
129
|
-
*
|
|
130
|
-
* 永远是最后几行(构建结果、报错栈),前台命令那边正好相反(一次性命令的头部是
|
|
131
|
-
* 「跑的是什么命令、什么配置」,尾部是结论,**两头都要**)。两处不一致是刻意的。
|
|
145
|
+
* 这个文件剩下的是**只有后台命令才有的那些**:
|
|
132
146
|
*
|
|
133
|
-
*
|
|
147
|
+
* | 留在这儿 | 为什么不能下沉 |
|
|
148
|
+
* | ------------------------- | ---------------------------------------------------- |
|
|
149
|
+
* | `MAX_TASKS` 名额判定 | 每类作业各有取值(后台命令 8,持久会话 4) |
|
|
150
|
+
* | `confineShell` + 起进程 | 沙箱和 shell 拼装是 plugin 的知识,infra 不该认识它 |
|
|
151
|
+
* | 非零退出时给沙箱定性 | 要读退出码和输出,是这一类特有的事后判定 |
|
|
152
|
+
* | → `BackgroundTaskInfo` | 那个形状在 protocol,而 **infra 依赖不到 protocol** |
|
|
134
153
|
*
|
|
135
|
-
*
|
|
136
|
-
*
|
|
137
|
-
*
|
|
154
|
+
* 最后一行不只是分层洁癖:`BackgroundTaskInfo` 是**发给宿主的线上形状**
|
|
155
|
+
* (server 的 `/api/sessions/:id/tasks`、Web 检视面板、TUI 的 `/tasks`),
|
|
156
|
+
* 而作业表装着两类东西。
|
|
138
157
|
*
|
|
139
|
-
*
|
|
140
|
-
* `task_output` 给的仍然是尾部,因为那条决定本来就是对的。
|
|
158
|
+
* ## ✅ 2026-08-26(方案 62):宿主那两个口子**不再只报后台命令**
|
|
141
159
|
*
|
|
142
|
-
*
|
|
160
|
+
* 这一节原来写着:把持久会话塞进那个形状意味着 `command` 那一格要填一个工作
|
|
161
|
+
* 目录,那是一句假话,所以 {@link listTasks} / {@link getTask} 只报 `command`
|
|
162
|
+
* 那一类,「宿主那一侧要不要显示持久会话,是一次界面决定,不在这一轮」。
|
|
143
163
|
*
|
|
144
|
-
*
|
|
145
|
-
*
|
|
164
|
+
* **那句判据一个字都没被推翻,被推翻的是「所以只报一类」这个结论。** 假话来自
|
|
165
|
+
* `command` 那一个字段填不出实话,而修法不是藏起半张表,是**把形状改对**:
|
|
166
|
+
* `BackgroundTaskInfo` 现在有 `kind` + `label`,`command` 只在 `'command'`
|
|
167
|
+
* 那一档才有(判据全文在 `protocol/src/task.ts` 那三格上)。
|
|
146
168
|
*
|
|
147
|
-
*
|
|
148
|
-
*
|
|
149
|
-
* *对象*,但一直拿得到会话 *id* —— `ToolContext.sessionId` 是工具契约上的必填字段,
|
|
150
|
-
* 而这张表从方案 47 起就已经在收它了(用来定 artifact 落在哪个目录)。
|
|
151
|
-
* 也就是说这一轮**没有为它新开一条通路**,只是把一条早就通着的路走完。
|
|
169
|
+
* 于是这两个口子改成**报整张表**,而那次「界面决定」也做了:三处界面按 `kind`
|
|
170
|
+
* 各画一枚记号,不加新列。
|
|
152
171
|
*
|
|
153
|
-
*
|
|
154
|
-
*
|
|
155
|
-
*
|
|
172
|
+
* ⚠️ **给模型的那一侧刻意没跟着变**:`task_*` 四个工具照旧走 kind 无关的路
|
|
173
|
+
* ([tasks.ts](./tasks.ts)),而每轮那条 system note
|
|
174
|
+
* (`core/src/agent/task-notice.ts`)**显式过滤成只报 `command`** —— 模型已经有
|
|
175
|
+
* `shell_list` 可以自己问,把持久会话塞进那条 note 是多一份重复、还每轮花 token。
|
|
176
|
+
* 那儿写着同一条理由,别当成漏了。
|
|
156
177
|
*
|
|
157
|
-
*
|
|
158
|
-
* 满了时给的建议是「先用 `task_stop` 停掉一个」,而按会话分之后,别人的任务
|
|
159
|
-
* 这个会话**够不着** —— 一条做不到的建议比没有建议更坏。代价写下来不掩饰:
|
|
160
|
-
* 进程里的总数现在是 `8 × 活跃会话数`,「机器」那一半的保护变松了。真要重新
|
|
161
|
-
* 收住,那是**另一条**进程级上限(和这一条并存),不是把这一条改回去。
|
|
178
|
+
* ## 三条上限,各挡各的
|
|
162
179
|
*
|
|
163
|
-
*
|
|
180
|
+
* | 上限 | 挡什么 |
|
|
181
|
+
* | ------------------------ | ----------------------------------------- |
|
|
182
|
+
* | **每会话**同时 8 个任务 | 模型一口气起十个 watch,机器和上下文一起崩 |
|
|
183
|
+
* | 单任务输出 256KB | 一个刷屏的 `pnpm dev` 跑一夜把内存吃光 |
|
|
184
|
+
* | `task_output` 单次 8KB | 取一次输出就把上下文塞满 |
|
|
164
185
|
*
|
|
165
|
-
*
|
|
166
|
-
*
|
|
167
|
-
* 一个进程都不负责杀({@link clearAllTasks} 只负责忘掉记录)。
|
|
168
|
-
* 所以「按会话分了会不会漏杀某个会话的进程」这个问题的答案是**不会**:
|
|
169
|
-
* 回收压根不遍历这张表。这条由 `__tests__/session-scope.test.ts` 最后一组钉着。
|
|
186
|
+
* 第二条用**环形缓冲**(丢头保尾)而不是「满了就截断」,溢出的那部分落盘 ——
|
|
187
|
+
* 两条的判据都跟着实现搬去了 `infra/jobs.ts` 的 `append()`,不在这儿重抄。
|
|
170
188
|
*
|
|
171
|
-
* ##
|
|
189
|
+
* ## 会话作用域的那道墙**没有因为下沉而变松**
|
|
190
|
+
*
|
|
191
|
+
* 「别的会话的 id 一律当不存在」这条判据(收窄之前一句 `task_stop t1` 能停掉
|
|
192
|
+
* 另一个会话的构建)现在由 infra 那张表的 `jobIn()` 执行,措辞和理由原样搬了
|
|
193
|
+
* 过去。`__tests__/session-scope.test.ts` 那一组盯着它。
|
|
194
|
+
*
|
|
195
|
+
* ⚠️ **同时 8 个** 是每会话一份。判据是那句话本身:满了时给的建议是「先用
|
|
196
|
+
* `task_stop` 停掉一个」,而按会话分之后别人的任务这个会话**够不着** ——
|
|
197
|
+
* 一条做不到的建议比没有建议更坏。代价写下来不掩饰:进程里的总数现在是
|
|
198
|
+
* `8 × 活跃会话数`。真要重新收住,那是**另一条**进程级上限(和这一条并存)。
|
|
199
|
+
*
|
|
200
|
+
* ## ⚠️ 进程退出时的清理:**不走作业表**
|
|
201
|
+
*
|
|
202
|
+
* 真正的回收是 infra 那张按 pid 记的长期子进程表,宿主 `dispose()`(走
|
|
203
|
+
* `OwnedProcessTable.release()`)收的就是它。两张表的分工写在 `infra/jobs.ts`
|
|
204
|
+
* 的文件头那张对照表里。
|
|
172
205
|
*
|
|
173
|
-
*
|
|
174
|
-
*
|
|
175
|
-
*
|
|
176
|
-
* 而**进程还在跑**是一个仍然成立的事实,且这张表是唯一还能停掉它的把手。
|
|
177
|
-
* 跟着冷却清掉的话,那几个 `pnpm dev` 会一直烧到进程退出,而没有任何人能报出
|
|
178
|
-
* 它们的存在;跟着冷却**杀掉**更糟 —— 用户开第 9 个标签页,第 1 个会话跑了
|
|
179
|
-
* 二十分钟的构建就没了。
|
|
206
|
+
* ⚠️ **2026-08-20(方案 60):那张表不再是「全进程一张」** —— 它按
|
|
207
|
+
* `sessionId` 分给各个 runtime(`processTableFor(sessionId)`)。这里起进程时
|
|
208
|
+
* 必须走那条路由。
|
|
180
209
|
*
|
|
181
|
-
*
|
|
182
|
-
*
|
|
183
|
-
*
|
|
210
|
+
* 走 `startLongLivedProcess()` 自由函数会登记进**进程级兜底表**,而那条的后果是
|
|
211
|
+
* **收得晚,不是收错人**:兜底表只在最后一个 runtime `release()` 把引用计数减到 0
|
|
212
|
+
* 时才被收,所以这个会话自己的 runtime dispose 掉之后进程还在跑,一直挂到整个
|
|
213
|
+
* epoch 进程里最后一个 runtime 走。这个方向是 infra 那边刻意选的(漏认领 =
|
|
214
|
+
* 泄漏一会儿,而不是静默端掉别人的构建),判据在 `processTableFor()` 上,
|
|
215
|
+
* 钉它的用例是 `runtime/__tests__/process-table-ownership.test.ts` 第五节。
|
|
216
|
+
*
|
|
217
|
+
* ⚠️ 别把它写成「另一个 runtime 的 dispose 会把这个进程收走」—— 那句话在这儿
|
|
218
|
+
* 活过一轮(2026-08-21 改掉),它描述的是方案 60 **修掉的**那个旧行为。
|
|
219
|
+
*
|
|
220
|
+
* ## ⚠️ 会话被冷却(`live: false`)时:这批任务**一个字都不动**
|
|
221
|
+
*
|
|
222
|
+
* 冷却(`SessionHub` 的 LRU + `SessionFactory.release()`)是「把内存还回去」。
|
|
223
|
+
* 作业表不在被放掉的那一批里,判据与 `release()` 里「工作区绑定不在这里解」
|
|
224
|
+
* 逐字同款 —— 会话可以被释放,而**进程还在跑**是一个仍然成立的事实,
|
|
225
|
+
* 且这张表是唯一还能停掉它的把手。跟着冷却清掉的话,那几个 `pnpm dev` 会一直
|
|
226
|
+
* 烧到进程退出,而没有任何人能报出它们的存在;跟着冷却**杀掉**更糟 ——
|
|
227
|
+
* 用户开第 9 个标签页,第 1 个会话跑了二十分钟的构建就没了。
|
|
184
228
|
*
|
|
185
229
|
* ⚠️ **真删会话(`DELETE /api/sessions/:id`)那条路今天没有收这一格** ——
|
|
186
|
-
*
|
|
230
|
+
* 判据和处置写在 {@link clearAllTasks} 上面。
|
|
187
231
|
*/
|
|
188
232
|
|
|
189
233
|
/** **每个会话**同时最多几个在跑(2026-08-15 从进程级收窄,判据见文件头) */
|
|
@@ -192,13 +236,12 @@ declare const MAX_TASKS = 8;
|
|
|
192
236
|
* 起一个后台任务。名额满了返回一句话而不是抛。
|
|
193
237
|
*
|
|
194
238
|
* @param sessionId 这个任务归谁(`ToolContext.sessionId`)。**必填**:一条不知道
|
|
195
|
-
*
|
|
239
|
+
* 属于谁的后台任务在作业表里没有位置 —— 它要么被所有会话看见(收窄之前那个
|
|
196
240
|
* 毛病),要么谁都看不见。它同时决定溢出的输出落进哪个 artifact 目录
|
|
197
241
|
* @param homeDir 数据目录(`ToolContext.homeDir`)。**不能省**(方案 54 §二):
|
|
198
242
|
* 省掉就落进全局 `~/.epoch/artifacts/`,而宿主的界面从 `<homeDir>/artifacts/` 读
|
|
199
243
|
* @param sandbox 这次的沙箱边界(方案 46 PR-4)。**不传 = 不上沙箱**,如实标成
|
|
200
|
-
* `skipped: 'not-requested'`。口径和 `runCommand` / `runCommandPty`
|
|
201
|
-
* 口子是给只关心任务表的用例留的,工具那条真实路径每次都传
|
|
244
|
+
* `skipped: 'not-requested'`。口径和 `runCommand` / `runCommandPty` 逐字一致
|
|
202
245
|
*/
|
|
203
246
|
declare function startTask(sessionId: string, command: string, cwd?: string, homeDir?: string, sandbox?: SandboxPolicy): {
|
|
204
247
|
task: BackgroundTaskInfo;
|
|
@@ -213,11 +256,11 @@ declare function startTask(sessionId: string, command: string, cwd?: string, hom
|
|
|
213
256
|
*/
|
|
214
257
|
declare function taskSandbox(sessionId: string, id: string): SandboxOutcome | undefined;
|
|
215
258
|
/**
|
|
216
|
-
*
|
|
259
|
+
* 这个会话起过的**全部作业**(方案 62 起不再收窄到 `command` 一类)。
|
|
217
260
|
*
|
|
218
|
-
*
|
|
219
|
-
*
|
|
220
|
-
*
|
|
261
|
+
* 这个函数是**给宿主的**口子(runtime 转出去给 server / TUI / Web 检视面板,
|
|
262
|
+
* 落点是 `BackgroundTaskInfo` 那个线上形状)。它 2026-08-26 之前只报 `command`,
|
|
263
|
+
* 理由和翻案的判据都在文件头那一节。
|
|
221
264
|
*/
|
|
222
265
|
declare function listTasks(sessionId: string): BackgroundTaskInfo[];
|
|
223
266
|
interface TaskOutputResult {
|
|
@@ -228,26 +271,21 @@ interface TaskOutputResult {
|
|
|
228
271
|
nextCursor: number;
|
|
229
272
|
/** 从 `since` 到现在有多少字节已经滚出环形缓冲 */
|
|
230
273
|
missed: number;
|
|
231
|
-
/** 还有没有更多(这次被
|
|
274
|
+
/** 还有没有更多(这次被 {@link MAX_OUTPUT_CHUNK} 截断了) */
|
|
232
275
|
hasMore: boolean;
|
|
233
276
|
}
|
|
234
277
|
/**
|
|
235
|
-
*
|
|
278
|
+
* 取增量输出。**方案 62 起也认持久会话**(判据见 {@link listTasks})。
|
|
236
279
|
*
|
|
237
|
-
*
|
|
238
|
-
*
|
|
239
|
-
*
|
|
280
|
+
* ⚠️ 它跟着两个列表口子一起放宽,而 {@link stopTask} / {@link waitTask} 没有 ——
|
|
281
|
+
* 判据在 {@link commandJob} 上那一节:**取输出是「看」,不是「动」**。
|
|
282
|
+
* 一格在检视面板上列得出来、点开却永远是空的,那是把可见性做了一半。
|
|
240
283
|
*
|
|
241
|
-
*
|
|
242
|
-
*
|
|
284
|
+
* `since` 是绝对偏移,语义和实现都在 `infra/jobs.ts` 的 `readJob()`
|
|
285
|
+
* —— 那一层本来就是 kind 无关的,所以这里放宽只是把一道多余的闸撤掉。
|
|
243
286
|
*/
|
|
244
287
|
declare function taskOutput(sessionId: string, id: string, since?: number): TaskOutputResult | undefined;
|
|
245
|
-
/**
|
|
246
|
-
* 停掉一个任务(杀整棵树)。已经结束的返回 false。
|
|
247
|
-
*
|
|
248
|
-
* **别的会话的 id 也返回 false**,和「已经结束了」同一个出口:这是四个工具里
|
|
249
|
-
* 唯一真会动手的那个,一句 `task_stop t1` 曾经能停掉另一个会话的构建。
|
|
250
|
-
*/
|
|
288
|
+
/** 停掉一个任务(杀整棵树)。已经结束的、别的会话的都返回 false */
|
|
251
289
|
declare function stopTask(sessionId: string, id: string): Promise<boolean>;
|
|
252
290
|
interface TaskWaitResult {
|
|
253
291
|
info: BackgroundTaskInfo;
|
|
@@ -255,11 +293,8 @@ interface TaskWaitResult {
|
|
|
255
293
|
timedOut: boolean;
|
|
256
294
|
}
|
|
257
295
|
/**
|
|
258
|
-
*
|
|
259
|
-
*
|
|
260
|
-
* 超时**不杀它** —— 「我等不及了」和「我要停掉它」是两件事,
|
|
261
|
-
* 后者有 `task_stop`。把它们合并的话,模型一次 `task_wait` 超时就会把一个
|
|
262
|
-
* 跑了十分钟的构建白白毁掉。
|
|
296
|
+
* 等一个任务结束。超时**不杀它** —— 「我等不及了」和「我要停掉它」是两件事。
|
|
297
|
+
* 实现在 `infra/jobs.ts` 的 `waitJob()`。
|
|
263
298
|
*/
|
|
264
299
|
declare function waitTask(sessionId: string, id: string, timeoutMs: number): Promise<TaskWaitResult | undefined>;
|
|
265
300
|
/**
|
|
@@ -270,78 +305,418 @@ declare function waitTask(sessionId: string, id: string, timeoutMs: number): Pro
|
|
|
270
305
|
* TUI 里 `/resume` 一下,刚才起的那条 `pnpm dev` 从 `/tasks` 里消失,
|
|
271
306
|
* 而它还在跑 —— 一个停不掉也报不出来的进程,比一条错误归属的记录坏得多。
|
|
272
307
|
*
|
|
273
|
-
*
|
|
274
|
-
*
|
|
308
|
+
* ⚠️ **只搬 `command` 那一类。** 持久会话这一格今天不搬(那个 venv 还算不算
|
|
309
|
+
* 「这段对话的」是一次要单独判的产品决定,记在方案 49 的验收记录里),
|
|
310
|
+
* 而下沉之后两类住在同一张表里 —— 点名 kind 就是那次刻意不作为的落点,
|
|
311
|
+
* 不点名的话它会在这一轮被静默改掉。
|
|
275
312
|
*/
|
|
276
313
|
declare function rekeyTasks(from: string, to: string): void;
|
|
277
314
|
/**
|
|
278
|
-
*
|
|
315
|
+
* 忘掉**所有**会话的后台命令记录(**不杀进程**)。只给用例用。
|
|
279
316
|
*
|
|
280
|
-
*
|
|
281
|
-
*
|
|
317
|
+
* 下沉之后它清的是共用表里 `command` 那一类,**不碰持久会话那一类** ——
|
|
318
|
+
* 判据见 infra `clearAllJobs` 的 `kinds` 参数。**用例**清场里的正确顺序是先
|
|
319
|
+
* `killAllTrackedProcesses()` 再这一下(生产路径走的是
|
|
320
|
+
* `OwnedProcessTable.release()`,不是它)—— 判据见文件头。
|
|
282
321
|
*
|
|
283
322
|
* ⚠️ **给合并的人:`DELETE /api/sessions/:id` 那条路今天不收这一格。**
|
|
284
|
-
* 表按会话分了之后,删掉一个会话 =
|
|
285
|
-
*
|
|
286
|
-
*
|
|
287
|
-
* 「放弃挂起的审批 + 中止在跑的回合」同一档。
|
|
323
|
+
* 表按会话分了之后,删掉一个会话 = 它那几个后台任务从此**没有任何界面看得见**。
|
|
324
|
+
* 该有的处置是删会话时连同它们一起停掉 —— 那是一次真的杀进程,语义上和
|
|
325
|
+
* `hub.unregister()` 里「放弃挂起的审批 + 中止在跑的回合」同一档。
|
|
288
326
|
*
|
|
289
327
|
* 这一轮**没做**,两个理由:入口在 `server/src/api.ts` 的删会话处理里,不在
|
|
290
328
|
* 这一轮的文件所有权内(.agents/plans/30 §十二那条规矩);而且「删会话要不要
|
|
291
|
-
*
|
|
292
|
-
*
|
|
293
|
-
*
|
|
294
|
-
* 而不杀进程,正好是上面说的那个洞。
|
|
329
|
+
* 杀掉它起的构建」是一次产品决定。要收的话在 infra 那边加一个
|
|
330
|
+
* `stopSessionJobs(sessionId)`(遍历那一格 `stopJob`),在删会话那条路上调一次
|
|
331
|
+
* —— **不是** `clearAllJobs` 的会话版:只忘掉记录而不杀进程,正好是上面说的那个洞。
|
|
295
332
|
*
|
|
296
333
|
* ⚠️ **2026-08-15 合并时看了,明确不做**(账在
|
|
297
|
-
* [30 §13.4](../../../../docs/verify/VERIFY_RECORD-30-web-multi-session.md)
|
|
298
|
-
* 上面那句判据成立 —— 「删会话要不要杀掉它起的构建」是一次产品决定,
|
|
299
|
-
* 合并收账不是替人做决定的场合。**这个洞今天真的在**,收的人该判的是
|
|
300
|
-
* 「值不值得单独一轮」,不是「上一轮为什么没做」。
|
|
334
|
+
* [30 §13.4](../../../../docs/verify/VERIFY_RECORD-30-web-multi-session.md))。
|
|
301
335
|
*/
|
|
302
336
|
declare function clearAllTasks(): void;
|
|
303
|
-
/**
|
|
337
|
+
/** 一行摘要,宿主展示用 */
|
|
304
338
|
declare function describeTask(info: BackgroundTaskInfo): string;
|
|
305
339
|
|
|
306
340
|
/**
|
|
307
|
-
*
|
|
308
|
-
* `task_stop`。
|
|
341
|
+
* 持久 shell 会话的上限和契约类型(方案 49)。
|
|
309
342
|
*
|
|
310
|
-
*
|
|
343
|
+
* 单独一个文件的理由**和同包的 [../limits.ts](../limits.js) 逐字同款**:
|
|
344
|
+
* [sessions.ts](./sessions.js) 和 [notice.ts](./notice.js) 都要用它们,而那两个
|
|
345
|
+
* 文件之间不该为了几个常量互相 import —— 那会绕出一个
|
|
346
|
+
* `sessions → notice → sessions` 的环(会话表要用「写入失败」那句措辞,
|
|
347
|
+
* 措辞要用上限和状态类型)。常量和类型下沉成叶子,环就不存在了。
|
|
348
|
+
* `oxlint` 的 `import/no-cycle` 是 **error**,所以这不是风格问题。
|
|
311
349
|
*
|
|
312
|
-
*
|
|
313
|
-
* 问一次。有了它,「先放后台跑,需要结果时再等」这条路才完整。
|
|
350
|
+
* ## 四条上限的取值判据(方案 §1.3:不发明新数字,取值可以不同)
|
|
314
351
|
*
|
|
315
|
-
*
|
|
352
|
+
* 逐条对照后台任务那三条写在 [sessions.ts](./sessions.js) 的文件头,那里同时
|
|
353
|
+
* 交代了「为什么持久会话是 4 个而后台任务是 8 个」。
|
|
354
|
+
*/
|
|
355
|
+
/** **每个会话**同时最多开几个持久会话 */
|
|
356
|
+
declare const MAX_SHELLS = 4;
|
|
357
|
+
/** 单个会话留多少字节输出(环形,丢头保尾,同 `MAX_TASK_OUTPUT`) */
|
|
358
|
+
declare const MAX_SHELL_OUTPUT: number;
|
|
359
|
+
/** `shell_read` / `shell_send` 单次最多吐多少(同 `MAX_OUTPUT_CHUNK`) */
|
|
360
|
+
declare const MAX_SHELL_CHUNK: number;
|
|
361
|
+
/** 空闲多久就回收(PR-2)。照 `plugin-lsp` 进程池那条口径 */
|
|
362
|
+
declare const SHELL_IDLE_MS: number;
|
|
363
|
+
/**
|
|
364
|
+
* 多久扫一遍空闲(PR-2)。
|
|
316
365
|
*
|
|
317
|
-
*
|
|
318
|
-
*
|
|
319
|
-
|
|
366
|
+
* 10 分钟的阈值上,1 分钟的粒度意味着最坏 11 分钟才被收 —— 那完全够用,而更密
|
|
367
|
+
* 的轮询是纯浪费:这个定时器唯一的作用是「没人再调 `shell_*` 时也能把进程收掉」。
|
|
368
|
+
*/
|
|
369
|
+
declare const SHELL_SWEEP_MS: number;
|
|
370
|
+
/**
|
|
371
|
+
* 一个会话的状态。**四档不能合并成「活着 / 不活着」**:
|
|
372
|
+
* 后三档对模型的下一步动作完全不同(自己 `exit` 的可以重开,被回收的要重开
|
|
373
|
+
* 并且知道「上下文没了」,被 `shell_close` 关掉的是它自己刚干的)。
|
|
374
|
+
*/
|
|
375
|
+
type ShellStatus =
|
|
376
|
+
/** 活着 */
|
|
377
|
+
'running'
|
|
378
|
+
/** shell 自己退了(模型发了 `exit`,或者它崩了) */
|
|
379
|
+
| 'exited'
|
|
380
|
+
/** `shell_close` 关掉的 */
|
|
381
|
+
| 'closed'
|
|
382
|
+
/** 空闲超过 {@link SHELL_IDLE_MS} 被回收(PR-2) */
|
|
383
|
+
| 'reaped';
|
|
384
|
+
/** 交给工具层 / 将来的展示层的那一份。**不含 `IPty` 句柄** */
|
|
385
|
+
interface ShellInfo {
|
|
386
|
+
id: string;
|
|
387
|
+
pid: number;
|
|
388
|
+
cwd: string;
|
|
389
|
+
/** 真正起的那个可执行文件(POSIX 恒 `/bin/sh`,Windows 跟 `config.shell` 走) */
|
|
390
|
+
shell: string;
|
|
391
|
+
status: ShellStatus;
|
|
392
|
+
exitCode?: number;
|
|
393
|
+
startedAt: number;
|
|
394
|
+
/** 上次发过输入或读过输出的时刻 —— 空闲回收量的就是它 */
|
|
395
|
+
lastActivity: number;
|
|
396
|
+
/** 累计产出过多少字节(含已经滚出缓冲的) */
|
|
397
|
+
outputBytes: number;
|
|
398
|
+
/** 有没有滚出去过 */
|
|
399
|
+
truncated: boolean;
|
|
400
|
+
/** 全文落盘的位置(只有溢出过才有) */
|
|
401
|
+
artifact?: string;
|
|
402
|
+
}
|
|
403
|
+
/** 一次增量读的结果。字段与 `TaskOutputResult` 逐字对齐,判据见 `readShell` */
|
|
404
|
+
interface ShellReadResult {
|
|
405
|
+
info: ShellInfo;
|
|
406
|
+
/** 这一段输出 */
|
|
407
|
+
output: string;
|
|
408
|
+
/** 下次传回来的游标(绝对偏移) */
|
|
409
|
+
nextCursor: number;
|
|
410
|
+
/** 从 `since` 到现在有多少字节已经滚出环形缓冲 */
|
|
411
|
+
missed: number;
|
|
412
|
+
/** 还有没有更多(这次被 {@link MAX_SHELL_CHUNK} 截断了) */
|
|
413
|
+
hasMore: boolean;
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
/**
|
|
417
|
+
* 持久 shell 会话这一**类作业**的产出方(方案 49 PR-1 / PR-2,PR-3 下沉)。
|
|
418
|
+
*
|
|
419
|
+
* 一个 `shell_open` 起的 PTY 在共用作业表里有一格:进程、状态、一段环形输出
|
|
420
|
+
* 缓冲、上次活动时间。
|
|
421
|
+
*
|
|
422
|
+
* ## ✅ 2026-08-20(PR-3):这张表和后台任务表**并成了一张**
|
|
423
|
+
*
|
|
424
|
+
* 改造前这里有一整张 `Map<sessionId, Map<shellId, Shell>>`,而 `background.ts`
|
|
425
|
+
* 有**逐字同款的第二份**(环形缓冲、溢出落盘、`since` 游标、按会话分区、停掉)。
|
|
426
|
+
* PR-1 的落地记录里明写着「合出来的那份该住 infra,而 **PR-3 正要把整张作业表
|
|
427
|
+
* 下沉到那里**」—— 这一轮兑现了:表在
|
|
428
|
+
* [infra/jobs.ts](../../../../infra/src/jobs.ts),两份并成一份。
|
|
429
|
+
*
|
|
430
|
+
* ⚠️ **「两张表」并掉了,方案 §1.4 那条分界线一个字没变。** 那条说的是
|
|
431
|
+
* **语义**不合并:后台任务是「起来不管了」,持久会话是「起来还要接着聊」——
|
|
432
|
+
* 前者只读输出,后者要往里发东西。所以 `shell_send` / `shell_read` 这一族照旧
|
|
433
|
+
* 独立存在,合并的只是「列出来 / 读输出 / 停掉」那一套控制(方案 §三)。
|
|
434
|
+
* 落在代码上:`kind: 'shell'` 这一格的 `terminates: false`,于是
|
|
435
|
+
* `task_wait s1` 会**立刻**说一句实话,而不是把整轮卡满超时。
|
|
436
|
+
*
|
|
437
|
+
* 这个文件剩下的是**只有持久会话才有的那些**:起 PTY、ConPTY 前导、空闲回收、
|
|
438
|
+
* pty 树的收法、以及 → `ShellInfo` 的投影(那个形状里的 `shell` 字段是「真正
|
|
439
|
+
* 起的那个可执行文件」,作业表不认识它,所以它住在 {@link ShellDetail})。
|
|
440
|
+
*
|
|
441
|
+
* ## 四条上限,照 `background.ts` 的口径(方案 §1.3:不发明新数字,取值可以不同)
|
|
442
|
+
*
|
|
443
|
+
* | 上限 | 后台任务 | 这里 | 为什么不同 |
|
|
444
|
+
* | ---------------------- | ---------- | ----------- | ---------------------------------------------- |
|
|
445
|
+
* | 同时几个(**每会话**) | 8 | **4** | 一个 PTY 比一个管道进程重,而实际用不到 8 个 |
|
|
446
|
+
* | 单个输出缓冲 | 256 KB | 256 KB | 同样**丢头保尾** —— REPL 里最有价值的是最后几行 |
|
|
447
|
+
* | 一次读多少 | 8 KB | 8 KB | 同上,再多就把上下文塞满 |
|
|
448
|
+
* | 空闲多久回收 | 无 | **10 分钟** | 照 `plugin-lsp` 的进程池那条口径 |
|
|
449
|
+
*
|
|
450
|
+
* ## ⚠️ 进程退出时的回收:**靠 infra 那张按 pid 记的表,不靠作业表**
|
|
451
|
+
*
|
|
452
|
+
* 每个 PTY 在起来的那一刻就 `trackForeign()` 登记进去(方案 §二),
|
|
453
|
+
* 宿主 `dispose()` 走 `OwnedProcessTable.release()` 收的就是那一张。
|
|
454
|
+
*
|
|
455
|
+
* ⚠️ **2026-08-20(方案 60):那张表按会话分给各个 runtime 了** ——
|
|
456
|
+
* 登记走 `processTableFor(sessionId)`,不走 `trackForeignProcess()` 那条自由函数:
|
|
457
|
+
* 它进的是进程级兜底表,而兜底表只在**最后一个** runtime 把引用计数减到 0 时才收,
|
|
458
|
+
* 于是这个 shell 会一直挂到整个 epoch 进程里最后一个 runtime 走 —— **收得晚,
|
|
459
|
+
* 不是被别人收走**(漏认领的方向是刻意这么选的,判据在 infra 的
|
|
460
|
+
* `processTableFor()` 上)。
|
|
461
|
+
*
|
|
462
|
+
* **这一格比后台任务那一格更要命**:后台进程和我们在同一个前台进程组,Ctrl+C 时
|
|
463
|
+
* 内核直接发给整组,就算我们的清理代码一行都没跑也不残留。PTY **没有这个保底** ——
|
|
464
|
+
* `forkpty()` 里 `setsid()` 过,它是另一个会话的组长。判据全文在
|
|
465
|
+
* [infra/child-process.ts](../../../../infra/src/child-process.ts) 的文件头那张表。
|
|
466
|
+
* 方案 §四 验收 12 量的就是这一条:把那一行登记摘掉 →
|
|
467
|
+
* 验收 8(Ctrl+C 之后没有孤儿 shell)当场变红。
|
|
468
|
+
*
|
|
469
|
+
* ⚠️ **摘登记必须在 `killPtyTree` 之后**,不能在之前 —— 判据写在
|
|
470
|
+
* {@link afterEnd} 上,那也是 infra 的 `endJob` 刻意不提供 `onEnd` 钩子的原因。
|
|
471
|
+
*/
|
|
472
|
+
|
|
473
|
+
/** 名额满了。**列出在跑的那几个**,措辞由工具层拼(这一层不做文案) */
|
|
474
|
+
interface ShellLimitReached {
|
|
475
|
+
error: 'limit';
|
|
476
|
+
limit: number;
|
|
477
|
+
running: ShellInfo[];
|
|
478
|
+
}
|
|
479
|
+
/** PTY 起不起来。`hint` 是 node-pty 那句语焉不详的报错后面该接的人话 */
|
|
480
|
+
interface ShellSpawnFailed {
|
|
481
|
+
error: 'spawn-failed';
|
|
482
|
+
message: string;
|
|
483
|
+
hint: string;
|
|
484
|
+
}
|
|
485
|
+
interface ShellOpened {
|
|
486
|
+
shell: ShellInfo;
|
|
487
|
+
sandbox: SandboxOutcome;
|
|
488
|
+
}
|
|
489
|
+
/**
|
|
490
|
+
* 开一个持久会话。
|
|
491
|
+
*
|
|
492
|
+
* @param sessionId 这个会话归谁(`ToolContext.sessionId`)。**必填**,判据同
|
|
493
|
+
* `startTask`:一条不知道属于谁的持久 shell 在作业表里没有位置
|
|
494
|
+
* @param cwd 工作目录。**边界校验不在这一层**(工具层用 infra 的 `isInWorkspace`
|
|
495
|
+
* 判过了,和 `terminal` 同一条),这里收到的已经是解析好的绝对路径
|
|
496
|
+
* @param homeDir 数据目录(`ToolContext.homeDir`)。决定溢出的输出落进哪个
|
|
497
|
+
* artifact 目录 —— **不能省**,判据同 `startTask`(方案 54 §二)
|
|
498
|
+
* @param sandbox 这次的沙箱边界。**不传 = 不上沙箱**,如实标成 `not-requested`;
|
|
499
|
+
* 工具那条真实路径每次都传,且传的是**和 `terminal` 同一个** `sandboxPolicyFor()`
|
|
500
|
+
*/
|
|
501
|
+
declare function openShell(sessionId: string, cwd: string, homeDir?: string, sandbox?: SandboxPolicy): ShellOpened | ShellLimitReached | ShellSpawnFailed;
|
|
502
|
+
interface ShellGone {
|
|
503
|
+
/** `undefined` = 压根没这个 id(或者是别的会话的) */
|
|
504
|
+
info?: ShellInfo;
|
|
505
|
+
}
|
|
506
|
+
/**
|
|
507
|
+
* 往一个会话里发一段输入,等 `waitMs` 之后把这段时间的输出交回去。
|
|
508
|
+
*
|
|
509
|
+
* `wait` 是「等多久再返回输出」而不是「等命令结束」—— 持久会话没有「结束」这个
|
|
510
|
+
* 状态(方案 §五 那条「不做 `shell_wait`」)。等不够就再 `shell_read` 一次,
|
|
511
|
+
* 游标接着上次的 `nextCursor` 走。
|
|
512
|
+
*
|
|
513
|
+
* ⚠️ **末尾的换行由调用方负责**:`shell_send({ input: 'pwd' })` 和 `'pwd\n'`
|
|
514
|
+
* 是两回事,前者只是把字符打进去、命令没被执行。工具描述里写明了这一条,
|
|
515
|
+
* 而这一层刻意**不替调用方补** —— 补的话 `\x03`(Ctrl+C)会变成
|
|
516
|
+
* 「Ctrl+C 加一个回车」,而那是方案 §1.5 里「用 shell_send 覆盖 signal」的
|
|
517
|
+
* 那条路唯一的走法。
|
|
518
|
+
*
|
|
519
|
+
* ⚠️ 但**换行本身要翻译成这个平台上的回车键**({@link asKeystrokes}):
|
|
520
|
+
* ConPTY 的控制台行输入只认 `\r`,Windows 上原样写 `\n` 的后果是
|
|
521
|
+
* 「命令回显出来了、一次都没执行」。原来这里写的是「输入原样写进去」,
|
|
522
|
+
* 那句话在 Windows 上就是这个 bug 本身 —— 只改写已有的换行,不追加,
|
|
523
|
+
* 所以上面那条 Ctrl+C 的约束一个字都没变。
|
|
524
|
+
*/
|
|
525
|
+
declare function sendToShell(sessionId: string, id: string, input: string, waitMs: number): Promise<ShellReadResult | ShellGone>;
|
|
526
|
+
/** 增量读。`since` 的语义和 `task_output` 逐字一致(绝对偏移) */
|
|
527
|
+
declare function readShell(sessionId: string, id: string, since?: number): ShellReadResult | ShellGone;
|
|
528
|
+
/**
|
|
529
|
+
* 关掉一个会话(杀整棵进程树)。已经不在跑的返回 `false`。
|
|
530
|
+
*
|
|
531
|
+
* 别的会话的 id 也返回 `false`,和「已经关了」同一个出口 —— 判据同
|
|
532
|
+
* `stopTask`:这是这一族里真会动手的那个。
|
|
533
|
+
*
|
|
534
|
+
* ⚠️ **kind 无关的 `task_stop` 走的是同一条路**(infra 的 `stopJob`),所以
|
|
535
|
+
* 「关掉」这件事只有一个实现 —— 那正是方案 §三 要的东西。
|
|
536
|
+
*/
|
|
537
|
+
declare function closeShell(sessionId: string, id: string): Promise<boolean>;
|
|
538
|
+
/** 这个会话开过的持久 shell,**只有这个会话的** */
|
|
539
|
+
declare function listShells(sessionId: string): ShellInfo[];
|
|
540
|
+
/**
|
|
541
|
+
* 忘掉**所有**会话的持久会话记录(**不杀进程**)。只给用例用。
|
|
542
|
+
*
|
|
543
|
+
* **用例**清场里的正确顺序是**先** infra 的 `killAllTrackedProcesses()`(用例要的
|
|
544
|
+
* 就是它那个跨表口径)**再**这一下:反过来的话那几个 pty 还活着,而唯一记着它们
|
|
545
|
+
* 的表已经空了。判据同 `clearAllTasks`。
|
|
546
|
+
*
|
|
547
|
+
* ⚠️ 生产路径不是这条:宿主 `dispose()` 走 `OwnedProcessTable.release()`,
|
|
548
|
+
* `killAllTrackedProcesses()` 在生产路径上零调用方。
|
|
549
|
+
*
|
|
550
|
+
* 只清 `shell` 那一类:下沉之后两族住在同一张表里,全清会顺手把别人的记录也带走。
|
|
551
|
+
*/
|
|
552
|
+
declare function clearAllShells(): void;
|
|
553
|
+
/**
|
|
554
|
+
* 扫一遍空闲会话,把超时的收掉。
|
|
555
|
+
*
|
|
556
|
+
* **两个触发点共用这一个函数**:{@link SHELL_SWEEP_MS} 那个定时器,以及每一次
|
|
557
|
+
* `shell_*` 调用(`sendToShell` / `readShell` / `listShells` 各自开头一句)。
|
|
558
|
+
* 两个都要:只有定时器的话,用例得等真的墙上时钟;只有懒扫的话,模型不再碰
|
|
559
|
+
* shell 工具时那几个 PTY 就永远活到进程退出 —— 而「10 分钟没动就收」这句话
|
|
560
|
+
* 本来是对**资源**的承诺,不是对调用者的承诺。
|
|
561
|
+
*
|
|
562
|
+
* ⚠️ 扫的是 {@link allJobs}(跨会话),不是某个会话那一格:一个不再被打开的
|
|
563
|
+
* 会话里那几个 PTY 恰恰是最该被收的。这也是 `allJobs` 存在的两个理由之一。
|
|
320
564
|
*
|
|
321
|
-
*
|
|
322
|
-
*
|
|
565
|
+
* @param now 现在几点。**给用例留的口子** —— 判据同 AGENTS.md 那条「别把断言挂在
|
|
566
|
+
* 墙上时钟上」:真等 10 分钟的用例不可能存在,而假时钟又验不到真进程被杀。
|
|
567
|
+
* 传一个未来的时刻,这条路的每一步都是真的。
|
|
568
|
+
*/
|
|
569
|
+
declare function sweepIdleShells(now?: number): ShellInfo[];
|
|
570
|
+
|
|
571
|
+
/**
|
|
572
|
+
* 持久 shell 会话那一族**给模型读的输出措辞**(方案 49)。
|
|
323
573
|
*
|
|
324
|
-
* ##
|
|
574
|
+
* ## 为什么单独一个文件
|
|
325
575
|
*
|
|
326
|
-
*
|
|
327
|
-
*
|
|
328
|
-
*
|
|
329
|
-
*
|
|
576
|
+
* 和同包的 [spill.ts](../spill.js) / [sandbox-notice.ts](../sandbox-notice.js)
|
|
577
|
+
* 是同一类,判据也逐字同款:**整份都是给模型读的工具输出**,一个字都不面向
|
|
578
|
+
* 用户界面。`locales/zh.yaml` 的范围说明里,「工具输出」和「工具 description」
|
|
579
|
+
* 并列写着**不进 catalog** —— 换用户界面语言不该换模型看到的东西。
|
|
580
|
+
* 所以它和那两份一起进 `scripts/i18n-scan.mjs` 的 `SKIP_FILES`。
|
|
330
581
|
*
|
|
331
|
-
*
|
|
332
|
-
*
|
|
333
|
-
*
|
|
582
|
+
* 分出这个文件而不是写在 [tools.ts](./tools.js) 里,正是为了让豁免仍然是
|
|
583
|
+
* **文件级**的:`tools.ts` 里那些真正用户可见的错误文案(工作目录越界、
|
|
584
|
+
* 名额满了、会话已回收)一条都没有跟着被豁免掉 —— 它们走 `t()`,
|
|
585
|
+
* key 在 `locales/{zh,en}.yaml` 的 `shell:` 一节。
|
|
586
|
+
*
|
|
587
|
+
* ## 措辞的三条纪律
|
|
588
|
+
*
|
|
589
|
+
* 一、**每一句都要能让模型选出下一步**。「会话已回收」后面必须跟「重开一个」,
|
|
590
|
+
* 否则模型只会把同一条 `shell_send` 再发一遍。
|
|
591
|
+
*
|
|
592
|
+
* 二、**不重新发明说法**。滚出缓冲那句、还有更多那句,都从 `spill.ts` 拿 ——
|
|
593
|
+
* 后台任务那边说的是同一件事,模型不该在两个工具里读到两种说法。
|
|
594
|
+
*
|
|
595
|
+
* 三、**「活不过 epoch 进程」要在开会话的那一刻就说**。方案 §二 有一条 ⚠️
|
|
596
|
+
* 逐字写着:不写的话模型会以为它能跨会话复用一个 venv。
|
|
597
|
+
*/
|
|
598
|
+
|
|
599
|
+
/** 一行摘要。五个工具和将来的展示层共用一份措辞(同 `describeTask`) */
|
|
600
|
+
declare function describeShell(info: ShellInfo): string;
|
|
601
|
+
|
|
602
|
+
/**
|
|
603
|
+
* 持久 shell 会话那五个工具(方案 49 PR-1 / PR-2):`shell_open` / `shell_send` /
|
|
604
|
+
* `shell_read` / `shell_close` / `shell_list`。
|
|
605
|
+
*
|
|
606
|
+
* ## 为什么是新工具族,而不是给 `terminal` 加第四种模式(2026-08-14 拍板)
|
|
607
|
+
*
|
|
608
|
+
* 判据是**一个工具四种语义**(方案 §1.1):`terminal` 已经有三种(管道 / PTY /
|
|
609
|
+
* 后台),而它们的超时、输出上限、返回值、审批口径各不相同。加持久就是第四种,
|
|
610
|
+
* 工具描述会长成一张真值表 —— 而模型最容易在这种工具上选错档,表现是
|
|
611
|
+
* 「我让它跑测试,它开了个后台任务然后说不知道结果」。
|
|
612
|
+
*
|
|
613
|
+
* 名字用 `shell_*` 而不是 `terminal_*`:**`terminal` 这个名字已经是一个工具了**,
|
|
614
|
+
* `terminal_open` 和 `terminal` 并列会让模型以为前者是后者的一个模式。
|
|
615
|
+
*
|
|
616
|
+
* ## 权限:这一族里有两个真会执行命令
|
|
617
|
+
*
|
|
618
|
+
* | 工具 | operation | 为什么 |
|
|
619
|
+
* | --------------------------------------- | ----------- | ------------------------------------------ |
|
|
620
|
+
* | `shell_open` | `command` | 它起一个进程 |
|
|
621
|
+
* | `shell_send` | `command` | **发进去的那段输入就是命令** |
|
|
622
|
+
* | `shell_read` / `shell_list` / `shell_close` | `file_read` | 不执行任何命令(同四个 `task_*` 的判据) |
|
|
623
|
+
*
|
|
624
|
+
* ⚠️ **`shell_send` 必须过危险命令表**,和 `terminal` 同一条。少了这一道,
|
|
625
|
+
* `shell_open` 之后一句 `shell_send({ input: 'rm -rf ~\n' })` 就是一条绕过护栏的
|
|
626
|
+
* 旁路 —— 而「后台不是旁路」那条既有判据(`tasks.ts` 文件头)说的是同一件事。
|
|
627
|
+
* 有用例守着。
|
|
628
|
+
*
|
|
629
|
+
* `shell_close` 归 `file_read` 而**不是** `command`。这里原来挂着一条 📌
|
|
630
|
+
* (「PR-3 重判 `task_stop` 时这一条跟着一起重判」)——
|
|
631
|
+
* ✅ **2026-08-20 PR-3 判过了,结论是两个都维持 `file_read`**,理由换成了
|
|
632
|
+
* 「它拿不到任何新能力 + 类别就是授权单位,标成 `command` 等于把执行权发出去」。
|
|
633
|
+
* 完整的三条判据和「什么会让它翻过来」写在
|
|
634
|
+
* `core/src/permission/operation-type.ts` 的 `PROCESS_TABLE_TOOLS` 上,
|
|
635
|
+
* `core/__tests__/job-control-permission.test.ts` 钉着它。
|
|
636
|
+
*
|
|
637
|
+
* ⚠️ PR-3 之后 `task_stop s1` 和 `shell_close s1` 是**同一件事**(走同一个
|
|
638
|
+
* `stopJob()`),所以这两条的类别必须一致 —— 不一致的后果是模型换一个工具名
|
|
639
|
+
* 就绕过一次确认框。那条一致性也在上面那个用例里。
|
|
640
|
+
*
|
|
641
|
+
* ## 措辞分两处,判据在各自文件头
|
|
642
|
+
*
|
|
643
|
+
* 给模型读的输出在 [notice.ts](./notice.js)(整份进 i18n 的 `SKIP_FILES`),
|
|
644
|
+
* 用户可见的错误文案走 `t()`、key 在 `locales/{zh,en}.yaml` 的 `shell:` 一节。
|
|
645
|
+
* 这个文件里因此**一个中文字面量都没有**,只有 `description`(工具描述给模型看,
|
|
646
|
+
* 本来就不在 i18n 的分母里,而且它进 prompt 指纹 —— 跟着界面语言变才是错的)。
|
|
647
|
+
*/
|
|
648
|
+
|
|
649
|
+
/**
|
|
650
|
+
* 五个工具**无条件注册**,判据逐字照四个 `task_*`:它们只读这张会话表,
|
|
651
|
+
* 一个会话都没有时 `shell_list` 就说一句「没有」。按「有没有会话」动态注册的话,
|
|
652
|
+
* 模型第一次 `shell_open` 之后工具表会变,而它这一轮看到的还是旧的。
|
|
653
|
+
*/
|
|
654
|
+
declare const SHELL_TOOLS: readonly EpochTool[];
|
|
655
|
+
|
|
656
|
+
/**
|
|
657
|
+
* 后台作业控制器那四个工具(方案 36,2026-08-20 方案 49 PR-3 变成 kind 无关):
|
|
658
|
+
* `task_list` / `task_output` / `task_wait` / `task_stop`。
|
|
659
|
+
*
|
|
660
|
+
* ## ✅ 2026-08-20:这四个**不认 kind 了**
|
|
661
|
+
*
|
|
662
|
+
* 改造前它们只看得见 `terminal(background: true)` 起的后台命令,而方案 49 PR-1
|
|
663
|
+
* 又给持久 shell 会话配了 `shell_list` / `shell_read` / `shell_close` ——
|
|
664
|
+
* **同一套「列出来 / 读输出 / 停掉」的第二份**。方案 §三 的判据是「模型不该学
|
|
665
|
+
* 两套控制方式」,第三类(后台子 agent)真做出来时会是第三套。
|
|
666
|
+
*
|
|
667
|
+
* 于是作业表下沉进了 [infra/jobs.ts](../../../infra/src/jobs.ts),这四个工具
|
|
668
|
+
* 直接问那张表:`task_list` 同时列后台命令和持久会话(**每行标出 kind**,
|
|
669
|
+
* 方案 §四 验收 11),`task_output` / `task_stop` 拿 `t3` 和 `s2` 一视同仁。
|
|
670
|
+
*
|
|
671
|
+
* 工具名**保留 `task_*`**,不改成 `job_*`(方案 §3.2):那四个名字已经在
|
|
672
|
+
* `docs/TOOLS.md`、用户的权限规则和审批缓存里了,改名的收益只有「和 dsh 一致」。
|
|
673
|
+
* 代价是名字里那个 `task` 现在比它管的东西窄了一点,工具描述把这件事说明白。
|
|
674
|
+
*
|
|
675
|
+
* ## `shell_*` 那五个**没有被这一轮吃掉**
|
|
676
|
+
*
|
|
677
|
+
* 方案 §1.4 那条分界线一个字没变:持久会话要**往里发东西**(`shell_send`),
|
|
678
|
+
* 后台命令不需要 —— 那不是同一种语义,合并工具会长出一张真值表。
|
|
679
|
+
* 合并的只有控制那一套,于是今天有两条路能关掉一个会话
|
|
680
|
+
* (`shell_close s1` 和 `task_stop s1`),而它们走的是**同一个** `stopJob()`。
|
|
681
|
+
* 两条路不是重复:一条在它自己那一族里(模型刚 `shell_open` 完,手边就是它),
|
|
682
|
+
* 一条在统一的控制器里(模型在 `task_list` 里看到一屏东西,要收掉其中一个)。
|
|
683
|
+
*
|
|
684
|
+
* ## 权限:这四个仍然归 `file_read`,但**判据换了一条**
|
|
685
|
+
*
|
|
686
|
+
* 它们**不执行任何命令**,真正的命令在 `terminal` / `shell_send` 那一步就已经
|
|
687
|
+
* 过完危险表和权限判定了。把它们标成 `command` 会让模型每取一次输出都弹一次
|
|
688
|
+
* 确认框。
|
|
689
|
+
*
|
|
690
|
+
* ⚠️ `task_stop` 是这四个里唯一真会动手的,它现在能停的不止是 shell 命令 ——
|
|
691
|
+
* 还有持久会话,将来还有后台子 agent。**这一格 PR-3 真的重判了一遍**,结论是
|
|
692
|
+
* 维持 `file_read`,而理由不再是「不执行命令」这一句:完整判据(连同
|
|
693
|
+
* `shell_close`)写在 `core/src/permission/operation-type.ts` 的
|
|
694
|
+
* `PROCESS_TABLE_TOOLS` 上,`core/__tests__/job-control-permission.test.ts`
|
|
695
|
+
* 把它钉住了。
|
|
696
|
+
*
|
|
697
|
+
* ## 四个都吃 `ctx.sessionId`
|
|
698
|
+
*
|
|
699
|
+
* 作业表按会话分区(判据在 `infra/src/jobs.ts` 的文件头),于是这四个工具
|
|
700
|
+
* **每一个**都要说出「我是谁」。别的会话的 id 一律走 {@link notFound} 那条出口 ——
|
|
701
|
+
* 和「压根没这个 id」同一句话。**那道墙同时是这四个归 `file_read` 的前提**:
|
|
702
|
+
* `task_stop` 停得到的每一格都是这个会话自己起的。
|
|
334
703
|
*/
|
|
335
704
|
|
|
336
705
|
declare const TASK_TOOLS: readonly EpochTool[];
|
|
337
706
|
|
|
338
707
|
/**
|
|
339
|
-
* 终端插件 ——
|
|
708
|
+
* 终端插件 —— `terminal` 这一个工具的定义,加整个插件的装配。
|
|
340
709
|
*
|
|
341
710
|
* 「怎么起进程、怎么收干净」在 [exec.ts](./exec.ts),这里只管工具契约:
|
|
342
711
|
* 参数 schema、危险命令拦截、workdir 边界、以及执行结果到 `ToolResult` 的映射。
|
|
712
|
+
*
|
|
713
|
+
* ⚠️ **另外两族工具的定义不在这个文件里**:四个 `task_*` 在
|
|
714
|
+
* [tasks.ts](./tasks.ts),五个 `shell_*`(持久会话,方案 49)在
|
|
715
|
+
* [shell/tools.ts](./shell/tools.ts)。这个文件只把它们摊进 `terminalPlugin.tools`。
|
|
716
|
+
* 分开的判据是**一个文件一族语义** —— 三族的超时、上限、审批口径各不相同,
|
|
717
|
+
* 挤在一起读的人得先分辨每段注释在说哪一族(同 `exec.ts` / `pty.ts` 那次拆分)。
|
|
343
718
|
*/
|
|
344
719
|
|
|
345
720
|
declare const terminalPlugin: EpochPlugin;
|
|
346
721
|
|
|
347
|
-
export { MAX_TASKS, TASK_TOOLS, cleanupBackgroundProcesses, clearAllTasks, describeTask, listTasks, rekeyTasks, startTask, stopTask, taskOutput, taskSandbox, terminalPlugin, waitTask };
|
|
722
|
+
export { MAX_SHELLS, MAX_SHELL_CHUNK, MAX_SHELL_OUTPUT, MAX_TASKS, SHELL_IDLE_MS, SHELL_SWEEP_MS, SHELL_TOOLS, type ShellInfo, type ShellStatus, TASK_TOOLS, cleanupBackgroundProcesses, clearAllShells, clearAllTasks, closeShell, describeShell, describeTask, listShells, listTasks, openShell, readShell, rekeyTasks, sendToShell, startTask, stopTask, sweepIdleShells, taskOutput, taskSandbox, terminalPlugin, waitTask };
|