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 +8 -3
- package/README.md +5 -5
- package/README.zh-CN.md +5 -5
- package/bin/agentus.mjs +3 -3
- package/docs/dev/01-dev-loop.md +2 -2
- package/docs/dev/03-release.md +5 -5
- package/docs/dev/04-recover.md +14 -8
- package/docs/dev/README.md +24 -1
- package/docs/installation.md +8 -8
- package/docs/installation.zh-CN.md +8 -8
- package/docs/transcript-folding.md +1 -1
- package/package.json +1 -1
- package/packages/server/src/acp/session-manager.ts +51 -3
- package/packages/server/src/index.ts +47 -2
- package/packages/server/src/notify/center.ts +15 -0
- package/packages/server/src/pty-helper.py +73 -15
- package/packages/server/src/term.ts +46 -2
- package/packages/web/dist/assets/index-CCvfL56d.css +32 -0
- package/packages/web/dist/assets/index-DPCbsWh9.js +283 -0
- package/packages/web/dist/index.html +2 -2
- package/packages/web/dist/assets/index-C5XlgEcj.js +0 -283
- package/packages/web/dist/assets/index-D667PX2c.css +0 -32
package/NOTICE
CHANGED
|
@@ -1,9 +1,14 @@
|
|
|
1
1
|
Agentus
|
|
2
2
|
Copyright 2026 Agentus contributors
|
|
3
3
|
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
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:
|
|
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` | `
|
|
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>:
|
|
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 `:
|
|
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` (
|
|
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:
|
|
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` | `
|
|
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>:
|
|
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
|
-
把它指向 `:
|
|
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` (
|
|
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 :
|
|
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
|
|
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 ?? "
|
|
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
|
}
|
package/docs/dev/01-dev-loop.md
CHANGED
|
@@ -11,7 +11,7 @@ instances:
|
|
|
11
11
|
| | live | dev |
|
|
12
12
|
|---|---|---|
|
|
13
13
|
| path | `worktrees/agentus` | `worktrees/agentus-dev` |
|
|
14
|
-
| HTTP port | `
|
|
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=
|
|
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`.
|
package/docs/dev/03-release.md
CHANGED
|
@@ -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
|
|
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:
|
|
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:
|
|
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:
|
|
156
|
-
curl -s --noproxy '*' "http://127.0.0.1:
|
|
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`
|
package/docs/dev/04-recover.md
CHANGED
|
@@ -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
|
-
| `
|
|
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:
|
|
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, `:
|
|
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:
|
|
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
|
|
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:
|
|
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:
|
|
159
|
-
curl -s -X DELETE -H "Authorization: Bearer $TOK" http://127.0.0.1:
|
|
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
|
package/docs/dev/README.md
CHANGED
|
@@ -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 | `
|
|
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
|
package/docs/installation.md
CHANGED
|
@@ -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
|
|
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:
|
|
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` | `
|
|
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` (
|
|
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>
|
|
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
|
|
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:
|
|
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 `:
|
|
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,默认
|
|
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:
|
|
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` | `
|
|
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`(
|
|
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>
|
|
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
|
|
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:
|
|
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 的 `:
|
|
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:
|
|
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.
|
|
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 ??
|
|
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
|
-
|
|
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
|
-
|
|
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 ??
|
|
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
|
|
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
|
};
|