agentus 0.3.2 → 0.3.4
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 +18 -50
- package/README.zh-CN.md +12 -42
- package/bin/agentus.mjs +4 -4
- package/docs/dev/01-dev-loop.md +1 -2
- package/docs/dev/02-verify.md +0 -1
- package/docs/dev/03-release.md +13 -0
- package/docs/dev/04-recover.md +6 -26
- package/docs/dev/05-agent-driven.md +1 -1
- package/docs/dev/README.md +4 -5
- package/docs/installation.md +76 -30
- package/docs/installation.zh-CN.md +68 -29
- package/package.json +10 -9
- package/packages/server/mock/agent.mjs +46 -0
- package/packages/server/src/acp/session-manager.ts +99 -28
- package/packages/server/src/index.ts +55 -63
- package/packages/server/src/notify/center.ts +118 -26
- package/packages/server/src/notify/types.ts +25 -0
- package/packages/server/src/settings.ts +40 -12
- package/packages/shared/src/index.ts +40 -0
- package/packages/web/dist/assets/index-D-RQzauE.css +32 -0
- package/packages/web/dist/assets/index-N0FnNa4-.js +119 -0
- package/packages/web/dist/index.html +2 -2
- package/packages/web/dist/sw.js +39 -0
- package/packages/web/dist/assets/index-CCvfL56d.css +0 -32
- package/packages/web/dist/assets/index-DPCbsWh9.js +0 -283
- package/scripts/make-cert.sh +0 -101
|
@@ -28,6 +28,57 @@ nvm install 22 && nvm use 22 # 或者
|
|
|
28
28
|
brew install node # 或者 https://nodejs.org/en/download
|
|
29
29
|
```
|
|
30
30
|
|
|
31
|
+
### 该跑哪份 Hermes
|
|
32
|
+
|
|
33
|
+
裸装的 Hermes 足够跑会话。但驾驶舱自己依赖的那几项能力**不在发布版里** —— 按会话切换的**思考深度**
|
|
34
|
+
(ACP `reasoning_effort`)、**跨重启 / `session/load` 存活的权限模式**,以及可自由填写的**上下文预算**
|
|
35
|
+
(带 256K/512K/768K/1M 预设)。它们在 fork
|
|
36
|
+
[`LiangYang666/hermes-agent`](https://github.com/LiangYang666/hermes-agent) 的 ACP 分支上,
|
|
37
|
+
按基座版本各有一个**集成构建的 tag**,自成命名空间:
|
|
38
|
+
|
|
39
|
+
```
|
|
40
|
+
agentus/<hermes 内部版本>/merge-<YYYYMMDD-HHMM>
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
用 tag 而不是分支是刻意的:同名分支(在 `yl-dev/` 下)是工作面、会被重建,而 tag 永不移动 —— 所以钉
|
|
44
|
+
tag。`agentus/` 这个前缀也把它和上游自己的 tag(`v0.21.7`、`v0.21.7+canary.*`)分开;最后一段是时间戳,
|
|
45
|
+
可排序,所以「最新」就是一次 `sort -V`。
|
|
46
|
+
|
|
47
|
+
先正常装好 Hermes(它的安装器会带上 Python/Node 运行时,并把 `hermes` 放进 PATH),再把那份构建
|
|
48
|
+
套在那个运行时上跑:
|
|
49
|
+
|
|
50
|
+
```bash
|
|
51
|
+
# 1. Hermes 本体:槽位要用的运行时
|
|
52
|
+
curl -fsSL https://hermes-agent.nousresearch.com/install.sh | bash
|
|
53
|
+
|
|
54
|
+
# 2. 最新的集成构建,按 tag 取(写全 refs/tags/:永不歧义)
|
|
55
|
+
TAG=$(git ls-remote --tags --refs https://github.com/LiangYang666/hermes-agent.git \
|
|
56
|
+
'refs/tags/agentus/*' | awk '{print $2}' | sed 's#refs/tags/##' | sort -V | tail -1)
|
|
57
|
+
git clone --depth=1 --branch "$TAG" https://github.com/LiangYang666/hermes-agent.git ~/Project/hermes-agent
|
|
58
|
+
|
|
59
|
+
# 3. 包一层,让驾驶舱指向这个 wrapper
|
|
60
|
+
mkdir -p ~/.local/bin
|
|
61
|
+
cat > ~/.local/bin/hermes-acp-src <<'WRAP'
|
|
62
|
+
#!/bin/sh
|
|
63
|
+
exec env PYTHONPATH="$HOME/Project/hermes-agent" hermes "$@"
|
|
64
|
+
WRAP
|
|
65
|
+
chmod +x ~/.local/bin/hermes-acp-src
|
|
66
|
+
AGENTUS_HERMES_CMD=~/.local/bin/hermes-acp-src npm start
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
`PYTHONPATH` 的作用是让这份 checkout **盖在**已安装的包前面,而解释器和依赖仍由装好的运行时提供 ——
|
|
70
|
+
所以第 1 步不能省。验证槽位实际会跑什么(就是 Agentus 缺后端时提示的那条自检):
|
|
71
|
+
|
|
72
|
+
```bash
|
|
73
|
+
PYTHONPATH=~/Project/hermes-agent hermes acp --check # → Hermes ACP check OK
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
装成服务时,把 `AGENTUS_HERMES_CMD` 放进你交给服务端的环境里 —— 见「作为服务常驻」。保持默认
|
|
77
|
+
(`hermes`)就是一份纯安装,所以 fork 是升级项、不是必须项。
|
|
78
|
+
|
|
79
|
+
换到更新的构建只是在那棵树里 fetch + checkout(`git fetch --depth=1 origin tag <tag> && git checkout <tag>`),
|
|
80
|
+
不用重装;正在跑的槽位会在下次重启时用上。
|
|
81
|
+
|
|
31
82
|
## 安装
|
|
32
83
|
|
|
33
84
|
### 一条命令 —— 用 npm 上发布的包(对外)
|
|
@@ -89,7 +140,7 @@ npm run agentus # 与 `npx agentus` 同一个 CLI,直接
|
|
|
89
140
|
agentus [options]
|
|
90
141
|
|
|
91
142
|
-p, --port <n> 监听端口 (AGENTUS_PORT,默认 8788)
|
|
92
|
-
-d, --data <dir> 状态目录(sqlite
|
|
143
|
+
-d, --data <dir> 状态目录(sqlite、登录、设置)
|
|
93
144
|
(AGENTUS_DATA,默认 ~/.agentus)
|
|
94
145
|
-o, --open 就绪后自动在浏览器打开驾驶舱
|
|
95
146
|
--where 打印解析出的路径后退出(不启动任何东西)
|
|
@@ -97,8 +148,8 @@ agentus [options]
|
|
|
97
148
|
-v, --version 打印版本
|
|
98
149
|
```
|
|
99
150
|
|
|
100
|
-
`--where` 值得记住:它会打印服务端**实际**解析出的路径 —— 数据目录、前端产物、随附 APK、
|
|
101
|
-
|
|
151
|
+
`--where` 值得记住:它会打印服务端**实际**解析出的路径 —— 数据目录、前端产物、随附 APK、
|
|
152
|
+
Node 版本 —— 然后退出,什么都不启动。它是「我的数据去哪了」的唯一权威答案(见下文)。
|
|
102
153
|
|
|
103
154
|
## 首次运行
|
|
104
155
|
|
|
@@ -132,9 +183,6 @@ agentus [options]
|
|
|
132
183
|
| `AGENTUS_HERMES_HOME` | `~/.hermes` | 未在行里指定 home 时的默认 `HERMES_HOME` |
|
|
133
184
|
| `AGENTUS_PERM_TIMEOUT_MS` | `300000`(5 分钟) | 权限提示自动取消前的等待时间 |
|
|
134
185
|
| `AGENTUS_HISTORY_PAGE` | `500` | 对话记录分页大小 |
|
|
135
|
-
| `AGENTUS_TLS_PORT` | 有证书时 `8443`,否则关闭 | TLS 监听端口(`0` = 关闭) |
|
|
136
|
-
| `AGENTUS_TLS_CERT` / `AGENTUS_TLS_KEY` | `<DATA_DIR>/tls/{cert,key}.pem` | 该监听用的证书材料 |
|
|
137
|
-
| `AGENTUS_TLS_CA` | `<DATA_DIR>/tls/ca.pem` | 设备只需装一次的根证书 |
|
|
138
186
|
| `AGENTUS_TTS_BASE_URL` | 未设置 | 服务端语音合成的 OpenAI 兼容 base(`…/v1`);不设 = 只用浏览器语音 |
|
|
139
187
|
| `AGENTUS_TTS_API_KEY` / `AGENTUS_TTS_MODEL` / `AGENTUS_TTS_VOICE` | – / `tts-1` / `alloy` | 同上 |
|
|
140
188
|
| `AGENTUS_STT_BASE_URL` | 未设置 | 服务端语音识别的 OpenAI 兼容 base;不设 = 只用浏览器识别 |
|
|
@@ -161,7 +209,7 @@ agentus [options]
|
|
|
161
209
|
### 数据目录
|
|
162
210
|
|
|
163
211
|
默认 `~/.agentus`。里面有 `agentus.sqlite`(会话 + 记录)、`credentials.json`、`auth.token`、
|
|
164
|
-
`auth.secret`、`
|
|
212
|
+
`auth.secret`、`settings.json`。首次启动时以 `0700` 创建,启动日志会打印它**以及为什么是
|
|
165
213
|
这个目录胜出**:
|
|
166
214
|
|
|
167
215
|
| 顺序 | 规则 |
|
|
@@ -175,14 +223,13 @@ agentus [options]
|
|
|
175
223
|
会打印服务端解析的结果,不用靠猜。
|
|
176
224
|
|
|
177
225
|
任何东西都不会写进 `node_modules`:对已安装的包来说,`<pkg>/packages/server/.data` 会被下一次
|
|
178
|
-
`npm i -g agentus`
|
|
226
|
+
`npm i -g agentus` 删掉,连带记录和登录一起没了。
|
|
179
227
|
|
|
180
228
|
### 端口
|
|
181
229
|
|
|
182
230
|
| 端口 | 协议 | 用途 |
|
|
183
231
|
|---|---|---|
|
|
184
|
-
| `AGENTUS_PORT`(8788) | 纯 HTTP | 局域网、本机回环、`curl`、脚本 —— 无证书警告,CI
|
|
185
|
-
| `AGENTUS_TLS_PORT`(8443) | HTTPS(自签) | **隧道** —— 加密公网那一跳 |
|
|
232
|
+
| `AGENTUS_PORT`(8788) | 纯 HTTP | 局域网、本机回环、`curl`、脚本 —— 无证书警告,CI 不变;**唯一的监听** |
|
|
186
233
|
| 5173 | Vite 开发服务器 | 前端热更新,仅在 `npm run dev` 期间 |
|
|
187
234
|
|
|
188
235
|
两个监听的 handler、路由、鉴权完全相同,只有 socket 不同。
|
|
@@ -202,24 +249,15 @@ agentus [options]
|
|
|
202
249
|
|
|
203
250
|
### TLS
|
|
204
251
|
|
|
205
|
-
纯 TCP 隧道(SakuraFrp、`ssh -L`、多数 frp 配置)只是转发字节 —— 它**不会**替你终结 TLS
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
```bash
|
|
209
|
-
scripts/make-cert.sh # 自签根证书 + 短期叶证书,SAN = 你实际输入的名字
|
|
210
|
-
# -> $AGENTUS_DATA/tls/{ca.pem,cert.pem,key.pem} (key 0600,已被 git 忽略)
|
|
211
|
-
# 启动日志会写:https://0.0.0.0:8443 (self-signed …) ; 把隧道指向这个端口
|
|
212
|
-
```
|
|
252
|
+
纯 TCP 隧道(SakuraFrp、`ssh -L`、多数 frp 配置)只是转发字节 —— 它**不会**替你终结 TLS。所以要
|
|
253
|
+
在服务端**前面**终结 TLS:放一层反代(Caddy / nginx),或者用自带 `public_tls` 的隧道。
|
|
213
254
|
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
(设置 → 安全 → CA 证书);iOS 还需要再到 *关于本机 → 证书信任设置 → 启用*。这是公开材料 ——
|
|
217
|
-
私钥永远不会离开 `<DATA_DIR>/tls/`。
|
|
255
|
+
本进程自己只讲纯 HTTP,刻意如此:LAN 这条路不需要证书,`curl`/脚本保持不变;证书的生命周期(签发、
|
|
256
|
+
轮换、SAN)留在前置层里,而不是塞进应用。
|
|
218
257
|
|
|
219
|
-
|
|
220
|
-
(`
|
|
221
|
-
|
|
222
|
-
设备不受影响。
|
|
258
|
+
走 TLS 到达的请求,session cookie 照样是 `Secure`:handler 读 `X-Forwarded-Proto`
|
|
259
|
+
(`packages/server/src/auth.ts`),凡设置了这个头的前置层(Caddy 和 nginx 默认都设)都会拿到
|
|
260
|
+
`Secure` cookie,本地无需任何配置。
|
|
223
261
|
|
|
224
262
|
### 后端
|
|
225
263
|
|
|
@@ -314,7 +352,7 @@ npm i -g agentus@latest # 或者:npx agentus@latest … 跑一次
|
|
|
314
352
|
|
|
315
353
|
```bash
|
|
316
354
|
npm rm -g agentus # 删程序(状态保留)
|
|
317
|
-
rm -rf ~/.agentus # 删状态 —— SQLite
|
|
355
|
+
rm -rf ~/.agentus # 删状态 —— SQLite、登录、设置
|
|
318
356
|
```
|
|
319
357
|
|
|
320
358
|
装到用户级前缀的话用 `npm rm -g --prefix ~/.local agentus`。卸载**不会**碰 `~/.hermes`(你 agent
|
|
@@ -346,5 +384,6 @@ rm -rf ~/.agentus # 删状态 —— SQLite、登录、TLS 密
|
|
|
346
384
|
**页面能打开但一直显示「reconnecting」。** 某条隧道丢掉了 WebSocket 的 `Upgrade`。实时流走
|
|
347
385
|
`/ws`;请换成能透传 WebSocket 的隧道(SakuraFrp 的 TCP+auto-HTTPS 隧道已验证可用)。
|
|
348
386
|
|
|
349
|
-
**局域网里出现证书警告。**
|
|
350
|
-
|
|
387
|
+
**局域网里出现证书警告。** 服务端自己只讲纯 HTTP(`:8788`),所以出现警告说明你是用 IP 直接
|
|
388
|
+
访问了终结 TLS 的那层前置(它的证书签的是域名,不是 IP)。要么用纯 HTTP 的 `:8788`,要么用证书
|
|
389
|
+
签发的那个名字去访问前置层。
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "agentus",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.4",
|
|
4
4
|
"description": "Multi-session web cockpit for ACP-speaking coding agents (Qoder CLI / Hermes). UI has no intelligence — the agent lives in spawned CLI subprocesses.",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"repository": {
|
|
@@ -23,7 +23,6 @@
|
|
|
23
23
|
"packages/shared/src/",
|
|
24
24
|
"packages/shared/package.json",
|
|
25
25
|
"packages/web/dist/",
|
|
26
|
-
"scripts/make-cert.sh",
|
|
27
26
|
"docs/",
|
|
28
27
|
"NOTICE"
|
|
29
28
|
],
|
|
@@ -56,20 +55,22 @@
|
|
|
56
55
|
"plan-service-sweep": "node scripts/qa/plan-service-sweep.mjs",
|
|
57
56
|
"plan-mcp-sweep": "node scripts/qa/plan-mcp-sweep.mjs",
|
|
58
57
|
"plan-v2-sweep": "tsx scripts/qa/plan-v2-sweep.mts",
|
|
59
|
-
"tls-smoke": "node scripts/tls-smoke.mjs",
|
|
60
58
|
"notify-testbed": "tsx scripts/notify-testbed.mts",
|
|
61
59
|
"notify-smoke": "tsx scripts/notify-smoke.mts",
|
|
60
|
+
"web-notify-rules": "tsx scripts/qa/web-notify-rules.mts",
|
|
61
|
+
"web-notify-sweep": "node scripts/qa/web-notify-sweep.mjs",
|
|
62
62
|
"apk": "bash scripts/build-apk.sh",
|
|
63
63
|
"transcript-fold": "tsx scripts/qa/transcript-fold.mts",
|
|
64
64
|
"store-fold": "tsx scripts/qa/store-fold.mts",
|
|
65
65
|
"live-stream": "tsx scripts/qa/live-stream.mts",
|
|
66
66
|
"open-target": "tsx scripts/qa/open-target.mts",
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
67
|
+
"rail-recent": "tsx scripts/qa/rail-recent.mts",
|
|
68
|
+
"permission-echo": "node scripts/qa/permission-echo.mjs",
|
|
69
|
+
"turn-pulse": "tsx scripts/qa/turn-pulse.mts",
|
|
70
|
+
"resume-tail": "tsx scripts/qa/resume-tail.mts",
|
|
71
|
+
"render-lag-sweep": "node scripts/qa/render-lag-sweep.mjs",
|
|
72
|
+
"replay-window": "node scripts/qa/replay-window.mjs",
|
|
73
|
+
"think-cadence": "node scripts/qa/think-cadence.mjs"
|
|
73
74
|
},
|
|
74
75
|
"engines": {
|
|
75
76
|
"node": ">=22.5"
|
|
@@ -5,8 +5,14 @@
|
|
|
5
5
|
//
|
|
6
6
|
// Env switches to simulate edge cases (append `=N` to env vars when spawning):
|
|
7
7
|
// MOCK_TOOL=1 -> prompt triggers a tool_call + requestPermission
|
|
8
|
+
// MOCK_TOOL2=1 -> TWO tool_calls + requestPermissions in one turn (a session holding more
|
|
9
|
+
// than one pending request; `[tool2]` in the prompt does the same)
|
|
8
10
|
// MOCK_THINK=1 -> emits agent_thought_chunk before the answer
|
|
9
11
|
// MOCK_SLOW_MS=n -> delay between chunks (default 60)
|
|
12
|
+
// MOCK_SPAWN_MS=n -> boot latency before the handshake answers (default 0). A real agent takes
|
|
13
|
+
// seconds to come up (node + initialize + session/load) and that window is
|
|
14
|
+
// where the cockpit's 「正在唤醒 agent 进程…」 state lives — a browser sweep
|
|
15
|
+
// cannot observe a state that lasts 80 ms.
|
|
10
16
|
// MOCK_SINK=1 -> exit the process mid-turn (crash path for AC5/QA)
|
|
11
17
|
import { randomUUID } from "node:crypto";
|
|
12
18
|
import fs from "node:fs";
|
|
@@ -14,6 +20,10 @@ import { AgentSideConnection, RequestError, ndJsonStream } from "@agentclientpro
|
|
|
14
20
|
import { Readable, Transform, Writable } from "node:stream";
|
|
15
21
|
|
|
16
22
|
const SLOW = Number(process.env.MOCK_SLOW_MS || 60);
|
|
23
|
+
/** Boot latency — deliberately BEFORE the stream is wired, so the slot sits in `starting` for as
|
|
24
|
+
* long as QA asks. Defaults to 0: the mock is instant unless a sweep needs the wake window. */
|
|
25
|
+
const SPAWN_MS = Number(process.env.MOCK_SPAWN_MS || 0);
|
|
26
|
+
if (SPAWN_MS > 0) await new Promise((r) => setTimeout(r, SPAWN_MS));
|
|
17
27
|
|
|
18
28
|
// The file a `[tool-diff]` turn pretends to edit: hermes hands the cockpit the whole file
|
|
19
29
|
// before and after, so the approval surface has a path and a real change to show.
|
|
@@ -498,6 +508,42 @@ const agent = () => ({
|
|
|
498
508
|
}
|
|
499
509
|
}
|
|
500
510
|
|
|
511
|
+
// `[tool2]` — TWO approvals from one turn, raised together (the shape an agent produces when it
|
|
512
|
+
// edits two files in parallel), with no diff payload so the two cards differ only by file name.
|
|
513
|
+
// This is the fixture for the state that has no other name: a session can hold more than one
|
|
514
|
+
// pending request, while the dialog draws only `perms[0]` — so answering the first swaps in the
|
|
515
|
+
// second, drawn identically, and the operator reads 「我点了允许,它还在那儿」 (measured
|
|
516
|
+
// 2026-10-11). QA only: scripts/qa/permission-echo.mjs.
|
|
517
|
+
if (process.env.MOCK_TOOL2 === "1" || /\btool2\b/.test(text)) {
|
|
518
|
+
const mode2 = s.currentModeId || "default";
|
|
519
|
+
if (mode2 !== "dont_ask" && mode2 !== "accept_edits") {
|
|
520
|
+
await Promise.all(["./two-a.txt", "./two-b.txt"].map(async (name, i) => {
|
|
521
|
+
const toolCallId = `tc-${sessionId}-pair-${i}`;
|
|
522
|
+
await send(agent._conn, sessionId, {
|
|
523
|
+
sessionUpdate: "tool_call", toolCallId,
|
|
524
|
+
title: `Write file: ${name}`, kind: "edit", status: "pending",
|
|
525
|
+
rawInput: { path: name, content: `hello from the mock agent (${i + 1})` },
|
|
526
|
+
});
|
|
527
|
+
const resp = await agent._conn.requestPermission({
|
|
528
|
+
sessionId,
|
|
529
|
+
toolCall: { toolCallId, title: `Write file: ${name}`, kind: "edit" },
|
|
530
|
+
options: [
|
|
531
|
+
{ optionId: "allow", name: "Allow", kind: "allow_once" },
|
|
532
|
+
{ optionId: "reject", name: "Reject", kind: "reject_once" },
|
|
533
|
+
],
|
|
534
|
+
});
|
|
535
|
+
const pick = resp?.outcome?.optionId;
|
|
536
|
+
await send(agent._conn, sessionId, {
|
|
537
|
+
sessionUpdate: "tool_call_update", toolCallId,
|
|
538
|
+
status: pick === "reject" ? "failed" : "completed",
|
|
539
|
+
rawOutput: pick === "reject"
|
|
540
|
+
? `rejected by the operator — nothing written (chosen option: ${pick})`
|
|
541
|
+
: `wrote ${name} (chosen option: ${pick})`,
|
|
542
|
+
});
|
|
543
|
+
}));
|
|
544
|
+
}
|
|
545
|
+
}
|
|
546
|
+
|
|
501
547
|
if (wantTool) {
|
|
502
548
|
const toolCallId = `tc-${sessionId}-${Date.now()}`;
|
|
503
549
|
const edit = wantHermes ? MOCK_HERMES_EDIT : MOCK_EDIT;
|
|
@@ -268,14 +268,33 @@ export class SessionManager {
|
|
|
268
268
|
return s.stderrBuf.slice(-lines).join("\n ");
|
|
269
269
|
}
|
|
270
270
|
|
|
271
|
+
/**
|
|
272
|
+
* Drop every pending approval of one slot, and TELL THE WIRE each one is gone.
|
|
273
|
+
*
|
|
274
|
+
* The client has exactly two ways to take an approval off the screen: the `permission-resolved`
|
|
275
|
+
* event, or a fresh `sessions` snapshot that no longer lists it. A bare `clear()` therefore
|
|
276
|
+
* leaves the popup, the transcript card, the ⚿ badge — and the phone's 待你确认 activity — for a
|
|
277
|
+
* request that does not exist any more; the operator clicks it, the server logs 「answer dropped」
|
|
278
|
+
* and answers `permission-expired`, and he reads his own cockpit as broken (measured 2026-10-11:
|
|
279
|
+
* `kill -9` the child with an approval up and the dialog is still there 4s later). So: every path
|
|
280
|
+
* that resolves these promises from the SERVER side goes through here.
|
|
281
|
+
*
|
|
282
|
+
* The decision is `cancelled` — what the agent would have received had nobody ever answered.
|
|
283
|
+
*/
|
|
284
|
+
#dropPending(live: LiveSession, decision: PermissionDecision = { outcome: "cancelled" }): void {
|
|
285
|
+
if (!live.pendingPermissions.size) return;
|
|
286
|
+
for (const [requestId, { resolve, timer }] of live.pendingPermissions) {
|
|
287
|
+
clearTimeout(timer);
|
|
288
|
+
resolve({ outcome: decision });
|
|
289
|
+
this.#emit({ t: "permission-resolved", requestId, decision });
|
|
290
|
+
}
|
|
291
|
+
live.pendingPermissions.clear();
|
|
292
|
+
}
|
|
293
|
+
|
|
271
294
|
/** Graceful shutdown: SIGTERM every live child before server exit. */
|
|
272
295
|
async shutdown(): Promise<void> {
|
|
273
296
|
const kills = [...this.#sessions.values()].map(async (s) => {
|
|
274
|
-
|
|
275
|
-
clearTimeout(timer);
|
|
276
|
-
resolve({ outcome: { outcome: "cancelled" } });
|
|
277
|
-
}
|
|
278
|
-
s.pendingPermissions.clear();
|
|
297
|
+
this.#dropPending(s);
|
|
279
298
|
s.child?.kill("SIGTERM");
|
|
280
299
|
});
|
|
281
300
|
await Promise.allSettled(kills);
|
|
@@ -690,11 +709,7 @@ export class SessionManager {
|
|
|
690
709
|
// operator watched their slot flicker into the archive for a second — measured on :8901).
|
|
691
710
|
// Mark the swap BEFORE the signal, so the exit we are causing is not read as a dead agent.
|
|
692
711
|
live.replacing = true;
|
|
693
|
-
|
|
694
|
-
clearTimeout(timer);
|
|
695
|
-
resolve({ outcome: { outcome: "cancelled" } });
|
|
696
|
-
}
|
|
697
|
-
live.pendingPermissions.clear();
|
|
712
|
+
this.#dropPending(live);
|
|
698
713
|
live.busy = false;
|
|
699
714
|
// show the swap honestly: "starting" with no pid, not a pid that is already gone
|
|
700
715
|
live.info.status = "starting";
|
|
@@ -1243,11 +1258,7 @@ export class SessionManager {
|
|
|
1243
1258
|
* it / it crashed / the cockpit reclaimed it for being idle) and a rail that shows one word for
|
|
1244
1259
|
* all three teaches the operator to distrust the column. */
|
|
1245
1260
|
#stop(live: LiveSession, why: "closed" | "archived" | "reaped"): void {
|
|
1246
|
-
|
|
1247
|
-
clearTimeout(timer);
|
|
1248
|
-
resolve({ outcome: { outcome: "cancelled" } });
|
|
1249
|
-
}
|
|
1250
|
-
live.pendingPermissions.clear();
|
|
1261
|
+
this.#dropPending(live);
|
|
1251
1262
|
live.child?.kill("SIGTERM");
|
|
1252
1263
|
live.info.status = why === "reaped" ? "reaped" : "closed";
|
|
1253
1264
|
live.info.pid = null;
|
|
@@ -1276,7 +1287,34 @@ export class SessionManager {
|
|
|
1276
1287
|
live.touchedAt = Date.now();
|
|
1277
1288
|
}
|
|
1278
1289
|
|
|
1279
|
-
/**
|
|
1290
|
+
/** The `n` most recently USED sessions that are not archived — 「热备」, the slots whose process is
|
|
1291
|
+
* kept no matter how quiet they go. Explicitly a FLOOR, not a cap: it names which slots may not be
|
|
1292
|
+
* reclaimed for being idle; it never bounds how many may run.
|
|
1293
|
+
*
|
|
1294
|
+
* Ranked over live AND COLD rows by the same clock the sweep uses (`max(touchedAt, lastActivity,
|
|
1295
|
+
* createdAt)`). Ranking only the live ones would leak the meaning: after a reap the seat would fall
|
|
1296
|
+
* to whatever still happens to hold a process, so a session nobody has opened in days could inherit
|
|
1297
|
+
* it and be shielded by the very rule that just fired on his own recent session.
|
|
1298
|
+
*
|
|
1299
|
+
* Archived rows never take a seat — 归档 is 「收起来」, its process is meant to go — so the seat
|
|
1300
|
+
* passes to the next most recent one instead of being held by a row he put away. */
|
|
1301
|
+
#warmSet(n: number): Set<string> {
|
|
1302
|
+
const lastAt = this.#store.lastActivityAt();
|
|
1303
|
+
return new Set(
|
|
1304
|
+
this.#store
|
|
1305
|
+
.listSessions(true)
|
|
1306
|
+
.filter((r) => r.archivedAt == null)
|
|
1307
|
+
.map((r) => ({
|
|
1308
|
+
id: r.id,
|
|
1309
|
+
at: Math.max(this.#sessions.get(r.id)?.touchedAt ?? 0, lastAt.get(r.id) ?? 0, r.createdAt),
|
|
1310
|
+
}))
|
|
1311
|
+
.sort((a, b) => b.at - a.at)
|
|
1312
|
+
.slice(0, n)
|
|
1313
|
+
.map((r) => r.id),
|
|
1314
|
+
);
|
|
1315
|
+
}
|
|
1316
|
+
|
|
1317
|
+
/** Reclaim slots: stop the agent process of a session nobody has touched for the operator's
|
|
1280
1318
|
* threshold. Deliberately NOT an archive — the row stays in 工作空间 wearing `reaped` and a click
|
|
1281
1319
|
* brings it back. AionUi ships exactly this switch (设置 → 系统 → 「Agent 空闲超时(分钟)」,
|
|
1282
1320
|
* default 5, 1–60) and the guards below are what its changelog had to add after the first version
|
|
@@ -1284,12 +1322,39 @@ export class SessionManager {
|
|
|
1284
1322
|
* one the operator is WATCHING right now (a reply that lands while he reads it must not be killed
|
|
1285
1323
|
* under him).
|
|
1286
1324
|
*
|
|
1287
|
-
*
|
|
1288
|
-
|
|
1325
|
+
* TWO deadlines, and the difference is the whole point (2026-10-10): `idleMs` is the operator's
|
|
1326
|
+
* answer to 「空闲多久收掉」 for the sessions he is NOT working in, while the 热备 set (`warmSlots`,
|
|
1327
|
+
* the most recently used ones) is exempt from it — 「即使空闲也保留在那里」 — and only obeys its own
|
|
1328
|
+
* much longer `warmTtlMs`. A single time rule was what made 「我只是离开浏览器」 cost him the agent
|
|
1329
|
+
* of the session he had been in a minute earlier.
|
|
1330
|
+
*
|
|
1331
|
+
* Every callback is read on EVERY tick, so a settings change applies with no restart (a restart
|
|
1332
|
+
* would kill exactly the slots this feature exists to keep alive cheaply); 0 = off for `idleMs`,
|
|
1333
|
+
* 0 = 不过期 for `warmTtlMs`. */
|
|
1334
|
+
startIdleReaper(opts: {
|
|
1335
|
+
idleMs: () => number;
|
|
1336
|
+
/** How many of the most recent sessions are 热备 — exempt from `idleMs`. Read per tick, like the
|
|
1337
|
+
* threshold itself: changing it in 设置 must apply without a restart (a restart would kill
|
|
1338
|
+
* exactly the slots this feature exists to keep alive cheaply). */
|
|
1339
|
+
warmSlots: () => number;
|
|
1340
|
+
/** How long a warm slot may stay untouched before even its exemption expires; 0 = 不过期.
|
|
1341
|
+
* Read per tick too, and it is a SEPARATE deadline from `idleMs` (hours, not minutes). */
|
|
1342
|
+
warmTtlMs: () => number;
|
|
1343
|
+
watched?: (id: string) => boolean;
|
|
1344
|
+
intervalMs?: number;
|
|
1345
|
+
}): () => void {
|
|
1289
1346
|
const interval = opts.intervalMs ?? 30_000;
|
|
1347
|
+
/** Two decimals where it matters: a QA run drives these thresholds in SECONDS, and 「0 min」 on
|
|
1348
|
+
* both sides of the message is a log line that cannot be read. */
|
|
1349
|
+
const fmt = (ms: number): string =>
|
|
1350
|
+
ms < 3_600_000 ? `${(ms / 60_000).toFixed(1)} min` : `${(ms / 3_600_000).toFixed(1)} h`;
|
|
1290
1351
|
const tick = (): void => {
|
|
1291
1352
|
const idleMs = opts.idleMs();
|
|
1292
|
-
|
|
1353
|
+
const warmTtlMs = opts.warmTtlMs();
|
|
1354
|
+
// Either rule alone is a reason to sweep (idle off + a warm TTL still expires warm slots).
|
|
1355
|
+
if (!(idleMs > 0) && !(warmTtlMs > 0)) return;
|
|
1356
|
+
const warmN = opts.warmSlots();
|
|
1357
|
+
const warm = warmN > 0 ? this.#warmSet(warmN) : null;
|
|
1293
1358
|
const now = Date.now();
|
|
1294
1359
|
let reaped = 0;
|
|
1295
1360
|
for (const s of [...this.#sessions.values()]) {
|
|
@@ -1303,10 +1368,20 @@ export class SessionManager {
|
|
|
1303
1368
|
// 「刚点进去、没发消息、换个会话过一分钟 acp 就被回收」) — the fresh process inherited a
|
|
1304
1369
|
// days-old stamp, so the very next tick found it over the threshold.
|
|
1305
1370
|
const lastAt = Math.max(s.touchedAt, s.info.lastAt ?? 0, s.info.createdAt);
|
|
1306
|
-
|
|
1307
|
-
|
|
1308
|
-
|
|
1309
|
-
|
|
1371
|
+
const age = now - lastAt;
|
|
1372
|
+
if (warm?.has(s.info.id)) {
|
|
1373
|
+
// 热备 = 「即使空闲也保留在这里」. The idle rule does not apply; only its own, much longer
|
|
1374
|
+
// deadline does — otherwise five quiet slots are a permanent ~700 MB floor.
|
|
1375
|
+
if (!(warmTtlMs > 0) || age < warmTtlMs) continue;
|
|
1376
|
+
console.log(
|
|
1377
|
+
`[agentus] warm slot expired: ${s.info.id} (${fmt(age)} untouched, warm ttl ${fmt(warmTtlMs)})`,
|
|
1378
|
+
);
|
|
1379
|
+
} else {
|
|
1380
|
+
if (!(idleMs > 0) || age < idleMs) continue;
|
|
1381
|
+
console.log(
|
|
1382
|
+
`[agentus] idle reap: ${s.info.id} (${Math.round(age / 60_000)} min idle, limit ${Math.round(idleMs / 60_000)} min)`,
|
|
1383
|
+
);
|
|
1384
|
+
}
|
|
1310
1385
|
this.#stop(s, "reaped");
|
|
1311
1386
|
reaped += 1;
|
|
1312
1387
|
}
|
|
@@ -1583,11 +1658,7 @@ export class SessionManager {
|
|
|
1583
1658
|
// …and if we are REPLACING this slot's process (restart), the exit is the one we asked for: the
|
|
1584
1659
|
// slot has not failed, it is coming back on a new child.
|
|
1585
1660
|
if (live.replacing) return;
|
|
1586
|
-
|
|
1587
|
-
clearTimeout(timer);
|
|
1588
|
-
resolve({ outcome: { outcome: "cancelled" } });
|
|
1589
|
-
}
|
|
1590
|
-
live.pendingPermissions.clear();
|
|
1661
|
+
this.#dropPending(live);
|
|
1591
1662
|
live.busy = false;
|
|
1592
1663
|
live.info.status = "error";
|
|
1593
1664
|
live.info.lastError = `agent process exited (code=${code} sig=${sig})` + stderrTail(live);
|