agentus 0.0.0-stage → 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 (59) hide show
  1. package/LICENSE +202 -0
  2. package/NOTICE +11 -0
  3. package/README.md +476 -2
  4. package/README.zh-CN.md +396 -0
  5. package/bin/agentus.mjs +147 -0
  6. package/docs/android-notify-contract.md +658 -0
  7. package/docs/dev/01-dev-loop.md +186 -0
  8. package/docs/dev/02-verify.md +237 -0
  9. package/docs/dev/03-release.md +218 -0
  10. package/docs/dev/04-recover.md +199 -0
  11. package/docs/dev/05-agent-driven.md +114 -0
  12. package/docs/dev/README.md +65 -0
  13. package/docs/installation.md +366 -0
  14. package/docs/installation.zh-CN.md +350 -0
  15. package/docs/refs/aionui-acp-rules.md +46 -0
  16. package/docs/refs/hermes-studio-ui-ideas.md +191 -0
  17. package/docs/transcript-folding.md +139 -0
  18. package/package.json +76 -5
  19. package/packages/server/mcp/plan-server.mjs +217 -0
  20. package/packages/server/mock/agent.mjs +693 -0
  21. package/packages/server/package.json +22 -0
  22. package/packages/server/src/acp/backends.ts +196 -0
  23. package/packages/server/src/acp/errors.ts +46 -0
  24. package/packages/server/src/acp/registry.ts +579 -0
  25. package/packages/server/src/acp/session-manager.ts +1868 -0
  26. package/packages/server/src/auth.ts +688 -0
  27. package/packages/server/src/dashscope.ts +235 -0
  28. package/packages/server/src/fs.ts +379 -0
  29. package/packages/server/src/hotwords.ts +132 -0
  30. package/packages/server/src/index.ts +1486 -0
  31. package/packages/server/src/notify/center.ts +994 -0
  32. package/packages/server/src/notify/types.ts +222 -0
  33. package/packages/server/src/plan/plan.ts +126 -0
  34. package/packages/server/src/pty-helper.py +135 -0
  35. package/packages/server/src/settings.ts +469 -0
  36. package/packages/server/src/store/export.ts +209 -0
  37. package/packages/server/src/store/store.ts +1238 -0
  38. package/packages/server/src/term.ts +201 -0
  39. package/packages/server/src/title.ts +109 -0
  40. package/packages/server/src/voice.ts +251 -0
  41. package/packages/shared/package.json +14 -0
  42. package/packages/shared/src/index.ts +271 -0
  43. package/packages/web/dist/assets/index-D667PX2c.css +32 -0
  44. package/packages/web/dist/assets/index-d9NzwPFu.js +283 -0
  45. package/packages/web/dist/coding-agents/hermes.png +0 -0
  46. package/packages/web/dist/coding-agents/qoder.svg +5 -0
  47. package/packages/web/dist/icons/agentus-icon-inverse.svg +301 -0
  48. package/packages/web/dist/icons/agentus-icon.svg +301 -0
  49. package/packages/web/dist/icons/agentus-mark-inverse.svg +301 -0
  50. package/packages/web/dist/icons/agentus-mark.svg +301 -0
  51. package/packages/web/dist/icons/apple-touch-icon-180.png +0 -0
  52. package/packages/web/dist/icons/favicon.ico +0 -0
  53. package/packages/web/dist/icons/icon-192.png +0 -0
  54. package/packages/web/dist/icons/icon-512.png +0 -0
  55. package/packages/web/dist/icons/icon-maskable-512.png +0 -0
  56. package/packages/web/dist/index.html +30 -0
  57. package/packages/web/dist/manifest.webmanifest +17 -0
  58. package/packages/web/dist/sw.js +61 -0
  59. package/scripts/make-cert.sh +101 -0
