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