agentus 0.3.0 → 0.3.2

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/NOTICE CHANGED
@@ -1,9 +1,14 @@
1
1
  Agentus
2
2
  Copyright 2026 Agentus contributors
3
3
 
4
- This product includes code derived from AionUi (https://github.com/iOfficeAI/AionUi),
5
- licensed under the Apache License 2.0. Derivations are marked with SPDX file headers
6
- where applicable.
4
+ Licensed under the Apache License, Version 2.0 — see LICENSE. Third-party components
5
+ keep their own licences.
6
+
7
+ AionUi (https://github.com/iOfficeAI/AionUi) is credited as an INSPIRATION. Its
8
+ published design notes and the behaviour of its interfaces were studied while Agentus
9
+ was designed, and some of our choices follow them. No AionUi source code was copied:
10
+ every implementation in this repository is Agentus's own, and no file carries an
11
+ AionUi licence header because none needs one.
7
12
 
8
13
  The Hermes and Qoder brand marks under packages/web/public/coding-agents/ are the
9
14
  respective property of their owners (EKKOLearnAI / Alibaba). They are included
package/README.md CHANGED
@@ -24,7 +24,7 @@ agents: open several sessions in the browser, each one backed by a real
24
24
  ```bash
25
25
  # public one-liner — the published package (Node >= 22.5; the store is node:sqlite)
26
26
  npx agentus # …or: npm i -g agentus, then `agentus`
27
- # open http://localhost:8787 and log in (admin / 123456 — see "Login" below),
27
+ # open http://localhost:8788 and log in (admin / 123456 — see "Login" below),
28
28
  # then "+ new session", pick a backend + a working directory
29
29
  ```
30
30
 
@@ -102,7 +102,7 @@ layer never changes.
102
102
 
103
103
  | var | default | meaning |
104
104
  |---|---|---|
105
- | `AGENTUS_PORT` | `8787` | server port (never reads bare `PORT` — that name is polluted on shared hosts) |
105
+ | `AGENTUS_PORT` | `8788` | server port (never reads bare `PORT` — that name is polluted on shared hosts) |
106
106
  | `AGENTUS_DATA` | `~/.agentus` | where state lives; see "Where your state lives" for the full order (`<repo>/packages/server/.data` on a checkout that already has one) |
107
107
  | `AGENTUS_HERMES_CMD` | `hermes` | binary to spawn for the hermes backend |
108
108
  | `AGENTUS_QODER_CMD` | `~/.local/bin/qodercli` | ditto for qoder |
@@ -196,7 +196,7 @@ Auth environment:
196
196
  ### Exposing it publicly (tunnel / reverse proxy)
197
197
 
198
198
  Put `AGENTUS_BASIC_AUTH=:pass` in the server's environment, then point the tunnel at
199
- `<lan-ip>:8787`. That gives you two independent locks — Basic at the edge of the app, the
199
+ `<lan-ip>:8788`. That gives you two independent locks — Basic at the edge of the app, the
200
200
  operator login inside it — and scripts can still get in (`curl -u :pass` plus the machine
201
201
  token).
202
202
 
@@ -231,7 +231,7 @@ Two things to know about a tunnel in front of this app:
231
231
  #### TLS: a second listener, on purpose
232
232
 
233
233
  A plain TCP tunnel (SakuraFrp, safe-nat, `ssh -L`, most frp setups) forwards bytes — it
234
- does **not** terminate TLS for you. Point one at `:8787` and the operator's password and
234
+ does **not** terminate TLS for you. Point one at `:8788` and the operator's password and
235
235
  session cookie cross the internet in clear text. So the server can speak TLS itself:
236
236
 
237
237
  ```bash
@@ -242,7 +242,7 @@ scripts/make-cert.sh # self-signed, SAN = the name you actual
242
242
 
243
243
  | port | speaks | for |
244
244
  |---|---|---|
245
- | `AGENTUS_PORT` (8787) | plain HTTP | LAN, loopback, `curl`, scripts — no cert warning, CI unchanged |
245
+ | `AGENTUS_PORT` (8788) | plain HTTP | LAN, loopback, `curl`, scripts — no cert warning, CI unchanged |
246
246
  | `AGENTUS_TLS_PORT` (8443) | HTTPS (self-signed) | **the tunnel** — encrypts the public hop |
247
247
 
248
248
  Same handler, same routes, same auth; only the socket differs. Two listeners beat both
package/README.zh-CN.md CHANGED
@@ -22,7 +22,7 @@
22
22
  ```bash
23
23
  # 对外一条命令 —— 用 npm 上发布的包(Node >= 22.5,存储用 node:sqlite)
24
24
  npx agentus # 或者 npm i -g agentus 然后直接跑 `agentus`
25
- # 打开 http://localhost:8787,用 admin / 123456 登录(见下面「登录」),
25
+ # 打开 http://localhost:8788,用 admin / 123456 登录(见下面「登录」),
26
26
  # 然后「+ 新建会话」,选一个后端和一个工作目录
27
27
  ```
28
28
 
@@ -93,7 +93,7 @@ npm run agentus # the same CLI as `npx agentus`, straight from the
93
93
 
94
94
  | 变量 | 默认值 | 含义 |
95
95
  |---|---|---|
96
- | `AGENTUS_PORT` | `8787` | 服务端口(从不读取裸 `PORT` —— 这个名字在共享主机上被污染) |
96
+ | `AGENTUS_PORT` | `8788` | 服务端口(从不读取裸 `PORT` —— 这个名字在共享主机上被污染) |
97
97
  | `AGENTUS_DATA` | `~/.agentus` | 状态所在;完整顺序见「状态放在哪」(已有数据的老 checkout 上是 `<repo>/packages/server/.data`) |
98
98
  | `AGENTUS_HERMES_CMD` | `hermes` | hermes 后端要拉起的二进制 |
99
99
  | `AGENTUS_QODER_CMD` | `~/.local/bin/qodercli` | qoder 同上 |
@@ -177,7 +177,7 @@ cookie 用 `HttpOnly` 而不是 `localStorage` 里的 token,并且它会顺带
177
177
 
178
178
  ### 公网暴露(隧道 / 反向代理)
179
179
 
180
- 把 `AGENTUS_BASIC_AUTH=:pass` 放进服务端环境,然后把隧道指向 `<lan-ip>:8787`。这样你就有了两把独立
180
+ 把 `AGENTUS_BASIC_AUTH=:pass` 放进服务端环境,然后把隧道指向 `<lan-ip>:8788`。这样你就有了两把独立
181
181
  的锁 —— 边缘的 Basic,里面的运维者登录 —— 脚本依然能进(`curl -u :pass` 加上机器 token)。
182
182
 
183
183
  这里推荐只用密码的形式(`:pass`,或裸 `pass`):HTTP Basic 总是会让浏览器问用户名(RFC 7617 传
@@ -205,7 +205,7 @@ cookie 用 `HttpOnly` 而不是 `localStorage` 里的 token,并且它会顺带
205
205
  #### TLS:刻意做成第二个监听器
206
206
 
207
207
  纯 TCP 隧道(SakuraFrp、safe-nat、`ssh -L`、大多数 frp 配置)只转发字节 —— 它**不会**替你终结 TLS。
208
- 把它指向 `:8787`,运维者的密码和 session cookie 就会以明文穿过互联网。所以服务端可以自己讲 TLS:
208
+ 把它指向 `:8788`,运维者的密码和 session cookie 就会以明文穿过互联网。所以服务端可以自己讲 TLS:
209
209
 
210
210
  ```bash
211
211
  scripts/make-cert.sh # self-signed, SAN = the name you actually type
@@ -215,7 +215,7 @@ scripts/make-cert.sh # self-signed, SAN = the name you actual
215
215
 
216
216
  | 端口 | 讲什么 | 给谁 |
217
217
  |---|---|---|
218
- | `AGENTUS_PORT` (8787) | 纯 HTTP | LAN、loopback、`curl`、脚本 —— 无证书警告,CI 不变 |
218
+ | `AGENTUS_PORT` (8788) | 纯 HTTP | LAN、loopback、`curl`、脚本 —— 无证书警告,CI 不变 |
219
219
  | `AGENTUS_TLS_PORT` (8443) | HTTPS(自签名) | **隧道** —— 加密公网那一跳 |
220
220
 
221
221
  同一套 handler,同一套路由,同一套认证;只有 socket 不同。两个监听器胜过另外两种做法:把单端口变成
package/bin/agentus.mjs CHANGED
@@ -2,7 +2,7 @@
2
2
  /**
3
3
  * `agentus` — the command an npm install gives you.
4
4
  *
5
- * npx agentus # start the cockpit on :8787
5
+ * npx agentus # start the cockpit on :8788
6
6
  * npx agentus --port 9000 # …somewhere else
7
7
  * npx agentus --data ~/work # …with its state in a directory you chose
8
8
  * npx agentus --where # print the resolved paths and exit (nothing starts)
@@ -36,7 +36,7 @@ Usage
36
36
  agentus [options]
37
37
 
38
38
  Options
39
- -p, --port <n> port to listen on (AGENTUS_PORT, default 8787)
39
+ -p, --port <n> port to listen on (AGENTUS_PORT, default 8788)
40
40
  -d, --data <dir> where state lives — sqlite, login, TLS material, settings
41
41
  (AGENTUS_DATA, default ~/.agentus)
42
42
  -o, --open open the cockpit in your browser once it is up
@@ -115,7 +115,7 @@ if (opts.port !== undefined) env.AGENTUS_PORT = String(opts.port);
115
115
  if (opts.data !== undefined) env.AGENTUS_DATA = opts.data;
116
116
  if (opts.where) env.AGENTUS_PRINT_PATHS = "1";
117
117
 
118
- const port = env.AGENTUS_PORT ?? "8787";
118
+ const port = env.AGENTUS_PORT ?? "8788";
119
119
  if (!opts.where) {
120
120
  console.log(`[agentus] starting — open http://localhost:${port} when it is up (Ctrl-C stops it)`);
121
121
  }
@@ -11,7 +11,7 @@ instances:
11
11
  | | live | dev |
12
12
  |---|---|---|
13
13
  | path | `worktrees/agentus` | `worktrees/agentus-dev` |
14
- | HTTP port | `8787` | `8901` |
14
+ | HTTP port | `8788` | `8901` |
15
15
  | TLS port | `8443` | disabled (`AGENTUS_TLS_PORT=0`) |
16
16
  | data dir | `<tree>/packages/server/.data` | `/tmp/agentus-qa-account` |
17
17
  | started by | `~/.hermes/cache/agentus/launch.py` | `bash scripts/dev.sh start` |
@@ -85,7 +85,7 @@ which one won and why** — a directory change is the thing that would otherwise
85
85
  For the worktrees, the outcome is fixed by the launchers rather than by the rules:
86
86
 
87
87
  - **live** — `~/.hermes/cache/agentus/launch.py` pins `AGENTUS_DATA=<live>/packages/server/.data`
88
- and `AGENTUS_PORT=8787`, and pops the inherited QA/DEV keys. The launcher owns its
88
+ and `AGENTUS_PORT=8788`, and pops the inherited QA/DEV keys. The launcher owns its
89
89
  identity; it never trusts `os.environ`.
90
90
  - **dev** — `scripts/dev.sh` exports `AGENTUS_DATA=/tmp/agentus-qa-account` and
91
91
  `AGENTUS_PORT=8901`.
@@ -75,7 +75,7 @@ If `<slug>` is omitted, the branch name is used with `/` → `-`. What it does:
75
75
  | start shape | `scripts/start.sh`, `package.json` |
76
76
  | stale launcher | the live process is still running under `tsx watch` |
77
77
 
78
- 5. If a restart is needed, calls `scripts/relaunch.sh --port 8787 --tree <live>
78
+ 5. If a restart is needed, calls `scripts/relaunch.sh --port 8788 --tree <live>
79
79
  --launcher <live launcher>`, which TERMs the process group, waits for the port to free,
80
80
  starts the launcher, polls `/healthz`, and **asserts the pid changed** (a failure exits
81
81
  non-zero, so "built fine, still serving old code" cannot pass).
@@ -92,7 +92,7 @@ are all required:
92
92
  - `no server-side change` printed,
93
93
  - the **asset hash moved** (`web asset: <before> -> <after>`; `promote` warns if web sources
94
94
  changed but the hash did not),
95
- - the **pid did NOT move** (`lsof -ti:8787` unchanged — a moved pid means it took the
95
+ - the **pid did NOT move** (`lsof -ti:8788` unchanged — a moved pid means it took the
96
96
  restart branch and the release was not the kind it claimed to be).
97
97
 
98
98
  `package.json` is a restart trigger, so **any** edit to it — even one new npm alias for a QA
@@ -145,15 +145,15 @@ curl -s --noproxy '*' -H "Authorization: Bearer $TOK" http://127.0.0.1:8901/api/
145
145
  hash. `promote` echoes it; you can also read it directly:
146
146
 
147
147
  ```bash
148
- curl -s --noproxy '*' http://127.0.0.1:8787/api/version
148
+ curl -s --noproxy '*' http://127.0.0.1:8788/api/version
149
149
  ```
150
150
 
151
151
  **3. Grep the served bundle for the new markers.** Fetch the real vite-named file
152
152
  (`assets/index-<hash>.js`) and look for a string only the new code has:
153
153
 
154
154
  ```bash
155
- HASH=$(curl -s --noproxy '*' http://127.0.0.1:8787/api/version | ...)
156
- curl -s --noproxy '*' "http://127.0.0.1:8787/assets/$HASH.js" | grep -c '<new-marker>'
155
+ HASH=$(curl -s --noproxy '*' http://127.0.0.1:8788/api/version | ...)
156
+ curl -s --noproxy '*' "http://127.0.0.1:8788/assets/$HASH.js" | grep -c '<new-marker>'
157
157
  ```
158
158
 
159
159
  Two traps: the file is `index-<hash>.js`, so a check that fetches `/assets/<hash>.js`
@@ -23,13 +23,13 @@ empty. Triage the identity, not the liveness (see "Is it serving the wrong store
23
23
 
24
24
  | port | who |
25
25
  |---|---|
26
- | `8787` | live HTTP |
26
+ | `8788` | live HTTP |
27
27
  | `8443` | live TLS (public tunnel points here) |
28
28
  | `8901` | dev / QA instance |
29
29
  | `8648` | **Hermes Studio** — not Agentus, do not touch |
30
30
 
31
31
  ```bash
32
- lsof -nP -iTCP:8787 -sTCP:LISTEN # who owns the port (returns the CHILD, not the supervisor)
32
+ lsof -nP -iTCP:8788 -sTCP:LISTEN # who owns the port (returns the CHILD, not the supervisor)
33
33
  ps -o pid,ppid,pgid,command -p <pid> # its process group and command line
34
34
  ```
35
35
 
@@ -82,7 +82,7 @@ file at that same path, and its server was the only way into the machine. Rules:
82
82
  `spawn(..., { detached: true })` and kill the process group — rather than cleaning up by
83
83
  name afterwards.
84
84
  5. **Re-check the neighbours afterwards:** one `curl` per resident service
85
- (`:8648` Studio, `:8787`/`:8443` live, `:8901` dev). A cleanup that leaves the box worse
85
+ (`:8648` Studio, `:8788`/`:8443` live, `:8901` dev). A cleanup that leaves the box worse
86
86
  than it found it is the whole failure mode.
87
87
 
88
88
  ## The SQLite store, WAL, and the `*.bak` files
@@ -127,7 +127,7 @@ running slot was reaped — yet the port answers 200 and `/api/version` looks fi
127
127
  `AGENTUS_DATA`/`AGENTUS_PORT` and pops the QA/DEV keys, but verify identity directly:
128
128
 
129
129
  ```bash
130
- PID=$(lsof -nP -iTCP:8787 -sTCP:LISTEN -t)
130
+ PID=$(lsof -nP -iTCP:8788 -sTCP:LISTEN -t)
131
131
  ps eww -p "$PID" | tr ' ' '\n' | grep AGENTUS # the process's own identity vars
132
132
  lsof -p "$PID" | grep sqlite # which store it actually opened
133
133
  ```
@@ -137,11 +137,17 @@ Recovery:
137
137
  ```bash
138
138
  cd worktrees/agentus
139
139
  env -u AGENTUS_DATA -u AGENTUS_BASE -u DEV_LOG_PATH -u DEV_PROMOTE_DETACHED -u DEV_PROMOTE_SELF \
140
- bash scripts/relaunch.sh --port 8787 --launcher ~/.hermes/cache/agentus/launch.py
140
+ bash scripts/relaunch.sh --port 8788 --launcher ~/.hermes/cache/agentus/launch.py \
141
+ --launchd ai.hermes.agentus
141
142
  TOK=$(cat packages/server/.data/auth.token)
142
- curl -s -X POST -H "Authorization: Bearer $TOK" http://127.0.0.1:8787/api/sessions/<id>/resume
143
+ curl -s -X POST -H "Authorization: Bearer $TOK" http://127.0.0.1:8788/api/sessions/<id>/resume
143
144
  ```
144
145
 
146
+ `--launchd <label>` makes relaunch.sh hand the restart to launchd (`kickstart -k`) rather than
147
+ killing the process by hand — once the job is installed, a hand-kill is not a restart, it is a
148
+ respawn race (launchd brings the old process back while our own launcher starts a second one). Pass
149
+ it whenever the label is loaded; relaunch.sh ignores it when it is not.
150
+
145
151
  After any restart, put the operator back on the air: `POST /api/sessions/<id>/resume` with the
146
152
  machine token → `status: ready` and a fresh pid. **Capture the slot ids to resume before the
147
153
  restart** — in the window right after a restart a slot has dropped from `live` into
@@ -155,8 +161,8 @@ one-click problem. Endpoints behind the login:
155
161
 
156
162
  ```bash
157
163
  TOK=$(cat <DATA_DIR>/auth.token)
158
- curl -s -H "Authorization: Bearer $TOK" http://127.0.0.1:8787/api/auth/locked-ips
159
- curl -s -X DELETE -H "Authorization: Bearer $TOK" http://127.0.0.1:8787/api/auth/locked-ips
164
+ curl -s -H "Authorization: Bearer $TOK" http://127.0.0.1:8788/api/auth/locked-ips
165
+ curl -s -X DELETE -H "Authorization: Bearer $TOK" http://127.0.0.1:8788/api/auth/locked-ips
160
166
  ```
161
167
 
162
168
  In the UI it is Settings → 登录失败锁定 (which IPs are locked, for how long, unlock / unlock
@@ -20,12 +20,35 @@ variables, and they must never touch each other's code or data:
20
20
  |---|---|---|
21
21
  | tree | `worktrees/agentus` | `worktrees/agentus-dev` |
22
22
  | role | the instance the operator actually uses | where changes are made and tested |
23
- | HTTP port | `8787` | `8901` |
23
+ | HTTP port | `8788` | `8901` |
24
24
  | TLS port | `8443` | off (`AGENTUS_TLS_PORT=0`) |
25
25
  | data dir | `<tree>/packages/server/.data` (pinned by the launcher) | `/tmp/agentus-qa-account` |
26
26
  | launcher | `~/.hermes/cache/agentus/launch.py` | `scripts/dev.sh start\|stop\|status\|logs` |
27
+ | supervision | launchd job `ai.hermes.agentus` (`KeepAlive`) | none — a plain detached process |
27
28
  | hot reload | **no** — restart is explicit | yes (`tsx watch`) |
28
29
 
30
+ **Ports are configuration, not literals.** Both of live's ports live in
31
+ `~/.hermes/cache/agentus/ports.env`; `launch.py` pins them into the server's environment, and
32
+ `scripts/dev.sh` / `scripts/relaunch.sh` read the same file instead of hardcoding a number. Move
33
+ live by editing that one line and restarting it — never by hunting a literal through the launchers,
34
+ the scripts and the docs (that is how the port and the TLS port both drifted before).
35
+
36
+ **Live is supervised by launchd.** It used to be a detached child of whatever shell happened to
37
+ start it: nothing brought it back after a crash, and it inherited that shell's entire environment
38
+ (usually an agent session's, handing `HERMES_SESSION_ID` and `WEIXIN_TOKEN` down to every ACP child
39
+ it spawned). Now:
40
+
41
+ ```bash
42
+ launchctl print gui/$(id -u)/ai.hermes.agentus # state, pid, the job's environment
43
+ launchctl kickstart -k gui/$(id -u)/ai.hermes.agentus # the supported restart (stop then start)
44
+ tail -f /tmp/agentus-server.log # the job's stdout/stderr
45
+ ```
46
+
47
+ `scripts/relaunch.sh --launchd ai.hermes.agentus` is the same thing wrapped with a health check and
48
+ a pid-moved assertion, and that is what `promote` uses. Launchd's `KeepAlive` restarts it on any
49
+ exit, so a crash self-heals; a **manual** `kill` therefore no longer restarts live, it just makes
50
+ launchd respawn it.
51
+
29
52
  **Why two trees.** `tsx watch` reloads the whole server on any server-source edit, and a
30
53
  reload SIGTERMs every child process — every live `hermes acp` session, including the one
31
54
  the operator is typing into. Editing server code in the live tree would therefore kill his
@@ -91,7 +91,7 @@ serves once (no watcher). `AGENTUS_PORT=9000 npm start` moves the port.
91
91
  ```
92
92
  agentus [options]
93
93
 
94
- -p, --port <n> port to listen on (AGENTUS_PORT, default 8787)
94
+ -p, --port <n> port to listen on (AGENTUS_PORT, default 8788)
95
95
  -d, --data <dir> where state lives (sqlite, login, TLS material, settings)
96
96
  (AGENTUS_DATA, default ~/.agentus)
97
97
  -o, --open open the cockpit in your browser once it is up
@@ -107,7 +107,7 @@ answer to "where did my data go" (see below).
107
107
  ## First run
108
108
 
109
109
  1. Start it: `agentus` (or `npx agentus`, or `npm start` from a checkout).
110
- 2. Open **http://localhost:8787** and log in. Default credentials:
110
+ 2. Open **http://localhost:8788** and log in. Default credentials:
111
111
 
112
112
  ```
113
113
  username: admin
@@ -130,7 +130,7 @@ unless the backend row names another one — see [HERMES_HOME](#hermes_home).
130
130
 
131
131
  | var | default | meaning |
132
132
  |---|---|---|
133
- | `AGENTUS_PORT` | `8787` | server port (never reads bare `PORT`) |
133
+ | `AGENTUS_PORT` | `8788` | server port (never reads bare `PORT`) |
134
134
  | `AGENTUS_DATA` | `~/.agentus` | where state lives; see [Data directory](#data-directory) |
135
135
  | `AGENTUS_HERMES_CMD` | `hermes` | binary to spawn for the hermes backend |
136
136
  | `AGENTUS_QODER_CMD` | `~/.local/bin/qodercli` | ditto for qoder |
@@ -189,7 +189,7 @@ with it.
189
189
 
190
190
  | port | speaks | for |
191
191
  |---|---|---|
192
- | `AGENTUS_PORT` (8787) | plain HTTP | LAN, loopback, `curl`, scripts — no cert warning, CI unchanged |
192
+ | `AGENTUS_PORT` (8788) | plain HTTP | LAN, loopback, `curl`, scripts — no cert warning, CI unchanged |
193
193
  | `AGENTUS_TLS_PORT` (8443) | HTTPS (self-signed) | the tunnel — encrypts the public hop |
194
194
  | 5173 | Vite dev server | front-end hot reload, only during `npm run dev` |
195
195
 
@@ -268,7 +268,7 @@ a different session list.
268
268
  <key>Label</key> <string>org.agentus.cockpit</string>
269
269
  <key>ProgramArguments</key>
270
270
  <array>
271
- <string>/usr/bin/env</string><string>agentus</string><string>--port</string><string>8787</string>
271
+ <string>/usr/bin/env</string><string>agentus</string><string>--port</string><string>8788</string>
272
272
  </array>
273
273
  <key>EnvironmentVariables</key>
274
274
  <dict><key>AGENTUS_DATA</key><string>/Users/YOU/.agentus</string></dict>
@@ -293,7 +293,7 @@ Description=Agentus cockpit
293
293
  After=network.target
294
294
 
295
295
  [Service]
296
- ExecStart=/usr/bin/env agentus --port 8787
296
+ ExecStart=/usr/bin/env agentus --port 8788
297
297
  Environment=AGENTUS_DATA=%h/.agentus
298
298
  Restart=on-failure
299
299
 
@@ -335,7 +335,7 @@ touches `~/.hermes` (your agents' own data) or any working directory.
335
335
  ## Troubleshooting
336
336
 
337
337
  **Port already in use.** `agentus --port 9000`, or find and stop the holder:
338
- `lsof -nP -iTCP:8787 -sTCP:LISTEN`. Agentus never reads bare `PORT` for exactly this reason.
338
+ `lsof -nP -iTCP:8788 -sTCP:LISTEN`. Agentus never reads bare `PORT` for exactly this reason.
339
339
 
340
340
  **"No backend" / a session won't start.** The agent CLI is missing or not logged in. Run
341
341
  `hermes acp --check`, or `qodercli login` (a `-32000` on `newSession` means qoder is not logged
@@ -362,5 +362,5 @@ requests. The live stream rides on `/ws`; use a tunnel that passes WebSockets th
362
362
  TCP+auto-HTTPS tunnels are verified working).
363
363
 
364
364
  **Certificate warning on the LAN.** That is the TLS listener's self-signed cert (a bare LAN IP
365
- cannot carry a trusted cert). Either use the plain-HTTP `:8787` port on the LAN, or install the
365
+ cannot carry a trusted cert). Either use the plain-HTTP `:8788` port on the LAN, or install the
366
366
  issuer once from `/cert.crt` (see [TLS](#tls)).
@@ -88,7 +88,7 @@ npm run agentus # 与 `npx agentus` 同一个 CLI,直接
88
88
  ```
89
89
  agentus [options]
90
90
 
91
- -p, --port <n> 监听端口 (AGENTUS_PORT,默认 8787)
91
+ -p, --port <n> 监听端口 (AGENTUS_PORT,默认 8788)
92
92
  -d, --data <dir> 状态目录(sqlite、登录、TLS 材料、设置)
93
93
  (AGENTUS_DATA,默认 ~/.agentus)
94
94
  -o, --open 就绪后自动在浏览器打开驾驶舱
@@ -103,7 +103,7 @@ agentus [options]
103
103
  ## 首次运行
104
104
 
105
105
  1. 启动:`agentus`(或 `npx agentus`,或 checkout 里的 `npm start`)。
106
- 2. 打开 **http://localhost:8787** 登录。默认凭据:
106
+ 2. 打开 **http://localhost:8788** 登录。默认凭据:
107
107
 
108
108
  ```
109
109
  username: admin
@@ -125,7 +125,7 @@ agentus [options]
125
125
 
126
126
  | 变量 | 默认值 | 含义 |
127
127
  |---|---|---|
128
- | `AGENTUS_PORT` | `8787` | 服务端口(从不读裸 `PORT`) |
128
+ | `AGENTUS_PORT` | `8788` | 服务端口(从不读裸 `PORT`) |
129
129
  | `AGENTUS_DATA` | `~/.agentus` | 状态目录;见[数据目录](#数据目录) |
130
130
  | `AGENTUS_HERMES_CMD` | `hermes` | hermes 后端要拉起的可执行文件 |
131
131
  | `AGENTUS_QODER_CMD` | `~/.local/bin/qodercli` | qoder 后端同理 |
@@ -181,7 +181,7 @@ agentus [options]
181
181
 
182
182
  | 端口 | 协议 | 用途 |
183
183
  |---|---|---|
184
- | `AGENTUS_PORT`(8787) | 纯 HTTP | 局域网、本机回环、`curl`、脚本 —— 无证书警告,CI 不变 |
184
+ | `AGENTUS_PORT`(8788) | 纯 HTTP | 局域网、本机回环、`curl`、脚本 —— 无证书警告,CI 不变 |
185
185
  | `AGENTUS_TLS_PORT`(8443) | HTTPS(自签) | **隧道** —— 加密公网那一跳 |
186
186
  | 5173 | Vite 开发服务器 | 前端热更新,仅在 `npm run dev` 期间 |
187
187
 
@@ -257,7 +257,7 @@ Agentus 默认在前台跑;一个服务单元能让它跨登录、跨重启常
257
257
  <key>Label</key> <string>org.agentus.cockpit</string>
258
258
  <key>ProgramArguments</key>
259
259
  <array>
260
- <string>/usr/bin/env</string><string>agentus</string><string>--port</string><string>8787</string>
260
+ <string>/usr/bin/env</string><string>agentus</string><string>--port</string><string>8788</string>
261
261
  </array>
262
262
  <key>EnvironmentVariables</key>
263
263
  <dict><key>AGENTUS_DATA</key><string>/Users/YOU/.agentus</string></dict>
@@ -282,7 +282,7 @@ Description=Agentus cockpit
282
282
  After=network.target
283
283
 
284
284
  [Service]
285
- ExecStart=/usr/bin/env agentus --port 8787
285
+ ExecStart=/usr/bin/env agentus --port 8788
286
286
  Environment=AGENTUS_DATA=%h/.agentus
287
287
  Restart=on-failure
288
288
 
@@ -323,7 +323,7 @@ rm -rf ~/.agentus # 删状态 —— SQLite、登录、TLS 密
323
323
  ## 排错
324
324
 
325
325
  **端口被占用。** 用 `agentus --port 9000`,或找出并停掉占用者:
326
- `lsof -nP -iTCP:8787 -sTCP:LISTEN`。Agentus 从不读裸 `PORT`,原因正在于此。
326
+ `lsof -nP -iTCP:8788 -sTCP:LISTEN`。Agentus 从不读裸 `PORT`,原因正在于此。
327
327
 
328
328
  **「没有后端」/ 会话起不来。** 缺少 agent CLI,或它没登录。跑 `hermes acp --check`,或
329
329
  `qodercli login`(`newSession` 报 `-32000` 就是 qoder 没登录)。在设置里检查后端行的命令;启动
@@ -347,4 +347,4 @@ rm -rf ~/.agentus # 删状态 —— SQLite、登录、TLS 密
347
347
  `/ws`;请换成能透传 WebSocket 的隧道(SakuraFrp 的 TCP+auto-HTTPS 隧道已验证可用)。
348
348
 
349
349
  **局域网里出现证书警告。** 那是 TLS 监听的自签证书(裸局域网 IP 无法承载可信证书)。要么在
350
- 局域网里用纯 HTTP 的 `:8787` 端口,要么从 `/cert.crt` 装一次签发证书(见 [TLS](#tls))。
350
+ 局域网里用纯 HTTP 的 `:8788` 端口,要么从 `/cert.crt` 装一次签发证书(见 [TLS](#tls))。
@@ -131,7 +131,7 @@ And against real data — the one-off used when this landed, on the operator's o
131
131
  ```bash
132
132
  # rows → the CLIENT's fold, over the operator API
133
133
  curl -s -H "Authorization: Bearer $(cat "${AGENTUS_DATA:-$HOME/.agentus}/auth.token")" \
134
- 'http://127.0.0.1:8787/api/sessions/<id>/messages?tail=1'
134
+ 'http://127.0.0.1:8788/api/sessions/<id>/messages?tail=1'
135
135
  ```
136
136
 
137
137
  Result for `hello`: **3804 rows → 70** (10 agent messages, 15 thought blocks, 37 tool cards, 8 prompts),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agentus",
3
- "version": "0.3.0",
3
+ "version": "0.3.2",
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": {
@@ -159,6 +159,12 @@ interface LiveSession {
159
159
  * so a second interrupting utterance that already started its own turn is not mistaken for
160
160
  * "the one I cancelled is still running". */
161
161
  turnSeq: number;
162
+ /** When the operator last USED this slot — he opened it, or his page reported it as the one on
163
+ * screen. In memory on purpose: this is the half of the idle rule that says "he was just here",
164
+ * and the store's message clock (which survives restarts) is the other half. Reading only the
165
+ * message clock reaped a slot he had clicked into a minute earlier (see `touch()` and the clock
166
+ * in `startIdleReaper`). */
167
+ touchedAt: number;
162
168
  /** Text sinks for sessions that live on this connection but are NOT the one in the rail —
163
169
  * today only the throwaway fork used to summarise the conversation for a title. Anything
164
170
  * whose sessionId is not `info.acpSessionId` is routed here and never persisted. */
@@ -367,6 +373,10 @@ export class SessionManager {
367
373
  child, busy: false, pendingPermissions: new Map(), alwaysAllow: new Set(), stderrBuf: [],
368
374
  caps: { fork: false, load: false }, turnSeq: 0, collectors: new Map(), planReminderPending: true,
369
375
  planToken: randomUUID(),
376
+ // A brand-new process is, by definition, freshly used: the click that spawned it is the
377
+ // activity. Without this the slot inherits the store's message clock — yesterday's — and the
378
+ // reaper's first tick after the spawn kills the process the operator just asked for.
379
+ touchedAt: Date.now(),
370
380
  // Until this slot's first PROMPT, anything the agent says is its own history being replayed,
371
381
  // not an answer (see #onSessionUpdate). A resumed agent re-sends its whole transcript on
372
382
  // `session/load` — full-length blocks, no messageId — and persisting those wrote 1240 rows
@@ -515,6 +525,10 @@ export class SessionManager {
515
525
  child, busy: false, pendingPermissions: new Map(), alwaysAllow: new Set(), stderrBuf: [],
516
526
  caps: { fork: false, load: false }, turnSeq: 0, collectors: new Map(), planReminderPending: true,
517
527
  planToken: randomUUID(),
528
+ // A brand-new process is, by definition, freshly used: the click that spawned it is the
529
+ // activity. Without this the slot inherits the store's message clock — yesterday's — and the
530
+ // reaper's first tick after the spawn kills the process the operator just asked for.
531
+ touchedAt: Date.now(),
518
532
  // Until this slot's first PROMPT, anything the agent says is its own history being replayed,
519
533
  // not an answer (see #onSessionUpdate). A resumed agent re-sends its whole transcript on
520
534
  // `session/load` — full-length blocks, no messageId — and persisting those wrote 1240 rows
@@ -1007,7 +1021,7 @@ export class SessionManager {
1007
1021
  command: process.execPath,
1008
1022
  args: [path.join(path.dirname(fileURLToPath(import.meta.url)), "../../mcp/plan-server.mjs")],
1009
1023
  env: [
1010
- { name: "AGENTUS_PLAN_ENDPOINT", value: `http://127.0.0.1:${process.env.AGENTUS_PORT ?? 8787}` },
1024
+ { name: "AGENTUS_PLAN_ENDPOINT", value: `http://127.0.0.1:${process.env.AGENTUS_PORT ?? 8788}` },
1011
1025
  { name: "AGENTUS_PLAN_TOKEN", value: live.planToken },
1012
1026
  { name: "AGENTUS_PLAN_SESSION", value: live.info.id },
1013
1027
  ],
@@ -1246,6 +1260,22 @@ export class SessionManager {
1246
1260
  this.#sessions.delete(live.info.id);
1247
1261
  }
1248
1262
 
1263
+ /** The operator just USED this slot: he opened it, or his page reported it as the one on screen.
1264
+ *
1265
+ * Idempotent and cheap — the cockpit reports presence every few seconds, so while a slot is on
1266
+ * screen this is called continuously and the idle clock never runs out under a conversation he
1267
+ * is reading; the moment he switches away it stops, and the slot gets the full threshold from
1268
+ * the last report instead of from its last MESSAGE.
1269
+ *
1270
+ * Only the in-memory clock moves. `info.lastAt` stays the store's message time on purpose: it is
1271
+ * what the rail's relative stamps and the "last activity" hint mean, and making it say 刚刚 for a
1272
+ * session nobody has written to would be the UI lying. */
1273
+ touch(id: string): void {
1274
+ const live = this.#sessions.get(id);
1275
+ if (!live || live.info.status === "closed" || live.info.status === "reaped") return;
1276
+ live.touchedAt = Date.now();
1277
+ }
1278
+
1249
1279
  /** Reclaim idle slots: stop the agent process of a session nobody has touched for the operator's
1250
1280
  * threshold. Deliberately NOT an archive — the row stays in 工作空间 wearing `reaped` and a click
1251
1281
  * brings it back. AionUi ships exactly this switch (设置 → 系统 → 「Agent 空闲超时(分钟)」,
@@ -1266,7 +1296,13 @@ export class SessionManager {
1266
1296
  if (s.busy || s.pendingPermissions.size) continue;
1267
1297
  if (s.info.status === "starting" || s.info.status === "running") continue;
1268
1298
  if (opts.watched?.(s.info.id)) continue;
1269
- const lastAt = s.info.lastAt ?? s.info.createdAt;
1299
+ // The clock is the LATER of two things: the last MESSAGE this session has (store time, which
1300
+ // survives restarts) and the last time the operator actually USED the slot (`touchedAt` — the
1301
+ // click that respawned it, or the page reporting it as the one on screen). Reading only the
1302
+ // message clock is how a slot he had just opened got reaped a minute later (2026-10-10:
1303
+ // 「刚点进去、没发消息、换个会话过一分钟 acp 就被回收」) — the fresh process inherited a
1304
+ // days-old stamp, so the very next tick found it over the threshold.
1305
+ const lastAt = Math.max(s.touchedAt, s.info.lastAt ?? 0, s.info.createdAt);
1270
1306
  if (now - lastAt < idleMs) continue;
1271
1307
  console.log(
1272
1308
  `[agentus] idle reap: ${s.info.id} (${Math.round((now - lastAt) / 60_000)} min idle, limit ${Math.round(idleMs / 60_000)} min)`,
@@ -1287,7 +1323,19 @@ export class SessionManager {
1287
1323
  /** The list every client needs to be current: the rows PLUS what is already waiting for an
1288
1324
  * answer. Sent on connect too, which is what makes a refreshed page show a live approval. */
1289
1325
  #emitSessions(): void {
1290
- this.#emit({ t: "sessions", sessions: this.list(), pending: this.pendingPermissions() });
1326
+ // ALL THREE buckets, every time. This is the SAME event the HTTP path builds (`sessionsEvent()`
1327
+ // in index.ts), and the client replaces its lists wholesale from whatever arrives — so emitting
1328
+ // only `sessions` here meant every call site below (a resume, a close, an archive, and the idle
1329
+ // reaper) WIPED the operator's cold and archived rows off the rail until the next full event or
1330
+ // a reload: 「刷新之后只看得见活着的会话」. `cold()`/`archived()` are this class's own lists —
1331
+ // sending them costs nothing and is the only shape the client can safely consume.
1332
+ this.#emit({
1333
+ t: "sessions",
1334
+ sessions: this.list(),
1335
+ cold: this.cold(),
1336
+ archived: this.archived(),
1337
+ pending: this.pendingPermissions(),
1338
+ });
1291
1339
  }
1292
1340
 
1293
1341
  /** Every request currently waiting for an answer, across live sessions. Exists so a page
@@ -25,7 +25,7 @@ import * as auth from "./auth.js";
25
25
  import { NotifyCenter } from "./notify/center.js";
26
26
  import type { BackendId, ClientCommand, PermissionDecision, PromptAttachment, ServerEvent } from "../../shared/src/index.js";
27
27
 
28
- const PORT = Number(process.env.AGENTUS_PORT ?? 8787);
28
+ const PORT = Number(process.env.AGENTUS_PORT ?? 8788);
29
29
  // Data + web build resolve against THIS FILE, not the shell's cwd: `scripts/start.sh`
30
30
  // launches from the repo root while the dev server runs from packages/server, and a
31
31
  // cwd-relative path would silently open a second database (the operator's slots would
@@ -196,6 +196,10 @@ const notify = new NotifyCenter({
196
196
  operator: (req, url) => Boolean(auth.authenticate(req.headers, url)),
197
197
  // The companion artifact, so the phone panel in 设置 can show its size without a second request.
198
198
  apkPath: APK_FILE,
199
+ // The page reporting which session it is showing is also the idle reaper's clock: opening a slot
200
+ // (and staying in it) counts as USED, so switching away gives it a full threshold instead of
201
+ // reaping it on the next tick because its last MESSAGE was days ago.
202
+ onFocus: (sessionId: string) => mgr.touch(sessionId),
199
203
  // Username+password pairing: the phone types the same login the browser uses. The check and
200
204
  // its rate limit stay here, in auth.ts, so a phone cannot have a laxer door than the UI.
201
205
  credentials: (username, password, ip) => {
@@ -1385,9 +1389,42 @@ termWss.on("connection", (ws: WebSocket, _req: IncomingMessage, url: URL) => {
1385
1389
  return;
1386
1390
  }
1387
1391
  const session = term;
1392
+ // ---- backpressure, half two: OUR OWN socket queue ---------------------------
1393
+ // The page's watermarks (see ToolPanel's TerminalTab) can only count bytes that REACH it;
1394
+ // everything still queued in this process is invisible from there. A slow page plus a fast
1395
+ // producer therefore grows node's send queue without bound — measured on a `yes` flood: ~340 MB
1396
+ // queued in seconds while the page only ever received ~106 frames/s. So the socket's own backlog
1397
+ // is an independent brake, OR-ed with the client's request into one pause state, and the chain it
1398
+ // walks back is the same one: stop reading the child -> the pty helper blocks in write(1) -> the
1399
+ // shell blocks in write(2).
1400
+ const NET_HIGH = 1 << 20; // 1 MiB queued -> stop draining the pty
1401
+ const NET_LOW = 256 * 1024;
1402
+ let clientPaused = false;
1403
+ let socketPaused = false;
1404
+ let paused = false;
1405
+ const applyPause = (): void => {
1406
+ const want = clientPaused || socketPaused;
1407
+ if (want === paused) return;
1408
+ paused = want;
1409
+ if (want) session.pause();
1410
+ else session.resume();
1411
+ };
1388
1412
  const send = (msg: unknown): void => {
1389
- if (ws.readyState === WebSocket.OPEN) ws.send(JSON.stringify(msg));
1413
+ if (ws.readyState !== WebSocket.OPEN) return;
1414
+ ws.send(JSON.stringify(msg));
1415
+ if (!socketPaused && ws.bufferedAmount >= NET_HIGH) {
1416
+ socketPaused = true;
1417
+ applyPause();
1418
+ }
1390
1419
  };
1420
+ // `ws` emits no drain event for its send queue, so the way back up is a cheap poll that only
1421
+ // runs while we are actually holding the pty back.
1422
+ const backlogTimer = setInterval(() => {
1423
+ if (socketPaused && ws.bufferedAmount <= NET_LOW) {
1424
+ socketPaused = false;
1425
+ applyPause();
1426
+ }
1427
+ }, 100);
1391
1428
  // The buffered tail goes out first, so a reconnect (or a second tab) lands on the scrollback the
1392
1429
  // way ttyd's RESUME does instead of a blank screen.
1393
1430
  if (session.buffer) send({ t: "term-data", id: session.id, data: session.buffer });
@@ -1410,12 +1447,20 @@ termWss.on("connection", (ws: WebSocket, _req: IncomingMessage, url: URL) => {
1410
1447
  }
1411
1448
  } else if (cmd.t === "term-resize" && typeof cmd.cols === "number" && typeof cmd.rows === "number") {
1412
1449
  session.resize(cmd.cols, cmd.rows);
1450
+ } else if (cmd.t === "term-pause") {
1451
+ // The page's emulator fell behind: hold the pty so the backlog cannot grow there instead.
1452
+ clientPaused = true;
1453
+ applyPause();
1454
+ } else if (cmd.t === "term-resume") {
1455
+ clientPaused = false;
1456
+ applyPause();
1413
1457
  } else if (cmd.t === "term-close") {
1414
1458
  terms.kill(session.id);
1415
1459
  ws.close();
1416
1460
  }
1417
1461
  });
1418
1462
  const cleanup = (): void => {
1463
+ clearInterval(backlogTimer);
1419
1464
  session.subscribers.delete(onData);
1420
1465
  terms.kill(session.id);
1421
1466
  };