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
package/README.md CHANGED
@@ -1,3 +1,477 @@
1
- # Temporary Holding Version
1
+ # Agentus
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ [English](README.md) | [简体中文](README.zh-CN.md)
4
+
5
+ **Keep your agents on the track.** A multi-session web cockpit for ACP-speaking coding
6
+ agents: open several sessions in the browser, each one backed by a real
7
+ `hermes acp` / `qodercli --acp` subprocess, and drive them from any device.
8
+
9
+ > **Red line:** the UI holds *zero* intelligence. No agent loop, no model calls, no
10
+ > prompt engineering. The server does exactly six things — gate access, spawn a child,
11
+ > relay ACP JSON-RPC, stream events to the browser, persist the transcript, surface
12
+ > permission prompts. Reasoning lives in the CLI processes.
13
+
14
+ ```
15
+ [browser React SPA] ──HTTP + WS──▶ [Agentus server] ──ACP/JSON-RPC over stdio──▶ [hermes acp | qodercli --acp]
16
+ · session registry (who runs, where, pid)
17
+ · ACP client (ClientSideConnection)
18
+ · event fan-out → WS, commands → ACP
19
+ · SQLite transcript + resume
20
+ ```
21
+
22
+ ## Install
23
+
24
+ ```bash
25
+ # public one-liner — the published package (Node >= 22.5; the store is node:sqlite)
26
+ npx agentus # …or: npm i -g agentus, then `agentus`
27
+ # open http://localhost:8787 and log in (admin / 123456 — see "Login" below),
28
+ # then "+ new session", pick a backend + a working directory
29
+ ```
30
+
31
+ Or run the installer straight from the repository — same install, plus a Node check and a
32
+ per-user fallback (`sudo` never):
33
+
34
+ ```bash
35
+ curl -fsSL https://raw.githubusercontent.com/agentus-org/agentus/main/scripts/install.sh | bash
36
+ ```
37
+
38
+ Full guide — install, flags, every environment variable, login, TLS, the phone bridge:
39
+ [`docs/installation.md`](docs/installation.md) · [中文](docs/installation.zh-CN.md).
40
+
41
+ `npx agentus` runs the cockpit in the foreground — Ctrl-C stops it, and it takes its agent
42
+ children with it. Flags: `--port 9000`, `--data <dir>`, `--open` (open the browser once it
43
+ is up), `--where` (print the paths the server actually resolved — data dir, web bundle,
44
+ companion APK — and exit without starting anything).
45
+
46
+ **The one prerequisite npm cannot install for you is an agent CLI**: `hermes acp` (install
47
+ Hermes on your PATH; `hermes acp --check` self-checks) or `qodercli --acp` (`qodercli login`
48
+ first, else `newSession` fails with `-32000`). Without one the cockpit still runs and still
49
+ logs in — you just have no backend to start a session on.
50
+
51
+ The first slot you start will use the Hermes home **your own agent already uses**
52
+ (`~/.hermes`) unless the backend row names another one — see "HERMES_HOME" below.
53
+
54
+ ### From a checkout (development)
55
+
56
+ ```bash
57
+ git clone https://github.com/agentus-org/agentus.git
58
+ cd agentus
59
+ npm start # installs if needed, builds, serves (scripts/start.sh)
60
+ npm run dev -w @agentus/web # front-end hot reload (Vite :5173, host: true → reachable over LAN)
61
+ npm run agentus # the same CLI as `npx agentus`, straight from the checkout
62
+ ```
63
+
64
+ **Working on Agentus itself?** The bootstrapping manual is in
65
+ [`docs/dev/`](docs/dev/README.md): run a dev instance beside live, prove a change with the
66
+ browser sweeps, promote it to live, and what to do when live breaks. Written for a human
67
+ *and* for a coding agent you point at this repo.
68
+
69
+ `npm start` (→ `scripts/start.sh`) checks the Node version, installs dependencies with
70
+ `NODE_ENV=development` when `node_modules` is missing, builds when the web bundle is
71
+ stale, then serves. Use `AGENTUS_PORT=9000 npm start` to move the port.
72
+
73
+ ### Where your state lives
74
+
75
+ `~/.agentus` by default — `agentus.sqlite` (sessions and transcript), `credentials.json`,
76
+ `auth.token` (the machine credential), `auth.secret`, `tls/`, `settings.json`. It is
77
+ created 0700 on first boot, and the boot log prints it **and why that directory won**:
78
+
79
+ | order | rule |
80
+ |---|---|
81
+ | 1 | `AGENTUS_DATA` when it is set — every launcher and dev/QA instance pins this |
82
+ | 2 | `~/.agentus` once it holds something, or when there is no repo-side data yet |
83
+ | 3 | `<repo>/packages/server/.data` — an existing checkout that predates this layout |
84
+
85
+ Rule 3 is not a nicety: an install that quietly switched directories would come up with an
86
+ empty session list, which reads exactly like "my sessions are gone". To move such a
87
+ checkout over, boot it once with `AGENTUS_DATA=~/.agentus`. `agentus --where` prints what
88
+ the server resolved, rather than making you guess.
89
+
90
+ ### Backends
91
+
92
+ | id | command | notes |
93
+ |---|---|---|
94
+ | `hermes` | `hermes acp` | needs a Hermes install on PATH (`hermes acp --check`) |
95
+ | `qoder` | `qodercli --acp` | needs `qodercli login` first, else `newSession` fails `-32000` |
96
+ | `mock` | `node packages/server/mock/agent.mjs` | offline dev/QA: fake streaming, permission prompts, crash/plan/tool triggers |
97
+
98
+ Adding a backend is **one row** in `packages/server/src/acp/backends.ts`. The protocol
99
+ layer never changes.
100
+
101
+ ### Environment
102
+
103
+ | var | default | meaning |
104
+ |---|---|---|
105
+ | `AGENTUS_PORT` | `8787` | server port (never reads bare `PORT` — that name is polluted on shared hosts) |
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
+ | `AGENTUS_HERMES_CMD` | `hermes` | binary to spawn for the hermes backend |
108
+ | `AGENTUS_QODER_CMD` | `~/.local/bin/qodercli` | ditto for qoder |
109
+ | `AGENTUS_HERMES_HOME` | `~/.hermes` | default `HERMES_HOME` for a row that does not name one (the operator's own home) |
110
+ | `AGENTUS_PERM_TIMEOUT_MS` | `300000` (5 min) | how long a permission prompt waits before auto-cancelling |
111
+ | `AGENTUS_HISTORY_PAGE` | `500` | transcript page size (also set small in tests to exercise paging) |
112
+ | `AGENTUS_TERM_PTY` | unset | `1` = run the workspace terminal through Python's stdlib `pty` (real tty; needs `python3`) instead of pipes |
113
+ | `AGENTUS_TERM_CMD` | unset | override the terminal command line entirely (e.g. `socat …`), space-separated |
114
+ | `AGENTUS_TLS_PORT` | `8443` when a cert exists, else off | **second listener, TLS** — the one a public tunnel points at (`0` disables it) |
115
+ | `AGENTUS_TLS_CERT` / `AGENTUS_TLS_KEY` | `<AGENTUS_DATA>/tls/{cert,key}.pem` | cert material for that listener (see `scripts/make-cert.sh`) |
116
+ | `AGENTUS_TTS_BASE_URL` | unset | OpenAI-compatible base for **server** speech synthesis (`…/v1`). Unset = browser voices only |
117
+ | `AGENTUS_TTS_API_KEY` / `AGENTUS_TTS_MODEL` / `AGENTUS_TTS_VOICE` | – / `tts-1` / `alloy` | ditto |
118
+ | `AGENTUS_STT_BASE_URL` | unset | OpenAI-compatible base for **server** transcription. Unset = browser recognition only |
119
+ | `AGENTUS_STT_API_KEY` / `AGENTUS_STT_MODEL` / `AGENTUS_STT_LANGUAGE` | – / `whisper-1` / – | ditto |
120
+ | `DASHSCOPE_API_KEY` / `DASHSCOPE_BASE_URL` | – | 百炼 (DashScope) credentials. Read from the process env **and** from `~/.hermes/.env`, because the server is usually started from a plain shell |
121
+
122
+ The voice/theme variables are a **bootstrap only**: the settings page (⚙ in the rail)
123
+ owns the provider, endpoint, key, models, hotword list and the palette, and stores them
124
+ in `<AGENTUS_DATA>/settings.json` (0600, key never sent back to the browser). What the
125
+ page says wins; the env is what makes a fresh checkout work before anyone opens it.
126
+
127
+ ## Login
128
+
129
+ The cockpit is behind a login by default — this thing spawns processes that write to
130
+ your disk, so an open port is a remote shell waiting to happen.
131
+
132
+ ```
133
+ username: admin
134
+ password: 123456 (default — change it)
135
+ ```
136
+
137
+ Change it **in the app**: Settings → 账号 (the first card) takes a new username and/or a
138
+ new password, applies immediately, and writes `<DATA_DIR>/credentials.json` (0600, scrypt
139
+ hash — no plaintext on disk). Changing the password invalidates every other session; the
140
+ device that made the change is handed a fresh cookie, so it stays signed in. The current
141
+ password is required for any change, including a username-only one.
142
+
143
+ `AGENTUS_USERNAME` / `AGENTUS_PASSWORD` / `AGENTUS_PASSWORD_HASH` still work as the
144
+ bootstrap: they are what the app starts from, and once you save something in the 账号 card
145
+ the file wins from then on (the same "settings file > env" order voice and theme use). A
146
+ password-only change pins today's password as a hash, so an env value can never quietly
147
+ come back into play. Delete `credentials.json` to fall back to the environment.
148
+
149
+ Account management lives in Settings → 账号 (the same shape as hermes-studio's
150
+ AccountSettings, minus the multi-user parts — this cockpit is one operator):
151
+
152
+ | card | what it does |
153
+ |---|---|
154
+ | **账号** | change the username or the password (each in a small modal, current password required) |
155
+ | **登录会话** | every browser and script holding a session: client, IP, last seen, expiry. Revoke one, or log out everywhere else. Changing the password does the same for the others at once. |
156
+ | **登录失败锁定** | the login limiter, visible: which IPs are locked, for how long, with unlock / unlock all — so locking yourself out of your own cockpit is a 1-click problem, not a restart |
157
+
158
+ Sessions are stateless cookies, so the server keeps an index of them at
159
+ `<DATA_DIR>/sessions.json` (0600). It is advisory for display but authoritative for
160
+ existence: a cookie that is not in the index, or that was revoked, is refused — that is what
161
+ makes revocation and "log out everywhere" real, and it survives a restart. An upgrade from
162
+ an older build adopts the cookies that already exist once instead of logging every device
163
+ out.
164
+
165
+ Endpoints behind the login: `POST /api/auth/credentials`, `GET /api/auth/sessions`,
166
+ `POST /api/auth/sessions/revoke`, `POST /api/auth/sessions/revoke-others`,
167
+ `GET|DELETE /api/auth/locked-ips`.
168
+
169
+ Two credentials exist, deliberately:
170
+
171
+ | credential | how it travels | who uses it |
172
+ |---|---|---|
173
+ | **session cookie** | `HttpOnly` + `SameSite=Lax`, HMAC-SHA256 signed, 7d | the browser (issued by `POST /api/auth/login`) |
174
+ | **machine token** | `Authorization: Bearer <t>` or `?token=<t>` | scripts, CI, launchers — read it from `<DATA_DIR>/auth.token` (0600) |
175
+
176
+ The cookie is `HttpOnly` rather than a token in `localStorage`, and it rides along on
177
+ the WebSocket handshake for free. Everything under `/api` is closed to anonymous
178
+ callers; the app shell, its assets, `/healthz` and `/api/auth/*` stay open so the
179
+ login page itself can load. `scripts/*.mjs` read the machine token automatically
180
+ (`scripts/lib/auth.mjs`).
181
+
182
+ Auth environment:
183
+
184
+ | var | default | meaning |
185
+ |---|---|---|
186
+ | `AGENTUS_AUTH` | `on` | `off` = no login at all (local hacking; the boot log will scold you) |
187
+ | `AGENTUS_USERNAME` | `admin` | operator name |
188
+ | `AGENTUS_PASSWORD` | `123456` | operator password (plaintext in env) |
189
+ | `AGENTUS_PASSWORD_HASH` | — | `scrypt:<salt>:<hex>`; wins over `AGENTUS_PASSWORD` if set |
190
+ | `AGENTUS_SESSION_TTL_MS` | 604800000 (7d) | session lifetime |
191
+ | `AGENTUS_AUTH_SECRET` | `<DATA_DIR>/auth.secret` | cookie signing key (0600, auto-minted) |
192
+ | `AGENTUS_AUTH_TOKEN` | `<DATA_DIR>/auth.token` | machine token (0600, auto-minted) |
193
+ | `AGENTUS_LOGIN_MAX_FAILS` / `AGENTUS_LOGIN_LOCK_MS` | `5` / `30000` | per-IP brute-force lockout |
194
+ | `AGENTUS_BASIC_AUTH` | — | an HTTP Basic challenge in front of **everything** (including `/healthz` and the WS upgrade). `user:pass` checks both; `:pass` (or a bare `pass`) checks **only the password** and accepts any username. This is the outer lock you want before exposing a tunnel; see "Exposing it publicly". |
195
+
196
+ ### Exposing it publicly (tunnel / reverse proxy)
197
+
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
200
+ operator login inside it — and scripts can still get in (`curl -u :pass` plus the machine
201
+ token).
202
+
203
+ Password-only (`:pass`, or a bare `pass`) is the recommended form here: HTTP Basic always
204
+ asks the browser for a username (RFC 7617 carries `user:pass`), but a single-operator
205
+ service gains nothing from one — typing any name, or leaving it blank, works. If you do
206
+ configure a username, it is checked too.
207
+
208
+ Pick one outer lock, not both:
209
+
210
+ - **The tunnel's own gate**, if it has one. SakuraFrp's `auth_pass` is an IP-level
211
+ authorisation: the first visit shows a small page asking for the access password, the
212
+ IP is remembered after that, and the WebSocket keeps working through it (both measured
213
+ end to end). Zero config on this side; the cost is that machines cannot get in without
214
+ the extra authorisation dance (`POST /v4/tunnel/auth`).
215
+ - **`AGENTUS_BASIC_AUTH`**, when the tunnel has no gate, is a plain TCP forward, or you
216
+ want scriptable access (`curl -u :pass`). A real 401 challenge is understood by every
217
+ browser, proxy and HTTP client — unlike some tunnel "access auth" flavours, which answer
218
+ HTTP **200** with an authorise-your-IP page and are invisible to `curl`.
219
+
220
+ Turning both on means three password prompts in a row, so pick one.
221
+
222
+ Two things to know about a tunnel in front of this app:
223
+
224
+ - **WebSockets must pass through.** The cockpit's live stream rides on `/ws`; an HTTP/1.1
225
+ tunnel that drops `Upgrade` gives you a page that loads and then says "reconnecting"
226
+ forever. Verified working through SakuraFrp's TCP+auto-HTTPS tunnels.
227
+ - **Terminate TLS at the edge.** When the request arrives with `X-Forwarded-Proto: https`
228
+ the session cookie is issued `Secure` automatically (verified through that same tunnel),
229
+ so no config needed. The last hop inside your LAN stays plain HTTP.
230
+
231
+ #### TLS: a second listener, on purpose
232
+
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
235
+ session cookie cross the internet in clear text. So the server can speak TLS itself:
236
+
237
+ ```bash
238
+ scripts/make-cert.sh # self-signed, SAN = the name you actually type
239
+ # -> $AGENTUS_DATA/tls/{cert.pem,key.pem} (0600, git-ignored) — ~/.agentus/tls by default
240
+ # boot log then says: https://0.0.0.0:8443 (self-signed …) ; point a tunnel at THIS port
241
+ ```
242
+
243
+ | port | speaks | for |
244
+ |---|---|---|
245
+ | `AGENTUS_PORT` (8787) | plain HTTP | LAN, loopback, `curl`, scripts — no cert warning, CI unchanged |
246
+ | `AGENTUS_TLS_PORT` (8443) | HTTPS (self-signed) | **the tunnel** — encrypts the public hop |
247
+
248
+ Same handler, same routes, same auth; only the socket differs. Two listeners beat both
249
+ alternatives: turning the single port into HTTPS would put a cert warning in front of your
250
+ own LAN usage (and break `curl` scripts), while sniffing the first byte on one port to
251
+ serve both hides whether a given visit was really encrypted.
252
+
253
+ **Retiring the warning on your devices.** With TLS on, the listener serves its own
254
+ certificate at `/cert.crt`:
255
+
256
+ ```bash
257
+ https://<your-name>:<tls-port>/cert.crt # iOS: opens the profile installer directly
258
+ # Android: downloads it → Settings → Security → CA certificate
259
+ curl --cacert <AGENTUS_DATA>/tls/cert.pem https://<your-name>:<tls-port>/healthz # scripts, no -k
260
+ ```
261
+
262
+ Installed as a trusted root (iOS also needs *About → Certificate Trust Settings → enable*),
263
+ the browser stops asking and the address bar is clean. It is public material — the private
264
+ key never leaves `<AGENTUS_DATA>/tls/`.
265
+
266
+ The cert is **self-signed on purpose** — no CA issues for an unregistered domain or a bare
267
+ IP, so the browser shows "not private → proceed" once per device, and installing the issuer
268
+ (`/cert.crt`, above) retires even that. Keep the SAN to the name you type (**the DDNS name**,
269
+ not the public IP: a dynamic IP would need re-issuing, and a mismatch adds a second warning).
270
+
271
+ `make-cert.sh` issues **two** certs on purpose: a 10-year root (`ca.pem`, what devices
272
+ install) and a 390-day leaf (`cert.pem`, what the listener serves). Apple caps *server
273
+ certificate* validity at 398 days — a 10-year self-signed leaf is precisely what iOS refuses
274
+ *after* the user has been through the install dance. A root is not a server cert, so it can
275
+ live long; rotating the leaf (`--leaf-only`) never touches an installed device.
276
+
277
+ `npm run tls-smoke` (in CI, real sockets) guards all of it: a verifying client must be
278
+ *rejected* (that is what proves the port is actually TLS), the leaf must chain to the root
279
+ `/cert.crt` hands out and stay under the 398-day cap, the API login and `wss://…/ws` handshake
280
+ must work over TLS (the upgrade handler is bound to both listeners — the easy thing to
281
+ forget), and the LAN port must stay plain.
282
+
283
+ Logout revokes the session id server-side, so "sign out" ends the session instead of
284
+ just hiding the UI — the cookie stops working immediately.
285
+
286
+ ## HERMES_HOME (which data directory a slot reads and writes)
287
+
288
+ A spawned agent CLI inherits the server's environment, so the server **always sets
289
+ `HERMES_HOME` explicitly**: the row's own home if it names one, otherwise the default —
290
+ **your real `~/.hermes`**, the same `state.db` your gateway and Studio have open. That is
291
+ the intended production setting: a slot drives your actual agent, with your config,
292
+ credentials, memory and session list.
293
+
294
+ An *inherited* `HERMES_HOME` is ignored (a Hermes-launched shell leaks its own down), so
295
+ where a slot writes is always something you chose — on the row, or in
296
+ `AGENTUS_HERMES_HOME`.
297
+
298
+ To keep one slot's data separate (a clean session list, a different profile, a throwaway
299
+ experiment), name a directory in that row's HERMES_HOME — no permission needed. The
300
+ early-dev **isolation guard** is gone: it refused the real home outright because Hermes'
301
+ bundled SQLite 3.50.4 (inside the WAL-reset range) corrupted a real `state.db` when two
302
+ writers shared one WAL file (2026-10-02). The runtime now links SQLite 3.53.1, and sharing
303
+ one home is what Hermes itself already does all day (gateway + Studio bridge), so the
304
+ cockpit no longer gets in the way.
305
+
306
+ A row that points at its own home needs no permission — only a name. One trap survives when
307
+ a row points elsewhere: a fresh home whose `hindsight/config.json` keeps the default profile
308
+ name `"hermes"` would attach to your production memory daemon, so the row warns when it would.
309
+
310
+ ## What it does today (M0 → M4)
311
+
312
+ - multi-session rail, **grouped by working directory** — running sessions and **closed
313
+ cases** (transcripts whose process exited;
314
+ click to respawn + `loadSession` resume, re-applying the stored permission mode/effort),
315
+ with search across both by title / backend / cwd
316
+ - **sessions name themselves**: the agent's own title (ACP `session_info_update` — Hermes
317
+ generates one in the turn prologue) is adopted as-is, and a backend that never sends one
318
+ still gets a name derived from the first prompt (first line, markdown stripped, ≤50 chars);
319
+ the menu's **重新生成会话名** asks the agent to name the conversation from its CURRENT
320
+ content, on a throwaway `session/fork`, so the session's own transcript is untouched
321
+ - streaming render of `agent_message_chunk` / `agent_thought_chunk`, tool calls
322
+ (upserted by `toolCallId`, never appended), plan updates, usage. A tool call is one
323
+ compact line — status dot, truncated title, chevron — and shows the full name, kind,
324
+ status, input and output only when expanded (a transcript of twenty calls stays
325
+ readable)
326
+ - **context-window gauge** per session from ACP `usage_update` (warns at 65% / 85%; degrades
327
+ to used-only when the agent reports no window size) and a per-turn trace chip showing the
328
+ effort/mode a turn actually runs with
329
+ - **slash-command palette** driven by the agent's own `available_commands_update`
330
+ (filter, ↑/↓, Tab to accept, Esc) — never an invented command list
331
+ - **fork a session** (ACP `session/fork`, an unstable capability Hermes does offer): the
332
+ agent copies the parent's context into a new session, which comes up here as a session
333
+ of its own — same workspace, same modes, independent from then on
334
+ - permission cards (allow / always allow / reject / dismiss) wired to ACP's
335
+ server→client `requestPermission`, with a pending-count chip and a timeout so a session
336
+ can't wedge
337
+ - permission mode + reasoning-effort switches (`setSessionMode` / `setSessionConfigOption`),
338
+ persisted per session
339
+ - SQLite transcript with monotonic per-session `seq`; reconnect replays only the tail;
340
+ long transcripts **page** backwards ("load earlier") instead of truncating
341
+ - orphan reaping: every child is spawned detached and its pid recorded, so a crashed
342
+ server's leftovers are killed on next boot
343
+ - offline-tolerant: WS reconnect with an outbox (taps while disconnected are queued,
344
+ not swallowed), offline app shell via service worker
345
+ - PWA + phone layout: drawer rail, thumb-sized controls, safe-area padding,
346
+ 16px inputs (no iOS zoom), no horizontal overflow at 390px
347
+ - a head with **two** controls: which directory this session works in, and the workspace
348
+ panel. Everything that was up there moved next to the prompt where it belongs
349
+ - **workspace panel** (head ▤): a read-only file browser (breadcrumb, sizes, text
350
+ preview) and a shell rooted in the session's workspace, both killed with the socket.
351
+ Cwd is a per-session field: a *running* agent keeps the directory it was started in,
352
+ the panels and the next resume follow the one you pick
353
+ - **replies rendered as markdown** (markdown-it + highlight.js, sanitised with DOMPurify
354
+ and `html: false`, so an agent's `<script>` stays visible text): headings, lists,
355
+ tables, quotes, fenced code with a language label, copy button and syntax colouring
356
+ - **the agent's own knobs, placed the way ACP intends**: the model button prints the
357
+ model alone ("qwen3.8-flash"); its list is grouped by provider and collapsible (Hermes
358
+ offers 501 models — a flat list is unusable); thinking depth shows nothing but a small scale
359
+ (seven ticks, filled to the level, coloured grey→red), so a phone-width row still fits
360
+ the send button: a thinking-depth button and a
361
+ model button, each reading the agent's advertised options (`configOptions[].category`,
362
+ falling back to the option id) — and hidden when the agent offers none. Model switching
363
+ uses ACP `session/set_model`, which Hermes implements (the SDK in use does not type it,
364
+ so it goes through the generic request() overload)
365
+ - **the context window is yours to declare**: click the usage line to set the window the
366
+ gauge measures against, per session, persisted. ACP has no method to change a model's
367
+ window (it is the provider's property — that is what `usage_update.size` reports), so
368
+ this number only drives the gauge; switching models is the real lever
369
+ - composer that reads like a chat box, not a toolbar: attachments (`+`), settings
370
+ (permission mode · thinking depth · voice) and dictation live on one row under the
371
+ input, the context/spend line is small type *above* it, and send turns into stop
372
+ while a turn runs
373
+ - **attachments** as real ACP content blocks: images (`type: "image"`), text files
374
+ inlined with a filename header, links as `resource_link`. Only file names are
375
+ persisted — never the bytes
376
+ - **voice, both directions, browser-first**: read any reply aloud (per-message button
377
+ plus an auto-read toggle, voice picker, speed), dictate a prompt (live interim words).
378
+ A server endpoint is optional and only a proxy (`AGENTUS_TTS_BASE_URL` /
379
+ `AGENTUS_STT_BASE_URL`); with nothing configured, the browser does the work and the
380
+ UI says so
381
+ - **voice call mode** (📞 in the chat header): a full-screen call with the session — a canvas
382
+ orb that rides real audio, one colour per phase (listening / thinking / speaking / error,
383
+ announced to a screen reader), the reply read sentence by sentence as it streams, and
384
+ **barge-in that actually takes the floor**: talking over the reply stops our playback,
385
+ *cancels the agent's turn* and hands the microphone back. Its thresholds are knobs on the
386
+ call itself (⚙ in the corner): 抢话灵敏度 (0-100 %, higher = easier to interrupt, 60 % by
387
+ default), 抢话持续时间, 说完停顿 and 最少字数 (3 by default) — a phone on a table leaks its
388
+ own loudspeaker into its own microphone, a headset does not, so these are per-installation
389
+ settings (stored server-side, shared across devices) rather than constants. `最少字数` gates
390
+ only the automatic send; a tap on the orb always sends. While a call is open it owns the
391
+ voice: the auto-read of the same reply stands down, and a call reads **this turn's** answer
392
+ only — never the answer to the question before it
393
+ - **settings page** (⚙ in the rail): theme (light / dark / follow-the-OS, plus an accent
394
+ colour that retints the whole cockpit), the speech provider, endpoint and key, the
395
+ models (picked from the endpoint's own list), the hotword list, and a test button for
396
+ each direction. Everything the operator chooses — theme, speech endpoint, the call's
397
+ thresholds, and the talk-to-agent preferences (read replies aloud, voice, speed,
398
+ language, recogniser, server-side synthesis) — is a **row in the store's `settings`
399
+ table** (`voice` / `theme` / `call` / `prefs`, one row per section), not a file beside it
400
+ and not a browser's localStorage: settings follow the operator from the phone to the
401
+ laptop, and two tabs of one instance cannot disagree. A pre-store `settings.json` is
402
+ imported once and kept as `.imported`; the browser keeps a copy of each section only so
403
+ the first frame is already right
404
+ - **百炼 (DashScope) speech, first-class**: streaming recognition over its inference
405
+ WebSocket (`qwen-audio-3.1-asr-flash-streaming` — words appear as they are spoken),
406
+ batch recognition over the OpenAI-compatible chat route (`qwen3-asr-flash`), synthesis
407
+ via `SpeechSynthesizer` (`qwen-audio-3.0-tts-flash`, voices such as `longanhuan_v3.6`).
408
+ The browser cannot open that socket (the handshake needs an auth header) and the key
409
+ must not leave the server, so the cockpit owns the upstream connection and the browser
410
+ relays PCM through `/ws/asr`
411
+ - **hotwords, fixed and dynamic**: a fixed list (`词=权重`, `50` = super-hotword) that goes
412
+ into the recogniser's instant vocabulary, plus entity terms mined from this session's
413
+ own transcript and its neighbours — merged, deduped, capped, and previewable on the
414
+ settings page
415
+ - a session's dictation carries **that session's context** (its last few turns) as the
416
+ recogniser's bias, which is what keeps product names and identifiers intact
417
+ - streaming that does not fight the reader: a wheel/touch gesture detaches instantly,
418
+ new output is announced by a "jump to newest" button instead of yanking the viewport
419
+ - workspace picker: browse the server's directories (`GET /api/fs/dirs`, one level at a
420
+ time) or pick from the working directories you used before; the same picker re-points
421
+ an existing session
422
+
423
+ ## Naming
424
+
425
+ The UI says **session** everywhere (the rail, the empty state, the dialogs). "Slot" is
426
+ only the project's name — the concept an operator works with is a session, and the
427
+ product may well be renamed; nothing in the interface leans on the metaphor.
428
+
429
+ Names have two layers: `title` is what the rail shows, `auto_title` is the generated name it
430
+ falls back to. A generated name (the agent's, or ours derived from the first prompt) never
431
+ overwrites a hand-written one; clearing a rename restores it; and an explicit
432
+ **重新生成会话名** replaces it on purpose. Regeneration goes through the agent, not through a
433
+ model of our own: we fork the session, the fork summarises the copied history, and the fork is
434
+ discarded — our layer still holds no intelligence (red line D).
435
+
436
+ Session actions live on a per-session menu (⋯ / right-click / phone long-press):
437
+ rename (in place, reversible — clearing it restores the generated name), **regenerate the
438
+ name from the current conversation**, fork, workspace, **export**
439
+ (`GET /api/sessions/:id/export?format=md|json` — rendered from our own persisted
440
+ transcript, so an archived session exports without waking the agent; ACP itself has
441
+ no export concept), copy id, archive (= close: process exits, record kept) and,
442
+ for archived slots, resume or delete.
443
+
444
+ ## Tests
445
+
446
+ ```bash
447
+ npm run typecheck
448
+ npm run auth-smoke # 48 assertions: the lock, both credentials
449
+ npm run backend-smoke # 42: the backend registry, spawn env, health evidence
450
+ npm run workspace-smoke # 85: workspace field, file API, shell, attachments
451
+ npm run voice-smoke # 47: settings/theme guards, voice router, 百炼 shapes
452
+ npm run tls-smoke # the second listener, real sockets + real certs
453
+ npm run package-smoke # packs, installs into a throwaway prefix + HOME, boots
454
+ # that install, drives a mock turn, checks ~/.agentus
455
+ node scripts/smoke.mjs mock "hello" # end-to-end against the mock agent
456
+ ```
457
+
458
+ Every suite boots its own server on a scratch port with a throwaway data dir, so they run
459
+ anywhere (CI included):
460
+
461
+ - `auth-smoke` — anonymous REST/WS refusal, brute-force lockout, cookie signing and tamper
462
+ detection, expiry, WS-via-cookie, the machine token, logout revocation, `AGENTUS_AUTH=off`.
463
+ - `workspace-smoke` — the per-session workspace, the read-only file API, the PTY shell,
464
+ attachments on a prompt, the voice endpoint guards.
465
+ - `voice-smoke` — the settings/theme contract (validation, masking, 0600 file), the voice
466
+ router against a stand-in endpoint (OpenAI-compatible **and** 百炼's native shapes),
467
+ hotword merging and capping, and the `.env` bootstrap with an empty `$HOME`.
468
+
469
+ Browser QA evidence (63 rounds, each with repro → root cause → fix → regression)
470
+ lives in [`m1-qa-log.md`](./m1-qa-log.md). The `mock` backend triggers extra paths on
471
+ demand: `[tool]` (permission flow), `[think]`, `[plan]`, `[slow]` (reconnect drills),
472
+ `[sink]` (mid-turn child crash).
473
+
474
+ ## License
475
+
476
+ Apache-2.0. See [NOTICE](./NOTICE) for the AionUi inspiration credit (ideas only —
477
+ no code copied).