@@ -0,0 +1,350 @@
1
+ # 安装 Agentus
2
+
3
+ [English](./installation.md) · [README](../README.md)
4
+
5
+ Agentus 是面向「说 ACP 协议的编码 agent」的多会话 Web 驾驶舱。一个 npm 包(`agentus`)跑起一个
6
+ Node 服务:它提供构建好的 React 单页应用,并按需把 agent CLI(`hermes acp` / `qodercli --acp`)
7
+ 作为子进程拉起。浏览器负责驾驶会话,推理始终留在 CLI 子进程里。
8
+
9
+ ## 前置条件
10
+
11
+ 必须满足两条:
12
+
13
+ - **Node >= 22.5。** 存储层用的是 `node:sqlite`,它在 22.5 才落地。版本不够时 Agentus 会直接拒绝
14
+ 启动,并给出明确提示。
15
+ - **PATH 上有一个 ACP agent CLI** —— `hermes acp` 或 `qodercli --acp`。这是 npm 唯一无法替你装的
16
+ 前置项。没有它,驾驶舱照样能跑、也能登录,只是没有后端可以开会话。
17
+
18
+ ```bash
19
+ # 检查 agent 侧(Hermes 会自检;qoder 需要先登录)
20
+ hermes acp --check
21
+ qodercli login # 否则 newSession 会以 -32000 失败
22
+ ```
23
+
24
+ 装 Node 本身很简单:
25
+
26
+ ```bash
27
+ nvm install 22 && nvm use 22 # 或者
28
+ brew install node # 或者 https://nodejs.org/en/download
29
+ ```
30
+
31
+ ## 安装
32
+
33
+ ### 一条命令 —— 用 npm 上发布的包(对外)
34
+
35
+ ```bash
36
+ npx agentus # 跑一次,不往全局装
37
+ npm i -g agentus # 或者装上,然后直接跑 `agentus`
38
+ ```
39
+
40
+ `npx agentus` 在前台运行驾驶舱 —— Ctrl-C 即停,并且会把它拉起的 agent 子进程一并带走。
41
+
42
+ ### 一条命令 —— 仓里的安装脚本
43
+
44
+ 和上面同一件事,把检查与回退写在脚本里(**绝不**用 `sudo`):
45
+
46
+ ```bash
47
+ curl -fsSL https://raw.githubusercontent.com/agentus-org/agentus/main/scripts/install.sh | bash
48
+ ```
49
+
50
+ 安装脚本(`scripts/install.sh`)会检查 Node、用 npm 全局装上已发布的 `agentus` 包;如果全局目录
51
+ 不可写,它会退回到用户级前缀(`~/.local`),**绝不**动 `sudo`。装完会打印启动命令和要打开的网址。
52
+ 除非你显式设置 `AGENTUS_ALLOW_ROOT=1`,它拒绝以 root 运行。
53
+
54
+ 可用环境变量覆盖默认行为:
55
+
56
+ | 变量 | 默认值 | 作用 |
57
+ |---|---|---|
58
+ | `AGENTUS_VERSION` | `latest` | 固定安装的版本,例如 `0.1.0` |
59
+ | `AGENTUS_INSTALL_DIR` | 未设置 | 安装到该 `npm --prefix`,而不是全局目录 |
60
+ | `AGENTUS_REGISTRY` | npm 官方源 | 拉取用的源 |
61
+ | `AGENTUS_ALLOW_ROOT` | `0` | `1` 允许以 root 运行 |
62
+ | `AGENTUS_NODE_MIN` | `22.5` | 覆盖 Node 版本下限检查 |
63
+
64
+ ```bash
65
+ # 固定版本 + 指定前缀
66
+ AGENTUS_VERSION=0.1.0 AGENTUS_INSTALL_DIR="$HOME/.local" \
67
+ bash scripts/install.sh
68
+ ```
69
+
70
+ `npx agentus` 在前台运行驾驶舱 —— Ctrl-C 即停,并且会把它拉起的 agent 子进程一并带走。
71
+
72
+ ### 从源码(一份 checkout)
73
+
74
+ ```bash
75
+ git clone https://github.com/agentus-org/agentus.git
76
+ cd agentus
77
+ npm start # 需要时自动装依赖、构建、起服务(scripts/start.sh)
78
+ npm run dev -w @agentus/web # 前端热更新(Vite :5173,局域网可达)
79
+ npm run agentus # 与 `npx agentus` 同一个 CLI,直接从 checkout 里跑
80
+ ```
81
+
82
+ `npm start`(→ `scripts/start.sh`)会检查 Node 版本,`node_modules` 缺失时以
83
+ `NODE_ENV=development` 装依赖,前端产物过期时重新构建,然后只起一次服务(无 watcher)。
84
+ 用 `AGENTUS_PORT=9000 npm start` 换端口。
85
+
86
+ ## 命令行
87
+
88
+ ```
89
+ agentus [options]
90
+
91
+ -p, --port <n> 监听端口 (AGENTUS_PORT,默认 8787)
92
+ -d, --data <dir> 状态目录(sqlite、登录、TLS 材料、设置)
93
+ (AGENTUS_DATA,默认 ~/.agentus)
94
+ -o, --open 就绪后自动在浏览器打开驾驶舱
95
+ --where 打印解析出的路径后退出(不启动任何东西)
96
+ -h, --help 帮助文本
97
+ -v, --version 打印版本
98
+ ```
99
+
100
+ `--where` 值得记住:它会打印服务端**实际**解析出的路径 —— 数据目录、前端产物、随附 APK、TLS
101
+ 监听、Node 版本 —— 然后退出,什么都不启动。它是「我的数据去哪了」的唯一权威答案(见下文)。
102
+
103
+ ## 首次运行
104
+
105
+ 1. 启动:`agentus`(或 `npx agentus`,或 checkout 里的 `npm start`)。
106
+ 2. 打开 **http://localhost:8787** 登录。默认凭据:
107
+
108
+ ```
109
+ username: admin
110
+ password: 123456 ← 请改掉
111
+ ```
112
+
113
+ 在应用内改:**设置 → 账号**(第一张卡片)。改了立即生效,并写入
114
+ `<DATA_DIR>/credentials.json`(0600,scrypt 哈希)。
115
+ 3. 添加后端。后端注册表在首次启动时用内置行播种(见[后端](#后端));确认 `hermes` 或 `qoder`
116
+ 能解析到,或把某一行指向你自己的命令。
117
+ 4. **+ new session** → 选一个后端 + 一个工作目录 → 开会话。
118
+
119
+ 你启动的第一个会话会使用你自己 agent 已经在用的 Hermes home(`~/.hermes`),除非后端行里指定了
120
+ 另一个 —— 见 [HERMES_HOME](#hermes_home)。
121
+
122
+ ## 配置参考
123
+
124
+ ### 环境变量
125
+
126
+ | 变量 | 默认值 | 含义 |
127
+ |---|---|---|
128
+ | `AGENTUS_PORT` | `8787` | 服务端口(从不读裸 `PORT`) |
129
+ | `AGENTUS_DATA` | `~/.agentus` | 状态目录;见[数据目录](#数据目录) |
130
+ | `AGENTUS_HERMES_CMD` | `hermes` | hermes 后端要拉起的可执行文件 |
131
+ | `AGENTUS_QODER_CMD` | `~/.local/bin/qodercli` | qoder 后端同理 |
132
+ | `AGENTUS_HERMES_HOME` | `~/.hermes` | 未在行里指定 home 时的默认 `HERMES_HOME` |
133
+ | `AGENTUS_PERM_TIMEOUT_MS` | `300000`(5 分钟) | 权限提示自动取消前的等待时间 |
134
+ | `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
+ | `AGENTUS_TTS_BASE_URL` | 未设置 | 服务端语音合成的 OpenAI 兼容 base(`…/v1`);不设 = 只用浏览器语音 |
139
+ | `AGENTUS_TTS_API_KEY` / `AGENTUS_TTS_MODEL` / `AGENTUS_TTS_VOICE` | – / `tts-1` / `alloy` | 同上 |
140
+ | `AGENTUS_STT_BASE_URL` | 未设置 | 服务端语音识别的 OpenAI 兼容 base;不设 = 只用浏览器识别 |
141
+ | `AGENTUS_STT_API_KEY` / `AGENTUS_STT_MODEL` / `AGENTUS_STT_LANGUAGE` | – / `whisper-1` / – | 同上 |
142
+ | `DASHSCOPE_API_KEY` / `DASHSCOPE_BASE_URL` | – | 百炼(DashScope)凭据;从进程环境**和** `~/.hermes/.env` 读取 |
143
+
144
+ 认证相关:
145
+
146
+ | 变量 | 默认值 | 含义 |
147
+ |---|---|---|
148
+ | `AGENTUS_AUTH` | `on` | `off` = 完全不要登录(仅供本机折腾) |
149
+ | `AGENTUS_USERNAME` | `admin` | 操作员用户名 |
150
+ | `AGENTUS_PASSWORD` | `123456` | 操作员密码(环境里是明文) |
151
+ | `AGENTUS_PASSWORD_HASH` | — | `scrypt:<salt>:<hex>`;设了就优先于 `AGENTUS_PASSWORD` |
152
+ | `AGENTUS_SESSION_TTL_MS` | 604800000(7 天) | 会话有效期 |
153
+ | `AGENTUS_AUTH_SECRET` | `<DATA_DIR>/auth.secret` | cookie 签名密钥(0600,自动生成) |
154
+ | `AGENTUS_AUTH_TOKEN` | `<DATA_DIR>/auth.token` | 供脚本用的机器令牌(0600,自动生成) |
155
+ | `AGENTUS_LOGIN_MAX_FAILS` / `AGENTUS_LOGIN_LOCK_MS` | `5` / `30000` | 按 IP 的暴力破解锁定 |
156
+ | `AGENTUS_BASIC_AUTH` | — | 挡在**所有**请求前的 HTTP Basic(`:pass` 只校验密码) |
157
+
158
+ 语音/主题类的环境变量只是**引导值**:之后由设置页(侧栏 ⚙)接管,存到 `<DATA_DIR>/settings.json`
159
+ (0600)。页面里的设置优先;环境变量的作用是让一份全新安装在没人打开设置页之前也能正常工作。
160
+
161
+ ### 数据目录
162
+
163
+ 默认 `~/.agentus`。里面有 `agentus.sqlite`(会话 + 记录)、`credentials.json`、`auth.token`、
164
+ `auth.secret`、`tls/`、`settings.json`。首次启动时以 `0700` 创建,启动日志会打印它**以及为什么是
165
+ 这个目录胜出**:
166
+
167
+ | 顺序 | 规则 |
168
+ |---|---|
169
+ | 1 | 设置了 `AGENTUS_DATA` 时用它 —— 每个启动器和 dev/QA 实例都钉住这个 |
170
+ | 2 | `~/.agentus`,当它已有内容,或仓内还没有数据时 |
171
+ | 3 | `<repo>/packages/server/.data` —— 早于这套布局的既有 checkout |
172
+
173
+ 规则 3 的存在是为了让「悄悄换了目录」的安装不会以空的会话列表启动(那读起来就像是「我的会话
174
+ 都不见了」)。要迁移这样的 checkout,用 `AGENTUS_DATA=~/.agentus` 启动一次即可。`agentus --where`
175
+ 会打印服务端解析的结果,不用靠猜。
176
+
177
+ 任何东西都不会写进 `node_modules`:对已安装的包来说,`<pkg>/packages/server/.data` 会被下一次
178
+ `npm i -g agentus` 删掉,连带记录、登录和 TLS 密钥一起没了。
179
+
180
+ ### 端口
181
+
182
+ | 端口 | 协议 | 用途 |
183
+ |---|---|---|
184
+ | `AGENTUS_PORT`(8787) | 纯 HTTP | 局域网、本机回环、`curl`、脚本 —— 无证书警告,CI 不变 |
185
+ | `AGENTUS_TLS_PORT`(8443) | HTTPS(自签) | **隧道** —— 加密公网那一跳 |
186
+ | 5173 | Vite 开发服务器 | 前端热更新,仅在 `npm run dev` 期间 |
187
+
188
+ 两个监听的 handler、路由、鉴权完全相同,只有 socket 不同。
189
+
190
+ ### 登录
191
+
192
+ 驾驶舱默认在登录后面。存在两种凭据:
193
+
194
+ | 凭据 | 怎么传递 | 谁在用 |
195
+ |---|---|---|
196
+ | 会话 cookie | `HttpOnly` + `SameSite=Lax`,HMAC-SHA256 签名,7 天 | 浏览器(`POST /api/auth/login` 签发) |
197
+ | 机器令牌 | `Authorization: Bearer <t>` 或 `?token=<t>` | 脚本、CI、启动器 —— 从 `<DATA_DIR>/auth.token` 读(0600) |
198
+
199
+ 会话是无状态 cookie,服务端索引在 `<DATA_DIR>/sessions.json`(0600),所以吊销和「登出所有设备」
200
+ 是真实生效的,且能跨重启存活。`AGENTUS_USERNAME` / `AGENTUS_PASSWORD` / `AGENTUS_PASSWORD_HASH`
201
+ 是引导值;一旦你在「账号」卡片里保存过,就以文件为准。删掉 `credentials.json` 即可回退到环境变量。
202
+
203
+ ### TLS
204
+
205
+ 纯 TCP 隧道(SakuraFrp、`ssh -L`、多数 frp 配置)只是转发字节 —— 它**不会**替你终结 TLS。所以
206
+ 服务端自己会讲 TLS,落在**第二个**监听上:
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
+ ```
213
+
214
+ 该监听还会在 `/cert.crt` 上提供自己的签发证书,设备只需装一次根证书:
215
+ `https://<你的名字>:<tls-port>/cert.crt`。iOS 会直接打开描述文件安装器;Android 会下载它
216
+ (设置 → 安全 → CA 证书);iOS 还需要再到 *关于本机 → 证书信任设置 → 启用*。这是公开材料 ——
217
+ 私钥永远不会离开 `<DATA_DIR>/tls/`。
218
+
219
+ `make-cert.sh` 有意签**两**张证书:一张 10 年的根(`ca.pem`,设备装它)和一张 390 天的叶
220
+ (`cert.pem`,监听提供它)。Apple 把服务器证书有效期卡在 398 天,所以一张 10 年的叶证书正是
221
+ iOS 在用户走完安装流程**之后**才拒绝的东西。叶证书可以随意轮换(`--leaf-only`),已装根证书的
222
+ 设备不受影响。
223
+
224
+ ### 后端
225
+
226
+ | id | 命令 | 说明 |
227
+ |---|---|---|
228
+ | `hermes` | `hermes acp` | 需要 PATH 上有 Hermes 安装(`hermes acp --check`) |
229
+ | `qoder` | `qodercli --acp` | 需要先 `qodercli login`,否则 `newSession` 以 `-32000` 失败 |
230
+ | `mock` | `node packages/server/mock/agent.mjs` | 离线开发/QA:假流式、权限提示、崩溃/规划/工具触发 |
231
+
232
+ 新增后端就是 `packages/server/src/acp/backends.ts` 里的一行;协议层永远不变。
233
+
234
+ ## HERMES_HOME
235
+
236
+ 被拉起的 agent CLI 会继承服务端的环境,所以服务端**总是显式设置 `HERMES_HOME`**:行里指定了就用
237
+ 行里的,否则用默认 —— **你真实的 `~/.hermes`**,也就是你的 gateway 和 Studio 正打开的那个
238
+ `state.db`。这就是预期的生产设置:一个会话驱动的是你真正的 agent,带着你的配置、凭据、记忆和会话
239
+ 列表。
240
+
241
+ **继承来的** `HERMES_HOME` 会被忽略,所以会话往哪写永远是你选定的东西 —— 在后端行上,或经由
242
+ `AGENTUS_HERMES_HOME`。要让某个会话的数据独立,就在那行的 HERMES_HOME 里写一个目录,不需要任何
243
+ 许可。
244
+
245
+ ## 作为服务常驻
246
+
247
+ Agentus 默认在前台跑;一个服务单元能让它跨登录、跨重启常驻。把单元指向你平时用的同一个
248
+ `AGENTUS_DATA`,否则驾驶舱会带着另一份会话列表起来。
249
+
250
+ **macOS(launchd)** —— `~/Library/LaunchAgents/org.agentus.cockpit.plist`:
251
+
252
+ ```xml
253
+ <?xml version="1.0" encoding="UTF-8"?>
254
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
255
+ <plist version="1.0">
256
+ <dict>
257
+ <key>Label</key> <string>org.agentus.cockpit</string>
258
+ <key>ProgramArguments</key>
259
+ <array>
260
+ <string>/usr/bin/env</string><string>agentus</string><string>--port</string><string>8787</string>
261
+ </array>
262
+ <key>EnvironmentVariables</key>
263
+ <dict><key>AGENTUS_DATA</key><string>/Users/YOU/.agentus</string></dict>
264
+ <key>RunAtLoad</key> <true/>
265
+ <key>KeepAlive</key> <true/>
266
+ <key>StandardOutPath</key> <string>/tmp/agentus.out.log</string>
267
+ <key>StandardErrorPath</key><string>/tmp/agentus.err.log</string>
268
+ </dict>
269
+ </plist>
270
+ ```
271
+
272
+ ```bash
273
+ launchctl load ~/Library/LaunchAgents/org.agentus.cockpit.plist
274
+ launchctl kickstart -k gui/$(id -u)/org.agentus.cockpit # 重启
275
+ ```
276
+
277
+ **Linux(systemd,用户单元)** —— `~/.config/systemd/user/agentus.service`:
278
+
279
+ ```ini
280
+ [Unit]
281
+ Description=Agentus cockpit
282
+ After=network.target
283
+
284
+ [Service]
285
+ ExecStart=/usr/bin/env agentus --port 8787
286
+ Environment=AGENTUS_DATA=%h/.agentus
287
+ Restart=on-failure
288
+
289
+ [Install]
290
+ WantedBy=default.target
291
+ ```
292
+
293
+ ```bash
294
+ systemctl --user daemon-reload
295
+ systemctl --user enable --now agentus
296
+ journalctl --user -u agentus -f
297
+ ```
298
+
299
+ TODO(verify):launchd/systemd 单元里 `agentus` 能否被 `env` 解析,取决于 npm 把 bin 装到了哪
300
+ (用户级前缀默认不在服务进程的 PATH 上)—— 如果单元起不来,把 `ExecStart` / `ProgramArguments`
301
+ 改成 `agentus` bin 的绝对路径。
302
+
303
+ ## 升级
304
+
305
+ ```bash
306
+ npm i -g agentus@latest # 或者:npx agentus@latest … 跑一次
307
+ ```
308
+
309
+ 状态不受影响 —— `~/.agentus`(或 `AGENTUS_DATA` 指向的地方)不在包内,升级不会删掉它。如果你
310
+ 用 `AGENTUS_INSTALL_DIR=~/.local` 装到了某个前缀,就用同一个前缀升级。启动时服务端会一次性接管
311
+ 既有会话 cookie,而不是把所有设备登出。
312
+
313
+ ## 卸载
314
+
315
+ ```bash
316
+ npm rm -g agentus # 删程序(状态保留)
317
+ rm -rf ~/.agentus # 删状态 —— SQLite、登录、TLS 密钥、设置
318
+ ```
319
+
320
+ 装到用户级前缀的话用 `npm rm -g --prefix ~/.local agentus`。卸载**不会**碰 `~/.hermes`(你 agent
321
+ 自己的数据),也不会碰任何工作目录。
322
+
323
+ ## 排错
324
+
325
+ **端口被占用。** 用 `agentus --port 9000`,或找出并停掉占用者:
326
+ `lsof -nP -iTCP:8787 -sTCP:LISTEN`。Agentus 从不读裸 `PORT`,原因正在于此。
327
+
328
+ **「没有后端」/ 会话起不来。** 缺少 agent CLI,或它没登录。跑 `hermes acp --check`,或
329
+ `qodercli login`(`newSession` 报 `-32000` 就是 qoder 没登录)。在设置里检查后端行的命令;启动
330
+ 日志和「探测」都会说明命令能否解析。
331
+
332
+ **登录问题。**
333
+
334
+ - 忘了密码:删掉 `<DATA_DIR>/credentials.json` 并重启 —— `AGENTUS_USERNAME` / `AGENTUS_PASSWORD`
335
+ 的引导值(或默认值)重新生效。
336
+ - 被暴力破解限制器锁了:**设置 → 登录失败锁定** 会列出被锁的 IP,可 unlock / unlock all。
337
+ - 彻底进不去(登录不了就够不到设置页):删掉 `<DATA_DIR>/sessions.json` 丢掉会话索引,或重启
338
+ 服务 —— 内存里的锁定会在启动时清除。
339
+
340
+ **「我的会话不见了。」** 服务端读的是另一个数据目录。跑 `agentus --where` —— 它会打印数据目录
341
+ **以及是哪条规则选中的它**(见本页开头)。常见原因是:某个既有 checkout 的会话在
342
+ `<repo>/packages/server/.data`,而现在的安装默认到了 `~/.agentus`。用 `AGENTUS_DATA` 指向真正有
343
+ 数据的那份启动一次,例如 `AGENTUS_DATA=~/.agentus agentus` 或
344
+ `AGENTUS_DATA=<repo>/packages/server/.data agentus`。
345
+
346
+ **页面能打开但一直显示「reconnecting」。** 某条隧道丢掉了 WebSocket 的 `Upgrade`。实时流走
347
+ `/ws`;请换成能透传 WebSocket 的隧道(SakuraFrp 的 TCP+auto-HTTPS 隧道已验证可用)。
348
+
349
+ **局域网里出现证书警告。** 那是 TLS 监听的自签证书(裸局域网 IP 无法承载可信证书)。要么在
350
+ 局域网里用纯 HTTP 的 `:8787` 端口,要么从 `/cert.crt` 装一次签发证书(见 [TLS](#tls))。
@@ -0,0 +1,46 @@
1
+ # AionUi ACP 行为规则摘要(主代理亲读 PRD 提炼;子代理因 429 配额中断未完成)
2
+
3
+ > 来源:`~/Project/AionUi/docs/prds/conversations/acp/`(9 份 PRD,1798 行)。
4
+ > 本文件只记**对 Agentus 有约束力的行为规则**,不含代码。Agentus 采纳/调整见每条末尾。
5
+
6
+ ## permissions.md (F-PERM)
7
+ 1. **F-PERM-01 审批卡**:卡片必须含 操作类型 + 操作内容(title) + 选项(允许/始终允许/拒绝)。
8
+ → 采纳:我们的 PermissionCard 三态按钮直接映射 ACP options.kind(allow_once/allow_always/reject_once)。
9
+ 2. **超时自动拒绝**:30 分钟未响应自动 reject(AionUi 自己还没实现,只有 createdAt 预留)。
10
+ → 采纳但收紧:Agentus 服务端 pending 权限 5 分钟超时 → `{outcome:"cancelled"}`(防会话永久卡死,design.md §8-4)。
11
+ 3. **停止联动**:用户点"停止"时 pending 权限必须同时被取消。→ 采纳:cancel 路径 resolve 该会话所有 pending。
12
+ 4. **并发多个请求按序展示**。→ 采纳:UI 按 createdAt 排队渲染。
13
+ 5. "始终允许"仅当前会话生效,重置即清。→ 采纳:allow_always 记在 LiveSession 内存 Map,不落库。
14
+
15
+ ## reliability.md (F-RELIABILITY)
16
+ 6. 连接超时给明确提示,首次失败自动重试一次。→ 采纳:WS 重连指数退避;spawn 失败错误进 session.lastError + 红色 meta 气泡。
17
+ 7. **AI 回复超时的"活跃即续命"规则**:持续输出会重置计时器;工具调用与权限等待期间不触发超时。
18
+ → 采纳:Agentus 不做整回合硬超时(prompt 由 agent 自己负责),但 UI 上 running 态超过 3 分钟无任何事件时显示"仍在等待…"提示(仅提示不杀进程)。
19
+ 8. 启动失败给友好提示(后端命令不存在/未登录)。→ 采纳:create() 的 catch 把 stderr 尾巴带进 lastError(已实现 stderrTail),qoder 未登录时提示 `qodercli login`。
20
+
21
+ ## session.md / messaging.md(扫读要点)
22
+ 9. 一条消息的流式 chunk 合并进**同一条** assistant 消息(不是每个 chunk 一个气泡)。→ 已实现:前端按 kind 聚合(见 web/store)。
23
+ 10. tool_call_update 按 toolCallId upsert 合并,永不新增气泡。→ 已实现:store.upsertToolMessage。
24
+ 11. 会话切换/刷新后必须能恢复到"正在跑的回合"的实时流(resume 回放)。→ 采纳:WS resume 带 lastSeq per session,服务端从 SQLite 补发(已实现),并广播当前 busy 态。
25
+
26
+ ## config.md / display.md(扫读要点)
27
+ 12. config options(如思考深度)在 newSession 响应里声明,UI 按 type 渲染控件。→ 已实现:ConfigOptionView select → 顶栏下拉。
28
+ 13. 思考块默认折叠、流式期间自动展开、结束后按全局设置。→ 部分采纳:thought 块默认折叠、可点开(MVP 不做全局设置项)。
29
+
30
+ ## 版权红线
31
+ 本文件是规则提炼(思想),非逐字翻译;Agentus 代码不 derived 自 AionUi 源码文本,故 SPDX 头仅在未来真搬代码时添加。
32
+
33
+ ## 坑:SDK 会静默丢弃"不合规的嵌套条目"(2026-10-02 实测)
34
+
35
+ `@agentclientprotocol/sdk@1.5.1` 的入站校验对**数组项**不是报错而是**逐项丢弃**
36
+ (`acp.test.js`: `zPlan.parse({entries:[...]})` 只保留通过校验的项)。实测证据:
37
+
38
+ - 原始线上(裸 JSON-RPC 探针):`sessionUpdate:"plan"` 带 3 条 entries。
39
+ - 经 SDK:只剩 1 条 —— 唯一带 `priority` 的那条。
40
+ - 原因:ACP 规范要求 `PlanEntry` 的 `content` + `priority` + `status` **三者必填**;
41
+ 漏 `priority` 的条目被丢。同理适用于 `tool_call.content[]`、`locations[]` 等嵌套数组。
42
+
43
+ **对我们的意义**:界面侧完全看不到"丢了什么",只会看到更短的 plan/更空的工具卡。
44
+ 所以:① 自家 mock 必须严格合规;② 接入 Qoder 等第三方 agent 时,若 plan 项/工具输出
45
+ 莫名变少,先怀疑对端字段缺失,用裸探针(`assets/raw_probe.mjs` 思路)对比线上与 SDK 侧;
46
+ ③ 真 hermes 是合规的(`acp_adapter/events.py` 固定补 `priority="medium"`)。
@@ -0,0 +1,191 @@
1
+ # Hermes Studio 聊天 UI 设计思想摘要(调研笔记)
2
+
3
+ > 调研对象:`~/Project/hermes-studio`(Vue 3 + Pinia + Socket.IO + markdown-it + vue-virtual-scroller)。
4
+ > 目的:为 Agentus 的聊天 UI 提取可移植的设计思想,不抄代码。行号基于当前 checkout,引用路径相对 `packages/client/src`(服务端相对 `packages/server/src`)。
5
+
6
+ ---
7
+
8
+ ## 1. 流式消息渲染
9
+
10
+ ### 1.1 组件分层
11
+
12
+ - `views/hermes/ChatView.vue` — 薄壳:只做路由 ↔ store 同步(`routeSessionId` watch → `switchSession`,ChatView.vue:60-75),把 document.title 绑定到会话标题。
13
+ - `components/hermes/chat/ChatPanel.vue` — 三栏布局(会话列表 / 聊天区 / 工具面板),持有移动端抽屉、面板拖宽等布局态。
14
+ - `MessageList.vue`(1636 行)— 消息列表编排:显示过滤、fork 分隔线、滚动跟随、流式指示器、悬浮审批/排队区。
15
+ - `VirtualMessageList.vue` — 纯滚动容器原语,封装 vue-virtual-scroller + stick-to-bottom 逻辑,双向都可用(`virtualized` prop)。
16
+ - `MessageItem.vue` — 单条消息:按 `role`(user/assistant/tool/system/command)分支渲染,内嵌思考块、工具行、附件。
17
+ - `MarkdownRenderer.vue` — 内容 → HTML 的纯展示组件,被 assistant/user 正文与思考块复用。
18
+
19
+ **设计规则:**
20
+ 1. 滚动策略("怎么跟")与列表内容("显示什么")分离:`VirtualMessageList` 只懂视口数学,`MessageList` 决定何时调用。
21
+ 2. 列表显示层用 computed 过滤(`displayMessages`,MessageList.vue:127-146),不改 store 里的原始消息数组——流式期间 store 是唯一事实源。
22
+ 3. 历史页(长消息数)用虚拟滚动;实时聊天页显式传 `:virtualized="false"`(MessageList.vue:484 附近),因为消息数被硬性分页封顶(见 §2.2),全量渲染更简单且避免动态高度测量抖动。历史列表 `HistoryMessageList.vue:186` 则默认开启虚拟化。
23
+
24
+ ### 1.2 chunk 如何合并进消息列表
25
+
26
+ 核心是 store 内的事件 reducer(`stores/hermes/chat.ts`,约 2700-3100 行是一个大 `handleEvent` switch):
27
+
28
+ - 每次 run 持有闭包态 `activeAssistantMessageId` / `reasoningAssistantMessageId`(chat.ts:3188-3190 附近)。`message.delta` 到达时:若最后一个流式 assistant 消息还在,就地 `last.content += evt.delta`;否则新建 `{role:'assistant', isStreaming:true}` 并记住 id(chat.ts:2802-2837)。
29
+ - `tool.started` 先把流式 assistant 消息 `isStreaming=false` 封口、清空 `activeAssistantMessageId`(chat.ts:2840-2858)——即"文本段与工具段天然切开",工具之后的新 delta 会另起一条 assistant 消息。
30
+ - 生命周期事件(`run.completed` / `abort.completed` / `tool.completed`)负责收尾:封口所有 streaming 消息、把 `running` 工具 settle 为 `done/error`(chat.ts:2936-2945、2876-2894)。
31
+ - 兜底:若上游没发任何 `message.delta` 只在 `run.completed.output` 给全文,则补建一条 assistant 消息(chat.ts:2985-3005);若"无文本+无工具+空输出"判定为被吞掉的错误,插入 system 错误消息(chat.ts:3017-3027)。
32
+ - 重连/切回后恢复合并锚点:`resolveResumedAssistantState()` 用消息上的 `finishReason`/`runMarker` 判断"最后一条 assistant 是否是还在跑的 run",是则继续往它身上追加 delta(chat.ts:356-403)。
33
+
34
+ **设计规则:**
35
+ 1. delta 合并用"目标消息 id 指针"而非"总是找最后一条",防止并发工具/子代理事件把 delta 追加到错误消息。
36
+ 2. 直接 mutate 消息对象属性(`last.content = next`)而不是整条替换,配合 Pinia 的深 reactive 减少 diff 成本;但队列消息等跨组件共享态用整 Map 替换触发响应(chat.ts:1912-1917)。
37
+ 3. 终态事件必须"幂等收尾":`toolCallId` 找不到对应消息就忽略 completed 事件(chat.ts:2881-2893),completed 早于 started 到达(重连回放场景)也能靠 `resume` 快照补齐。
38
+ 4. 永远准备"没有 delta 流"的降级路径:`run.completed` 里的最终文本兜底渲染,保证 UI 不出现"成功但空白"。
39
+ 5. 乐观更新:发送瞬间本地 push user 消息 + `serverWorking.add(sid)`(chat.ts:2331-2337),失败时靠 `run.failed`/错误注入回滚视觉。回调全部捕获发送时的 `sid`,绝不用"当前激活会话"(chat.ts:2289 注释)——这是多会话流式不串台的关键。
40
+
41
+ ### 1.3 Markdown 增量渲染
42
+
43
+ - 渲染是**全量重渲**:`renderedHtml = computed(() => md.render(repairNestedMarkdownFences(props.content)))`(MarkdownRenderer.vue:146-148),依赖 Vue computed 只在 content 变化时重算,没有做 token 级增量 parser。
44
+ - 重渲前先做**语法修复**:`markdownFenceRepair.ts` 修复流式中途常见的"嵌套 fence 未闭合 / 反引号残缺"(repairNestedMarkdownFences,markdownFenceRepair.ts:183),让半截代码块不至于把后面的正文全部染成代码。
45
+ - `<think>` 标签类思考由 `thinking-parser.ts` 在渲染前剥离:解析时先用占位符保护 fenced/inline code(避免把代码示例里的 `<think>` 当真标签,thinking-parser.ts:19-33),未闭合的开标签在 `streaming:true` 时归入 `pending`(即正在思考),不进正文(thinking-parser.ts:70-80)。
46
+ - 重活后置:mermaid 图表在 `watch(renderedHtml, …, { flush: 'post' })` 后异步渲染(MarkdownRenderer.vue:371-373),并用 `renderGeneration` 计数丢弃过期渲染结果,防止流式重渲期间图表互相覆盖。
47
+ - html:false + linkify + breaks 的安全基线(MarkdownRenderer.vue:75-80);本地文件路径在 HTML 字符串阶段改写为下载 URL / 文件卡片 / 内嵌播放器(MarkdownRenderer.vue:174-235)。
48
+
49
+ **设计规则:**
50
+ 1. 增量流式 ≠ 增量渲染:全量 `md.render` + 防抖来自 SSE 帧合并本身;真正需要优化的是"未闭合语法",用一个 fence/think 修复层解决,比接 streaming markdown 库便宜得多。
51
+ 2. 思考/代码块等"内联协议"要在 parse 层处理,且处理时必须先屏蔽 code 区域——模型输出里复述标签是常态。
52
+ 3. 异步二次渲染(图表、高亮)要有 generation token + unmount 标志,防止旧 Promise 写新 DOM。
53
+ 4. 渲染 HTML 后处理(标题加 id 供大纲锚点、链接改写)集中在一个组件里做字符串替换,保持 md-it 插件链简单。
54
+
55
+ ### 1.4 滚动跟随(stick to bottom)
56
+
57
+ `VirtualMessageList.vue` 的状态机 + `MessageList.vue` 的触发器:
58
+
59
+ - 核心状态:`userDetachedFromBottom`(用户是否主动上翻)、`keepBottomUntil`(时间窗)、`programmaticScrollUntil`(区分程序滚动与用户滚动)(VirtualMessageList.vue:72-80)。
60
+ - 判定:`isNearBottom(threshold)` 用 `scrollHeight - scrollTop - clientHeight < threshold`;`handleScroll` 中"向上位移 >1px 且非程序滚动"→ 置 detached 并取消跟底;回到底部 32px 内 → 自动 re-attach(VirtualMessageList.vue:114-127、146-154)。wheel 事件额外提前打断(VirtualMessageList.vue:129-134)。
61
+ - 跟底是**多帧循环**:`scheduleScrollToBottom(frames)` 每帧重设 `scrollTop = scrollHeight - clientHeight`,因为流式内容在下一帧才变高;带 30 次失败上限防止死循环(VirtualMessageList.vue:178-205)。
62
+ - 触发分级(MessageList.vue:400-445):用户发新 run → 强制跟底 `frames:3, keepAliveMs:400`;流式内容 watch(最后一条消息 content 变化)→ 仅 `shouldAutoFollowBottom()` 为真时 `frames:1, keepAliveMs:0`;初始加载 → `frames:8, keepAliveMs:1200`。
63
+ - 离开底部时显示"回到底部"悬浮按钮,阈值 1000px(MessageList.vue:283-285)。
64
+ - 会话级滚动位置记忆:模块级 `Map<sessionId, {scrollTop, scrollHeight, wasNearBottom}>`,切走保存、切回恢复;`wasNearBottom` 则直接跟底(MessageList.vue:14、296-330)。
65
+ - 顶部翻页防跳动:`handleTopReach` 先 `captureScrollPosition()`,prepend 旧消息后按 `scrollHeight 增量` 还原 scrollTop(MessageList.vue:333-342;VirtualMessageList.vue:352-370)。
66
+ - 锚点定位(点击大纲/搜索结果跳转)用 token + 最多 8-10 帧的重复对齐,等待异步内容(代码高亮、mermaid)改变高度(VirtualMessageList.vue:267-330)。
67
+
68
+ **设计规则:**
69
+ 1. "跟底"必须区分程序滚动与用户滚动:所有程序 scrollTop 赋值前打时间戳(`markProgrammaticScroll`),scroll handler 里据此过滤回声。
70
+ 2. 单一布尔 `userDetachedFromBottom` + 近底 re-attach,比纯距离阈值可靠:用户上翻 1px 即脱离,滚回底部即重连。
71
+ 3. 跟底滚动是 rAF 循环不是单次 nextTick——流式期间内容高度逐帧增长,一次性 scrollTo 会落后。
72
+ 4. 不同事件用不同"力度"(帧数 + keepAlive 时长):用户主动行为强跟、delta 追加弱跟、初始加载超长跟。
73
+ 5. 列表增删(尤其头部 prepend)永远配合 scrollHeight 差值快照还原,否则翻页视觉抖动。
74
+ 6. 每条消息 DOM 上稳定 `id="message-{id}"`,锚点跳转先 `getElementById`、找不到再退化为 index 计算(VirtualMessageList.vue:207-216)。
75
+
76
+ ### 1.5 思考块 / 工具调用的区分展示
77
+
78
+ - 数据模型:思考文本挂在自己的 assistant 消息上(`message.reasoning`,两条来源:DB 历史字段或 `reasoning.delta`/`thinking.delta` 累加,chat.ts:74-79);工具调用是**独立 role:'tool' 消息**(有 `toolCallId`/`toolPreview`/`toolStatus` running|done|error/`toolDuration`),不是嵌在 assistant 里的 part。
79
+ - 思考块 UI(MessageItem.vue:1056-1090):可折叠 header(💭 + "思考中"/标签 + 时长 + 字符数),body 复用 MarkdownRenderer。展开策略三级:流式进行中强制展开 → 用户点击 override → 全局设置 `display.show_reasoning`(MessageItem.vue:252-266)。时长来自 store 里的 transient observation(`noteReasoningStart/End`,切会话即清,不入 DB,chat.ts:3894 附近注释)。
80
+ - 兜底兼容:若模型把思考写在 content 的 `<think>` 标签里而非独立事件,`parsedThinking` 同样能提取并与 `reasoning` 字段拼接展示(MessageItem.vue:227-243)。
81
+ - 工具调用双位置展示:a) 行内:会话流里的 tool 消息渲染成单行"› 工具名 + preview + spinner/error 徽标",可展开看 args/result 代码块(MessageItem.vue:897-994);b) 悬浮活动条:run 进行中在列表底部显示"本轮工具栈"(abort/compression 状态也塞进同一面板),工具行含 preview 截断 160 字 + 时长格式化(MessageList.vue:533-700)。
82
+ - 显示去重:正在悬浮面板里展示的工具行,从消息流里隐藏(`displayMessages` 过滤 `currentToolIds`,MessageList.vue:127-133);全局开关 `useToolTraceVisibility`(localStorage 持久化)可整体隐藏 tool 消息(useToolTraceVisibility.ts:1-34)。
83
+ - `currentToolCalls` 只取"最近一条 user/command 输入之后"的 tool 消息、最新在上(MessageList.vue:77-92)——工具栈天然按"轮"分组,不需要额外 runId 索引。
84
+
85
+ **设计规则:**
86
+ 1. 思考与正文是**同一消息的两个字段**,而不是两条消息:折叠、时长、字符数都锚定该 assistant 消息;`message.delta` 一到即视为思考结束。
87
+ 2. 工具是平级消息(role:'tool'),靠 `toolCallId` upsert 合并 started/completed——比嵌套 parts 更简单,虚拟列表项高度也稳定。
88
+ 3. "流式中的过程信息"放底部悬浮面板,结束后沉降回消息流(过滤反转):过程可见、历史可回放、同一信息不同时出现两份。
89
+ 4. 未知上游行为要兜底:`reasoning.available` 的 payload 被当作"结束信号"而非内容源(上游会塞正文前 500 字,chat.ts:2790-2800 注释);无 delta 只有 available 时不显示时长。
90
+ 5. 时长/计时器等展示态用轻量 setInterval + watchEffect 自管理,组件卸载必清理(MessageItem.vue:270-293)。
91
+
92
+ ---
93
+
94
+ ## 2. 会话列表 / 切换
95
+
96
+ ### 2.1 状态管理
97
+
98
+ - 单 store(`useChatStore`,4000 行)持有:`sessions: Session[]`、`activeSessionId`、`focusMessageId`、每会话运行态用 `Map/Set` 侧表(`serverWorking`、`queueLengths`、`queuedUserMessages`、`completedUnreadSessions`、`pendingApprovals`、`compressionStates`,chat.ts:693-742)。会话对象本身不带 running 标志——"是否在跑"是服务端报告的事实,不污染实体。
99
+ - 激活会话靠**对象引用**:`activeSession = sessions.value.find(...)` 缓存引用,流式 delta 直接 mutate 该引用(chat.ts:1053-1059 的 CRITICAL 注释)。
100
+
101
+ **设计规则:**
102
+ 1. 列表刷新必须**就地合并**而非整表替换:`refreshSessionListOnly()` 把新元数据写回既有 Session 对象,否则 `activeSession` 引用变孤儿、流式立即"断流"(chat.ts:1061 起)。`loadSessions()` 全量替换时同样先快照旧 messages/contextTokens 再回填(chat.ts:1001-1021)。
103
+ 2. 跨会话侧表(Set/Map)集中放 store,UI 组件只查 `isSessionLive(sid)` 之类的 API;删除/归档会话时统一 prune。
104
+ 3. 后台列表同步用 12s 轻量轮询 + `document.visibilityState` 和 `isStreaming` 双门闸(chat.ts:3887-3892)——流式期间绝不刷列表。
105
+ 4. 未读语义自定义:"完成时用户不在看该会话"才记 `completedUnreadSessions`,切回即清(chat.ts:820-845)。
106
+
107
+ ### 2.2 加载历史与切换
108
+
109
+ - `switchSession(sid, focusId?)`:先写 localStorage(记住最后活跃会话)→ `isLoadingMessages=true` → 通过 **Socket.IO `resume`** 而不是 REST 拉消息(chat.ts:1224-1245):一次往返同时拿到消息、是否在工作中、队列快照、abort/compression 状态、token 计数、fork 血缘、以及运行中事件的回放(`data.events` 逐条重放进 reducer,chat.ts:1307-1400)。带 15s 超时。
110
+ - 分页:`LIVE_CHAT_MESSAGE_PAGE_SIZE=150`、`LIVE_CHAT_MAX_LOADED_MESSAGES=300`(chat.ts:21-22)。顶部触达 `topReach` → `loadOlderMessages()` 按 offset 取更早页,按 id 去重后 prepend(chat.ts:1413-1443)。超过 300 条封顶后,UI 显示"去历史页看更早内容"链接而不是继续加载(MessageList.vue:210-232、237-249)。
111
+ - 路由是真源:ChatView 监听 `routeSessionId`,store 里目标会话不存在时重新 `loadSessions(preferredSessionId)`;非法路由 id 回退 replace 到无参聊天路由(ChatView.vue:38-75)。切换 URL 与 `switchSession` 双向:列表点击 → `router.push` + 必要时 switch(ChatPanel.vue:205-212)。
112
+
113
+ **设计规则:**
114
+ 1. "切换会话"与"重新接上正在跑的 run"是同一条通道(resume 快照 + 事件回放),避免 REST 历史与 WS 实时流两套合并逻辑打架。
115
+ 2. resume 回放的 events 只用于**状态重建**(compression/approval/tool 卡片),文本 delta 不重放——历史 messages 里已含全文;客户端对回放事件仍走同一个 reducer。
116
+ 3. 聊天页消息量硬封顶(~300 条),老内容卸载到独立历史页:实时页 DOM 小、滚动简单、无需虚拟化也能流畅。
117
+ 4. prepend 页必须 id 去重(服务端分页与 resume 快照可能重叠,chat.ts:1428-1430)。
118
+ 5. 空标题会话用第一条 user 消息前 40 字生成占位标题(chat.ts:1295-1301),等服务端 `session.title.updated` 事件再原地替换。
119
+
120
+ ### 2.3 删除 / 归档 / 批量
121
+
122
+ - `deleteSession`:先 API 成功再从列表移除;若删的是激活会话,切到剩余第一个,否则新建空会话(chat.ts:1510-1524)。
123
+ - `archiveSession` 同构,但非激活会话删除后顺带 `refreshSessionListOnly()` 校准(chat.ts:1526-1546)。
124
+ - 批量删除:ChatPanel 维护 `isBatchMode + selectedSessionKeys: Set`,`batchDeleteSessions` 返回 `{deleted, failed}`,partial 成功要 warning 而不是 success(ChatPanel.vue:69-72、785-825)。
125
+ - UI 层:删除入口是 hover 才显示的图标 + NPopconfirm 二次确认(SessionListItem.vue:184-191、219-224),移动端常显(media query,SessionListItem.vue:337 附近)。
126
+
127
+ **设计规则:**
128
+ 1. 删除流程 = 服务端权威 → 本地乐观移除 → 激活会话善后(自动切邻居),永不出现"删了当前会话却空白页"。
129
+ 2. 批量操作反馈区分 deleted/failed 计数,部分失败降级为 warning。
130
+ 3. 所有会话级操作参数带上 `session.profile`(多 profile 是服务端 API 的隐藏维度,chat.ts:1511)。
131
+
132
+ ---
133
+
134
+ ## 3. WS / 事件桥接层
135
+
136
+ ### 3.1 连接拓扑(`api/hermes/chat.ts`)
137
+
138
+ - 模块级**单例 socket** per (profile, transport):`connectChatRun()` 若已连接且 profile/transport 匹配直接复用;不匹配则 removeAllListeners + disconnect 重建,杜绝重复监听(chat.ts:658-676)。`io(url, { transports:['websocket','polling'], reconnection:true, reconnectionAttempts:Infinity, reconnectionDelay:1000, reconnectionDelayMax:30000, randomizationFactor:0.5 })`(chat.ts:695-706)。
139
+ - 两层监听:全局监听器(每种事件一个 `global*Handler`,只注册一次,chat.ts:708-755)→ 按 `event.session_id` 查 `sessionEventHandlers: Map<sid, handlers>` 分发(chat.ts:549-585)。业务 handler 由 `startRunViaSocket` / `resumeServerWorkingRun` 注册,run 结束即 unregister。
140
+ - 服务端对偶(`packages/server/src/services/hermes/run-chat/index.ts`):客户端 `resume` 时 `socket.join('session:{sid}')`,事件默认向整个 session room 广播(多标签页同步),room 空时回退直发当前 socket(index.ts:981-988)。
141
+ - 每会话服务端环形缓冲:`state.events.push(...)`,超过 200 裁头(index.ts:853-854);`resumeEvents = state.isWorking ? state.events : 只保留 run.reattach_failed`(index.ts:511-513)——空闲会话不回放旧事件,防止重复渲染。
142
+
143
+ ### 3.2 断线重连与追赶
144
+
145
+ - 客户端把 disconnect 原因分类:`TRANSIENT_DISCONNECT_REASONS = {transport close, transport error, ping timeout, …}`(chat.ts:140+)。瞬时断开只置 `sawTransientDisconnect` 标记**不**报错;`connect` 事件回来后发 `resume`,并挂一次性 `resumed` 监听把快照交给 `onReconnectResume` 回调(chat.ts:846-900)。真正的业务错误只走 `run.failed` / 非瞬断 / connect_error(非重试期间)。
146
+ - `applyReconnectResume`(store,chat.ts:2480-2530):用快照修正 `serverWorking`、队列、abort 状态,然后 `resumeServerWorkingRun()` 重建 handler + 用 `resolveResumedAssistantState`(runMarker/finishReason)找回"该往哪条消息续 delta"。
147
+ - 服务端-引擎侧也有游标追赶:Node ↔ Python agent bridge 之间是 HTTP 轮询(100ms),带 `cursor`/`event_cursor` 增量拉取(handle-bridge-run.ts:750、788-789、799-805);resume 时先 snapshot 未消费的 `missingOutput` 再设游标,保证断点不漏字。reattach 失败发 `run.reattach_failed` 事件(带去重:同 error 不重复 push,index.ts:598-610)。
148
+
149
+ ### 3.3 缓冲 / 去重 / 顺序
150
+
151
+ - **无客户端 seq 号**。事件本身带 `session_id`,所有 handler 校验 `data.session_id === sid` 且 `activeSessionId === sid` 才应用(chat.ts:1240-1242 resume 回调、851 reconnect 回调),乱序/跨会话事件被丢弃。
152
+ - 幂等靠**语义键 upsert**:工具消息按 `toolCallId` 查找 existing 再 update(chat.ts:2843-2846、2881-2885);分页 prepend 按消息 id 集合去重(chat.ts:1428);队列消息按 `queue_id` 记录"已出队"集合防 peer 消息重复入列(`dequeuedQueueIds`,chat.ts:711-712)。
153
+ - 事件→消息的合并在 store reducer 一处完成(单一 switch 同时服务 live 流和 resume 回放流),桥接层不做二次加工。
154
+
155
+ **设计规则:**
156
+ 1. Socket 层保持"哑管道":全局监听一次、按 session_id 路由到 handler map;业务状态全在 store。换 WS 库成本被隔离在 `api/hermes/chat.ts`。
157
+ 2. 断线恢复的正确姿势不是缓存 delta,而是**服务端持权威缓冲 + 客户端重连后拉快照对账**(resume payload),UI 端只需一个 `applySnapshot`。缓冲设上限(200 事件)且仅运行中回放。
158
+ 3. 区分瞬断与终断:对瞬断容忍(socket.io 自动重连 + resume),对终断(服务器主动 leave、auth 失败)才向 UI 抛错,避免网络抖动误报"连接失败"。
159
+ 4. 去重不靠全局 seq,靠 per-entity 幂等键(toolCallId / message id / queue_id / approval_id)——事件流是多类实体交织的,seq 只解决顺序解决不了语义重复。
160
+ 5. `queue_remaining > 0` 时跳过终态清理(不 close run handle、不卸载 handler),排队 run 复用同一连接与会话 handler(chat.ts:309-327、api 层 onRunCompleted 等)。
161
+ 6. 若底层引擎是轮询(bridge),也要做 cursor 增量 + snapshot-then-cursor 的追赶协议,与 WS 层 resume 快照同构。
162
+
163
+ ---
164
+
165
+ ## 4. 移动端 / 窄屏适配
166
+
167
+ 单一断点 `$breakpoint-mobile: 768px`(styles/variables.scss:178),CSS media query 与 JS `matchMedia('(max-width: 768px)')` 双轨并用。
168
+
169
+ - **JS 侧响应**:ChatPanel 维护 `isMobile = ref(false)`,由 `mobileQuery.addEventListener('change')` 更新(ChatPanel.vue:216-229)。面板拖拽宽度、localStorage 持久化在移动端全部禁用(ChatPanel.vue:129-155)。
170
+ - **首帧防闪**:`showSessions` 初始值**同步**读 matchMedia 而不是等 onMounted 翻转(ChatPanel.vue:74-83 有专门注释:异步初始化会导致窄屏首帧会话抽屉盖住聊天区)。移动端下会话列表变成绝对定位 overlay(z-index 120)+ backdrop,点开后选择会话即自动收起(ChatPanel.vue:212、2226-2260 样式)。
171
+ - **工具面板全屏化**:移动端右侧 files/terminal 面板改为覆盖整个内容区(`position:absolute; inset:0; width:100%`),resize handle 隐藏(ChatPanel.vue:2825-2843);大纲面板跳转后自动关闭(ChatPanel.vue:102-105)。
172
+ - **视口高度**:不用 `100vh`,用 `--vh` 变量:默认 `1vh`,`@supports (height: 100dvh)` 时改 `1dvh`,根容器 `height: calc(100 * var(--vh))`(styles/global.scss:68-74;App.vue:109;ChatView.vue:84-88)——解决移动浏览器地址栏伸缩导致的 100vh 溢出。
173
+ - **iOS 输入缩放**:≤768px 时所有 input/textarea 强制 `font-size: 16px !important`,防止聚焦自动放大页面(styles/global.scss:285-293)。
174
+ - **刘海/安全区**:header、浮动元素用 `calc(16px + env(safe-area-inset-top))` / `env(safe-area-inset-bottom)`(ChatPanel.vue:2650、global.scss:276、TerminalPanel.vue:917、VoiceTranscriptOverlay.vue:136)。
175
+ - **消息区**:窄屏下 user/assistant/system 消息体 `max-width: 100%`,diff 卡片改 `calc(100vw - 24px)`(MessageItem.vue:2154-2179);代码块、markdown 容器各自有窄屏 media 块(MarkdownRenderer.vue:835)。
176
+ - **输入框**:ChatInput 用 `isMobileChatInputViewport(window.innerWidth)` 监听 resize,移动端忽略用户自定义高度(不 clamp),并禁用 hover tooltip(`NTooltip :disabled="isMobileViewport"`)——触屏上 hover 提示只会碍事(utils/chat-input-height.ts:14-16;ChatInput.vue:87、1050 附近多处)。
177
+ - **侧边栏**:AppSidebar 窄屏默认收起(AppSidebar.vue:61),全局 hamburger 按钮 + backdrop 类在 global.scss 统一提供。
178
+
179
+ **设计规则:**
180
+ 1. 断点值只定义一次(SCSS 变量),JS 侧硬编码 768 与之对齐;判断逻辑收敛到少量 composable/util(chat-input-height.ts),组件不各自猜断点。
181
+ 2. 凡是影响布局的媒体查询状态,初始值必须同步求得(首帧正确),异步 onMounted 翻转=闪烁 bug。
182
+ 3. 窄屏不是"缩小桌面"而是**改换交互形态**:并排栏 → overlay + 完成即自动关闭;拖宽 → 禁拖全屏。
183
+ 4. 触屏差异要逐点处理:禁用 hover-only affordance(tooltip、hover 才显示的删除按钮改为常显)。
184
+ 5. 移动端 web 三件套必修:dvh 视口、16px 输入防缩放、safe-area inset。
185
+
186
+ ---
187
+
188
+ ## 附:值得移植 vs 不必照搬
189
+
190
+ - **值得移植**:resume 快照 + 事件回放的会话切换协议;`userDetachedFromBottom` 跟底状态机与多帧 rAF;fence/think 修复层;"tool 平级消息 + toolCallId upsert + 底部活动条"三件套;服务端小环形事件缓冲;12s 列表轮询的门闸条件。
191
+ - **不必照搬**:4000 行单 store(按 sessions/stream/reducer 拆分);模板里字符串 replace 生成 HTML(Agentus 若用 React 可直接组件化);`session_id` 无 seq 的方案在 ACP 多后端下建议升级为 per-run runMarker(他们已有雏形 `runMarker`/`finishReason`,值得正式化)。