agentus 0.3.3 → 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/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 +4 -3
- package/packages/server/mock/agent.mjs +38 -0
- package/packages/server/src/acp/session-manager.ts +27 -20
- package/packages/server/src/index.ts +40 -63
- package/packages/server/src/notify/center.ts +118 -26
- package/packages/server/src/notify/types.ts +25 -0
- 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-DCBs8dsw.js → index-N0FnNa4-.js} +47 -47
- 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/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,15 +55,17 @@
|
|
|
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
67
|
"rail-recent": "tsx scripts/qa/rail-recent.mts",
|
|
68
|
+
"permission-echo": "node scripts/qa/permission-echo.mjs",
|
|
68
69
|
"turn-pulse": "tsx scripts/qa/turn-pulse.mts",
|
|
69
70
|
"resume-tail": "tsx scripts/qa/resume-tail.mts",
|
|
70
71
|
"render-lag-sweep": "node scripts/qa/render-lag-sweep.mjs",
|
|
@@ -5,6 +5,8 @@
|
|
|
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)
|
|
10
12
|
// MOCK_SPAWN_MS=n -> boot latency before the handshake answers (default 0). A real agent takes
|
|
@@ -506,6 +508,42 @@ const agent = () => ({
|
|
|
506
508
|
}
|
|
507
509
|
}
|
|
508
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
|
+
|
|
509
547
|
if (wantTool) {
|
|
510
548
|
const toolCallId = `tc-${sessionId}-${Date.now()}`;
|
|
511
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;
|
|
@@ -1647,11 +1658,7 @@ export class SessionManager {
|
|
|
1647
1658
|
// …and if we are REPLACING this slot's process (restart), the exit is the one we asked for: the
|
|
1648
1659
|
// slot has not failed, it is coming back on a new child.
|
|
1649
1660
|
if (live.replacing) return;
|
|
1650
|
-
|
|
1651
|
-
clearTimeout(timer);
|
|
1652
|
-
resolve({ outcome: { outcome: "cancelled" } });
|
|
1653
|
-
}
|
|
1654
|
-
live.pendingPermissions.clear();
|
|
1661
|
+
this.#dropPending(live);
|
|
1655
1662
|
live.busy = false;
|
|
1656
1663
|
live.info.status = "error";
|
|
1657
1664
|
live.info.lastError = `agent process exited (code=${code} sig=${sig})` + stderrTail(live);
|
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
// Agentus server entry: HTTP (REST + static web build) + WebSocket relay.
|
|
2
2
|
// The browser never talks to CLI subprocesses directly (design.md §1).
|
|
3
3
|
import { createServer, type IncomingMessage, type ServerResponse } from "node:http";
|
|
4
|
-
import { createServer as createHttpsServer } from "node:https";
|
|
5
4
|
import type { Duplex } from "node:stream";
|
|
6
5
|
import { createHash, randomUUID } from "node:crypto";
|
|
7
6
|
import { homedir } from "node:os";
|
|
@@ -31,7 +30,7 @@ const PORT = Number(process.env.AGENTUS_PORT ?? 8788);
|
|
|
31
30
|
// cwd-relative path would silently open a second database (the operator's slots would
|
|
32
31
|
// "disappear"). Both entry points must land on the same store.
|
|
33
32
|
const HERE = path.dirname(fileURLToPath(import.meta.url)); // …/packages/server/src
|
|
34
|
-
// WHERE STATE LIVES — sqlite, credentials.json, auth.token,
|
|
33
|
+
// WHERE STATE LIVES — sqlite, credentials.json, auth.token, settings.json.
|
|
35
34
|
// 1. `AGENTUS_DATA` when it is set. Every launcher and dev/QA instance pins it, so the
|
|
36
35
|
// rules below can never move a running install's data out from under it.
|
|
37
36
|
// 2. `~/.agentus` once it holds something, or when there is no repo-side data yet.
|
|
@@ -41,7 +40,7 @@ const HERE = path.dirname(fileURLToPath(import.meta.url)); // …/packages/serve
|
|
|
41
40
|
// directory won and how to move).
|
|
42
41
|
// NOTHING may write inside node_modules: for an installed package
|
|
43
42
|
// `<pkg>/packages/server/.data` is deleted by the next `npm i -g agentus`, taking the
|
|
44
|
-
// transcript
|
|
43
|
+
// transcript and the login with it.
|
|
45
44
|
const DEFAULT_DATA_DIR = path.join(homedir(), ".agentus");
|
|
46
45
|
const REPO_DATA_DIR = path.resolve(HERE, "../.data");
|
|
47
46
|
const hasData = (dir: string): boolean => {
|
|
@@ -69,19 +68,6 @@ const APK_FILE = process.env.AGENTUS_APK
|
|
|
69
68
|
path.join(DATA_DIR, "agentus-companion.apk"),
|
|
70
69
|
].find((p) => fs.existsSync(p))
|
|
71
70
|
?? path.resolve(HERE, "../../../android/artifacts/agentus-companion.apk");
|
|
72
|
-
// Optional second listener, TLS (see the block right before listen). Declared up here
|
|
73
|
-
// because the request handler also serves the public cert — a phone that has to trust a
|
|
74
|
-
// self-signed issuer needs to fetch the cert from somewhere, and that somewhere should
|
|
75
|
-
// not require already trusting it.
|
|
76
|
-
// AGENTUS_TLS_PORT=0 -> off; no cert on disk -> off (the LAN listener is unaffected).
|
|
77
|
-
const TLS_PORT = Number(process.env.AGENTUS_TLS_PORT ?? 8443);
|
|
78
|
-
const TLS_CERT = process.env.AGENTUS_TLS_CERT ?? path.join(DATA_DIR, "tls", "cert.pem");
|
|
79
|
-
const TLS_KEY = process.env.AGENTUS_TLS_KEY ?? path.join(DATA_DIR, "tls", "key.pem");
|
|
80
|
-
// The issuer a device installs once (make-cert.sh: long-lived root + short-lived leaf).
|
|
81
|
-
// Falls back to the leaf itself for a single self-signed cert from an older setup.
|
|
82
|
-
const TLS_CA = process.env.AGENTUS_TLS_CA ?? path.join(DATA_DIR, "tls", "ca.pem");
|
|
83
|
-
const TLS_READY = TLS_PORT > 0 && fs.existsSync(TLS_CERT) && fs.existsSync(TLS_KEY);
|
|
84
|
-
|
|
85
71
|
// `agentus --where` (bin/agentus.mjs) asks the ONE place that decides these paths to say
|
|
86
72
|
// what it decided, then exits — before any listener, store or subprocess exists. Print and
|
|
87
73
|
// exit here rather than re-deriving the same rules in the CLI: "where is my data" must not
|
|
@@ -92,12 +78,11 @@ if (process.env.AGENTUS_PRINT_PATHS) {
|
|
|
92
78
|
console.log(`[agentus] data dir ${DATA_DIR} (${DATA_DIR_SOURCE})`);
|
|
93
79
|
console.log(`[agentus] web bundle ${WEB_DIST} (${has(path.join(WEB_DIST, "index.html"))})`);
|
|
94
80
|
console.log(`[agentus] companion apk ${APK_FILE} (${has(APK_FILE)})`);
|
|
95
|
-
console.log(`[agentus] tls listener ${TLS_READY ? `on :${TLS_PORT} (${TLS_CERT})` : `off (AGENTUS_TLS_PORT=${TLS_PORT}, cert ${has(TLS_CERT)})`}`);
|
|
96
81
|
console.log(`[agentus] http port ${PORT}`);
|
|
97
82
|
process.exit(0);
|
|
98
83
|
}
|
|
99
84
|
|
|
100
|
-
fs.mkdirSync(DATA_DIR, { recursive: true, mode: 0o700 }); // 0700: credentials
|
|
85
|
+
fs.mkdirSync(DATA_DIR, { recursive: true, mode: 0o700 }); // 0700: credentials live here
|
|
101
86
|
const store = new Store(path.join(DATA_DIR, "agentus.sqlite"));
|
|
102
87
|
// M6: an existing cockpit must behave exactly as before, so the registry starts as a copy of
|
|
103
88
|
// the builtin (env-driven) rows and the operator edits from there. Seeding is one-shot: an
|
|
@@ -223,16 +208,25 @@ notify.onAction(({ ref, action, activity, device }) => {
|
|
|
223
208
|
console.log(`[notify] action device=${device.name} activity=${activity.activityId} action=${action.actionId} type=${ref?.type ?? "none"}`);
|
|
224
209
|
if (!ref) return; // the button existed but nobody claimed it: log and drop, never guess
|
|
225
210
|
switch (ref.type) {
|
|
226
|
-
case "permission":
|
|
211
|
+
case "permission": {
|
|
227
212
|
// The lock screen answering an agent's permission prompt — the whole point of the channel.
|
|
228
|
-
mgr.respondPermission(String(ref.sessionId), String(ref.requestId), {
|
|
213
|
+
const applied = mgr.respondPermission(String(ref.sessionId), String(ref.requestId), {
|
|
229
214
|
outcome: "selected",
|
|
230
215
|
optionId: String(ref.optionId),
|
|
231
216
|
}, {
|
|
232
217
|
optionKind: typeof ref.optionKind === "string" ? ref.optionKind : undefined,
|
|
233
218
|
signature: typeof ref.signature === "string" ? ref.signature : undefined,
|
|
234
219
|
});
|
|
220
|
+
// The request is gone (our timeout, the agent gave up, its process died). Take the card off
|
|
221
|
+
// the phone instead of leaving a 「待你确认」 that no longer has a request behind it — the
|
|
222
|
+
// browser gets `permission-expired` for the same click, and a phone that keeps a dead button
|
|
223
|
+
// is the same defect one device over.
|
|
224
|
+
if (!applied) {
|
|
225
|
+
console.log(`[notify] permission answer dropped for ${ref.requestId}: card dismissed`);
|
|
226
|
+
notify.dismiss(`perm:${ref.requestId}`);
|
|
227
|
+
}
|
|
235
228
|
return;
|
|
229
|
+
}
|
|
236
230
|
case "open":
|
|
237
231
|
return; // the app already opened the deeplink; nothing to do server-side
|
|
238
232
|
default:
|
|
@@ -240,6 +234,19 @@ notify.onAction(({ ref, action, activity, device }) => {
|
|
|
240
234
|
}
|
|
241
235
|
});
|
|
242
236
|
|
|
237
|
+
// The page channel of the same decision: one `notify` frame to every cockpit websocket.
|
|
238
|
+
//
|
|
239
|
+
// No cursor, no replay, and no per-client state — deliberately unlike the device socket above. The
|
|
240
|
+
// phone replays because it was PAIRED and may have been asleep in a pocket; a browser tab that missed
|
|
241
|
+
// frames has a transcript to catch up in, and a wall of stale cards after a reload is worse than none.
|
|
242
|
+
// Every gate that matters (which kinds, whether the channel is on) was applied in the notify centre.
|
|
243
|
+
notify.onNotice((notice) => {
|
|
244
|
+
const wire = JSON.stringify({ t: "notify", notice });
|
|
245
|
+
for (const { ws } of clients.values()) {
|
|
246
|
+
if (ws.readyState === WebSocket.OPEN) ws.send(wire);
|
|
247
|
+
}
|
|
248
|
+
});
|
|
249
|
+
|
|
243
250
|
// Operator settings (voice, theme, call knobs, talk-to-agent prefs) live in the store's
|
|
244
251
|
// `settings` table: the UI owns them and env only bootstraps them (settings.ts). Read
|
|
245
252
|
// before anything asks.
|
|
@@ -408,9 +415,10 @@ function serveStatic(req: IncomingMessage, res: ServerResponse): void {
|
|
|
408
415
|
fs.createReadStream(file).pipe(res);
|
|
409
416
|
}
|
|
410
417
|
|
|
411
|
-
// One request handler,
|
|
412
|
-
//
|
|
413
|
-
//
|
|
418
|
+
// One request handler, plain HTTP on PORT (LAN, loopback, curl/scripts).
|
|
419
|
+
// TLS is NOT terminated in-process: put a TLS-terminating reverse proxy (or a tunnel that
|
|
420
|
+
// does its own `public_tls`) in FRONT of this port. That keeps the LAN path warning-free
|
|
421
|
+
// and `curl`/scripts byte-identical, and keeps cert lifecycle out of the app.
|
|
414
422
|
const handleRequest = async (req: IncomingMessage, res: ServerResponse) => {
|
|
415
423
|
const url = new URL(req.url ?? "/", "http://x");
|
|
416
424
|
try {
|
|
@@ -452,24 +460,6 @@ const handleRequest = async (req: IncomingMessage, res: ServerResponse) => {
|
|
|
452
460
|
return res.end("Agentus: authentication required\n");
|
|
453
461
|
}
|
|
454
462
|
|
|
455
|
-
// GET /cert.crt — the ISSUER to install into a phone's trust store (the "proceed
|
|
456
|
-
// anyway" click, once per device, is the whole cost of TOFU; this makes it a download).
|
|
457
|
-
// With make-cert.sh's root+leaf setup this is the root, so the leaf can be rotated
|
|
458
|
-
// yearly without every device having to re-install.
|
|
459
|
-
// PUBLIC material only: the key never leaves <DATA>/tls/.
|
|
460
|
-
if (url.pathname === "/cert.crt") {
|
|
461
|
-
if (!TLS_READY) return send(res, 404, { error: "tls is off on this server" });
|
|
462
|
-
const der = fs.readFileSync(fs.existsSync(TLS_CA) ? TLS_CA : TLS_CERT);
|
|
463
|
-
res.writeHead(200, {
|
|
464
|
-
// Apple's installer claims this type and opens the profile flow directly;
|
|
465
|
-
// Android/Chrome just downloads the file.
|
|
466
|
-
"content-type": "application/x-x509-ca-cert",
|
|
467
|
-
"content-disposition": 'attachment; filename="agentus.crt"',
|
|
468
|
-
"content-length": String(der.length),
|
|
469
|
-
});
|
|
470
|
-
return res.end(der);
|
|
471
|
-
}
|
|
472
|
-
|
|
473
463
|
// ---- the phone's onboarding page + the APK, both behind the operator login ----------
|
|
474
464
|
// The pairing code lives on this page, so it is NOT public: an unauthenticated visitor is
|
|
475
465
|
// sent to the login screen (the SPA at /) and comes back with a session cookie.
|
|
@@ -1250,7 +1240,14 @@ async function post(path){const r=await fetch(path,{method:'POST'});alert(r.ok?'
|
|
|
1250
1240
|
const html = fs.readFileSync(path.join(WEB_DIST, "index.html"), "utf8");
|
|
1251
1241
|
asset = html.match(/\/assets\/index-([A-Za-z0-9_-]+)\.js/)?.[1] ?? null;
|
|
1252
1242
|
} catch { /* no build on disk (dev server without dist) */ }
|
|
1253
|
-
|
|
1243
|
+
// …and `dev`: WHICH INSTANCE this is. Only two things in the UI care, and both are about
|
|
1244
|
+
// keeping a QA backend out of the operator's way — the mock agent shows up in the new-session
|
|
1245
|
+
// form on a dev instance only. The marker is the dev runner's own environment (`DEV_TREE`,
|
|
1246
|
+
// written by scripts/dev.sh; also AGENTUS_DEV=1 for a launcher of one's own); the live
|
|
1247
|
+
// launcher sets neither, so live answers false. An instance-level fact, so it rides the
|
|
1248
|
+
// endpoint that already describes the instance rather than every backend row.
|
|
1249
|
+
const dev = Boolean(process.env.DEV_TREE) || process.env.AGENTUS_DEV === "1";
|
|
1250
|
+
return send(res, 200, { asset, dev });
|
|
1254
1251
|
}
|
|
1255
1252
|
if (url.pathname.startsWith("/api/")) return send(res, 404, { error: "not_found" });
|
|
1256
1253
|
return serveStatic(req, res);
|
|
@@ -1269,8 +1266,7 @@ const httpServer = createServer(handleRequest);
|
|
|
1269
1266
|
// it becomes a socket. A browser cannot set headers on a WS handshake, so auth
|
|
1270
1267
|
// rides on the cookie (sent automatically, same-origin) — or ?token= for scripts.
|
|
1271
1268
|
const wss = new WebSocketServer({ noServer: true });
|
|
1272
|
-
// Bound to
|
|
1273
|
-
// needs its wss sockets too, and this handler reads the cookie / ?token=, never the scheme.
|
|
1269
|
+
// Bound to the single HTTP listener; this handler reads the cookie / ?token=, never the scheme.
|
|
1274
1270
|
const handleUpgrade = (req: IncomingMessage, socket: Duplex, head: Buffer) => {
|
|
1275
1271
|
const url = new URL(req.url ?? "/", "http://x");
|
|
1276
1272
|
// The companion's socket carries its own device token in the query — a phone cannot set
|
|
@@ -1596,25 +1592,6 @@ httpServer.listen(PORT, "0.0.0.0", () => {
|
|
|
1596
1592
|
);
|
|
1597
1593
|
}
|
|
1598
1594
|
});
|
|
1599
|
-
// ---- optional TLS listener (self-signed) ----
|
|
1600
|
-
// A tunnel that terminates on the public internet must NOT be plain HTTP: the login
|
|
1601
|
-
// password and the session cookie would cross it in the clear. SEPARATE from PORT on
|
|
1602
|
-
// purpose, so the LAN path stays plain HTTP — no cert warning in the house, curl
|
|
1603
|
-
// unchanged. (Config constants live at the top, next to the other paths.)
|
|
1604
|
-
if (TLS_READY) {
|
|
1605
|
-
const httpsServer = createHttpsServer({ cert: fs.readFileSync(TLS_CERT), key: fs.readFileSync(TLS_KEY) }, handleRequest);
|
|
1606
|
-
httpsServer.on("upgrade", handleUpgrade);
|
|
1607
|
-
httpsServer.listen(TLS_PORT, "0.0.0.0", () => {
|
|
1608
|
-
console.log(`[agentus-server] https://0.0.0.0:${TLS_PORT} (self-signed, SAN ${TLS_CERT} — point a public tunnel at THIS port; ${PORT} stays for the LAN)`);
|
|
1609
|
-
});
|
|
1610
|
-
// A busy TLS port must not take the LAN listener down with it: log, keep serving.
|
|
1611
|
-
httpsServer.on("error", (err) => {
|
|
1612
|
-
console.error(`[agentus-server] tls listener failed on ${TLS_PORT}: ${(err as Error).message}`);
|
|
1613
|
-
});
|
|
1614
|
-
} else if (TLS_PORT > 0) {
|
|
1615
|
-
console.log(`[agentus-server] tls off: no cert at ${TLS_CERT} (create it with scripts/make-cert.sh, or set AGENTUS_TLS_PORT=0)`);
|
|
1616
|
-
}
|
|
1617
|
-
|
|
1618
1595
|
httpServer.on("error", (err) => {
|
|
1619
1596
|
console.error(`[agentus] cannot listen on ${PORT}: ${(err as Error).message}`);
|
|
1620
1597
|
process.exit(1); // fail fast: never linger as a listener-less zombie
|