agentus 0.0.0-stage → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (59) hide show
  1. package/LICENSE +202 -0
  2. package/NOTICE +11 -0
  3. package/README.md +476 -2
  4. package/README.zh-CN.md +396 -0
  5. package/bin/agentus.mjs +147 -0
  6. package/docs/android-notify-contract.md +658 -0
  7. package/docs/dev/01-dev-loop.md +186 -0
  8. package/docs/dev/02-verify.md +237 -0
  9. package/docs/dev/03-release.md +218 -0
  10. package/docs/dev/04-recover.md +199 -0
  11. package/docs/dev/05-agent-driven.md +114 -0
  12. package/docs/dev/README.md +65 -0
  13. package/docs/installation.md +366 -0
  14. package/docs/installation.zh-CN.md +350 -0
  15. package/docs/refs/aionui-acp-rules.md +46 -0
  16. package/docs/refs/hermes-studio-ui-ideas.md +191 -0
  17. package/docs/transcript-folding.md +139 -0
  18. package/package.json +76 -5
  19. package/packages/server/mcp/plan-server.mjs +217 -0
  20. package/packages/server/mock/agent.mjs +693 -0
  21. package/packages/server/package.json +22 -0
  22. package/packages/server/src/acp/backends.ts +196 -0
  23. package/packages/server/src/acp/errors.ts +46 -0
  24. package/packages/server/src/acp/registry.ts +579 -0
  25. package/packages/server/src/acp/session-manager.ts +1868 -0
  26. package/packages/server/src/auth.ts +688 -0
  27. package/packages/server/src/dashscope.ts +235 -0
  28. package/packages/server/src/fs.ts +379 -0
  29. package/packages/server/src/hotwords.ts +132 -0
  30. package/packages/server/src/index.ts +1486 -0
  31. package/packages/server/src/notify/center.ts +994 -0
  32. package/packages/server/src/notify/types.ts +222 -0
  33. package/packages/server/src/plan/plan.ts +126 -0
  34. package/packages/server/src/pty-helper.py +135 -0
  35. package/packages/server/src/settings.ts +469 -0
  36. package/packages/server/src/store/export.ts +209 -0
  37. package/packages/server/src/store/store.ts +1238 -0
  38. package/packages/server/src/term.ts +201 -0
  39. package/packages/server/src/title.ts +109 -0
  40. package/packages/server/src/voice.ts +251 -0
  41. package/packages/shared/package.json +14 -0
  42. package/packages/shared/src/index.ts +271 -0
  43. package/packages/web/dist/assets/index-D667PX2c.css +32 -0
  44. package/packages/web/dist/assets/index-d9NzwPFu.js +283 -0
  45. package/packages/web/dist/coding-agents/hermes.png +0 -0
  46. package/packages/web/dist/coding-agents/qoder.svg +5 -0
  47. package/packages/web/dist/icons/agentus-icon-inverse.svg +301 -0
  48. package/packages/web/dist/icons/agentus-icon.svg +301 -0
  49. package/packages/web/dist/icons/agentus-mark-inverse.svg +301 -0
  50. package/packages/web/dist/icons/agentus-mark.svg +301 -0
  51. package/packages/web/dist/icons/apple-touch-icon-180.png +0 -0
  52. package/packages/web/dist/icons/favicon.ico +0 -0
  53. package/packages/web/dist/icons/icon-192.png +0 -0
  54. package/packages/web/dist/icons/icon-512.png +0 -0
  55. package/packages/web/dist/icons/icon-maskable-512.png +0 -0
  56. package/packages/web/dist/index.html +30 -0
  57. package/packages/web/dist/manifest.webmanifest +17 -0
  58. package/packages/web/dist/sw.js +61 -0
  59. package/scripts/make-cert.sh +101 -0
@@ -0,0 +1,1486 @@
1
+ // Agentus server entry: HTTP (REST + static web build) + WebSocket relay.
2
+ // The browser never talks to CLI subprocesses directly (design.md §1).
3
+ import { createServer, type IncomingMessage, type ServerResponse } from "node:http";
4
+ import { createServer as createHttpsServer } from "node:https";
5
+ import type { Duplex } from "node:stream";
6
+ import { createHash, randomUUID } from "node:crypto";
7
+ import { homedir } from "node:os";
8
+ import { fileURLToPath } from "node:url";
9
+ import path from "node:path";
10
+ import fs from "node:fs";
11
+ import { WebSocketServer, WebSocket } from "ws";
12
+ import { Store } from "./store/store.js";
13
+ import { exportFilename, renderJson, renderMarkdown, type ExportSessionHeader } from "./store/export.js";
14
+ import { SessionManager, SessionUnavailable } from "./acp/session-manager.js";
15
+ import { describeAcpError } from "./acp/errors.js";
16
+ import { BACKENDS } from "./acp/backends.js";
17
+ import { classifyError, checkedHealth, coerceRow, inspectRow, planFor, seedRows, startupCheck } from "./acp/registry.js";
18
+ import { ChangedOnDiskError, FsError, listDirs, readTextFile, statFile, writeTextFile } from "./fs.js";
19
+ import { terms } from "./term.js";
20
+ import { VoiceError, listVoiceModels, setHotwordSource, synthesize, transcribe, voiceCapabilities } from "./voice.js";
21
+ import { hotwordsFor, vocabularyOf } from "./hotwords.js";
22
+ import { openDashscopeStream } from "./dashscope.js";
23
+ import { initSettings, publicSettings, saveCall, savePrefs, saveSettings, saveTheme } from "./settings.js";
24
+ import * as auth from "./auth.js";
25
+ import { NotifyCenter } from "./notify/center.js";
26
+ import type { BackendId, ClientCommand, PermissionDecision, PromptAttachment, ServerEvent } from "../../shared/src/index.js";
27
+
28
+ const PORT = Number(process.env.AGENTUS_PORT ?? 8787);
29
+ // Data + web build resolve against THIS FILE, not the shell's cwd: `scripts/start.sh`
30
+ // launches from the repo root while the dev server runs from packages/server, and a
31
+ // cwd-relative path would silently open a second database (the operator's slots would
32
+ // "disappear"). Both entry points must land on the same store.
33
+ const HERE = path.dirname(fileURLToPath(import.meta.url)); // …/packages/server/src
34
+ // WHERE STATE LIVES — sqlite, credentials.json, auth.token, tls/, settings.json.
35
+ // 1. `AGENTUS_DATA` when it is set. Every launcher and dev/QA instance pins it, so the
36
+ // rules below can never move a running install's data out from under it.
37
+ // 2. `~/.agentus` once it holds something, or when there is no repo-side data yet.
38
+ // 3. the in-repo `<repo>/packages/server/.data`, if it exists and ~/.agentus does not —
39
+ // an existing checkout keeps its sessions instead of silently starting empty (that
40
+ // failure mode looks exactly like "all my sessions vanished"; the boot log says which
41
+ // directory won and how to move).
42
+ // NOTHING may write inside node_modules: for an installed package
43
+ // `<pkg>/packages/server/.data` is deleted by the next `npm i -g agentus`, taking the
44
+ // transcript, the login and the TLS key with it.
45
+ const DEFAULT_DATA_DIR = path.join(homedir(), ".agentus");
46
+ const REPO_DATA_DIR = path.resolve(HERE, "../.data");
47
+ const hasData = (dir: string): boolean => {
48
+ try { return fs.statSync(dir).isDirectory() && fs.readdirSync(dir).length > 0; } catch { return false; }
49
+ };
50
+ // An empty AGENTUS_DATA counts as unset (a launcher that clears a variable writes
51
+ // `AGENTUS_DATA=`), otherwise it would resolve to the shell's cwd.
52
+ const ENV_DATA_DIR = process.env.AGENTUS_DATA?.trim() || null;
53
+ const DATA_DIR = ENV_DATA_DIR
54
+ ?? (hasData(DEFAULT_DATA_DIR) || !hasData(REPO_DATA_DIR) ? DEFAULT_DATA_DIR : REPO_DATA_DIR);
55
+ const DATA_DIR_SOURCE = ENV_DATA_DIR
56
+ ? "from AGENTUS_DATA"
57
+ : DATA_DIR === REPO_DATA_DIR
58
+ ? "in-repo packages/server/.data"
59
+ : hasData(DEFAULT_DATA_DIR) ? "existing ~/.agentus" : "default ~/.agentus (created on this boot)";
60
+ const WEB_DIST = process.env.AGENTUS_WEB_DIST ?? path.resolve(HERE, "../../web/dist");
61
+ // The Android companion artifact (/notify links to it, /agentus-companion.apk streams it).
62
+ // First candidate is relative to THIS FILE (src → packages/server → packages → repo root,
63
+ // i.e. a checkout that built the APK); a published install has no android/ tree, so the
64
+ // data dir is the place to drop a downloaded artifact. Missing file is not fatal — the
65
+ // routes answer 404 and the panel says 尚未构建.
66
+ const APK_FILE = process.env.AGENTUS_APK
67
+ ?? [
68
+ path.resolve(HERE, "../../../android/artifacts/agentus-companion.apk"),
69
+ path.join(DATA_DIR, "agentus-companion.apk"),
70
+ ].find((p) => fs.existsSync(p))
71
+ ?? path.resolve(HERE, "../../../android/artifacts/agentus-companion.apk");
72
+ // Optional second listener, TLS (see the block right before listen). Declared up here
73
+ // because the request handler also serves the public cert — a phone that has to trust a
74
+ // self-signed issuer needs to fetch the cert from somewhere, and that somewhere should
75
+ // not require already trusting it.
76
+ // AGENTUS_TLS_PORT=0 -> off; no cert on disk -> off (the LAN listener is unaffected).
77
+ const TLS_PORT = Number(process.env.AGENTUS_TLS_PORT ?? 8443);
78
+ const TLS_CERT = process.env.AGENTUS_TLS_CERT ?? path.join(DATA_DIR, "tls", "cert.pem");
79
+ const TLS_KEY = process.env.AGENTUS_TLS_KEY ?? path.join(DATA_DIR, "tls", "key.pem");
80
+ // The issuer a device installs once (make-cert.sh: long-lived root + short-lived leaf).
81
+ // Falls back to the leaf itself for a single self-signed cert from an older setup.
82
+ const TLS_CA = process.env.AGENTUS_TLS_CA ?? path.join(DATA_DIR, "tls", "ca.pem");
83
+ const TLS_READY = TLS_PORT > 0 && fs.existsSync(TLS_CERT) && fs.existsSync(TLS_KEY);
84
+
85
+ // `agentus --where` (bin/agentus.mjs) asks the ONE place that decides these paths to say
86
+ // what it decided, then exits — before any listener, store or subprocess exists. Print and
87
+ // exit here rather than re-deriving the same rules in the CLI: "where is my data" must not
88
+ // have two answers.
89
+ if (process.env.AGENTUS_PRINT_PATHS) {
90
+ const has = (p: string) => (fs.existsSync(p) ? "present" : "MISSING");
91
+ console.log(`[agentus] node ${process.versions.node}`);
92
+ console.log(`[agentus] data dir ${DATA_DIR} (${DATA_DIR_SOURCE})`);
93
+ console.log(`[agentus] web bundle ${WEB_DIST} (${has(path.join(WEB_DIST, "index.html"))})`);
94
+ console.log(`[agentus] companion apk ${APK_FILE} (${has(APK_FILE)})`);
95
+ console.log(`[agentus] tls listener ${TLS_READY ? `on :${TLS_PORT} (${TLS_CERT})` : `off (AGENTUS_TLS_PORT=${TLS_PORT}, cert ${has(TLS_CERT)})`}`);
96
+ console.log(`[agentus] http port ${PORT}`);
97
+ process.exit(0);
98
+ }
99
+
100
+ fs.mkdirSync(DATA_DIR, { recursive: true, mode: 0o700 }); // 0700: credentials + TLS key live here
101
+ const store = new Store(path.join(DATA_DIR, "agentus.sqlite"));
102
+ // M6: an existing cockpit must behave exactly as before, so the registry starts as a copy of
103
+ // the builtin (env-driven) rows and the operator edits from there. Seeding is one-shot: an
104
+ // empty registry means "first boot", a non-empty one means the operator owns those rows.
105
+ {
106
+ const seeded = store.seedBackendsIfEmpty(seedRows());
107
+ if (seeded) console.log(`[agentus] backend registry seeded with ${seeded} row(s)`);
108
+ }
109
+ // Boot sweep (M6.1): one cheap, side-effect-free pass (does the command resolve?) behind the
110
+ // first tick, so the list never opens with every row "unchecked". It deliberately does NOT
111
+ // spawn anything: a real check belongs to 探测 (kind=manual) and, for proof, to a session
112
+ // (kind=session). One bad row must not take the cockpit down, hence the per-row catch.
113
+ setTimeout(() => {
114
+ for (const row of store.listBackends()) {
115
+ try {
116
+ store.recordBackendCheck(row.id, startupCheck(row));
117
+ } catch (e) {
118
+ console.warn(`[agentus] startup check failed for ${row.id}: ${e instanceof Error ? e.message : e}`);
119
+ }
120
+ }
121
+ }, 1200);
122
+
123
+ // Auth key material next to the store: a restart must NOT log the operator out,
124
+ // and the machine token has to stay stable for scripts/CI.
125
+ const authStatus = auth.initAuth(DATA_DIR);
126
+
127
+ function clientIp(req: IncomingMessage): string {
128
+ const fwd = String(req.headers["x-forwarded-for"] ?? "").split(",")[0].trim();
129
+ return fwd || req.socket.remoteAddress || "unknown";
130
+ }
131
+
132
+ /** Addresses the plan MCP tool will answer to. It has its own per-session token (see the route),
133
+ * so this is the second lock, not the only one: a token that leaked out of `ps` still has to be
134
+ * used from this machine. */
135
+ const LOOPBACK_IPS = new Set(["127.0.0.1", "::1", "::ffff:127.0.0.1", "localhost"]);
136
+
137
+ // ---- event fan-out to all WS clients + per-client replay tracking ----
138
+ const clients = new Map<string, { ws: WebSocket; lastSeen: Map<string, number> }>();
139
+
140
+ /** `~/x` and relative paths from the UI land on absolute ones, same as the picker. */
141
+ function expandUserPath(input: string): string {
142
+ const raw = input.trim();
143
+ if (raw === "~") return homedir();
144
+ if (raw.startsWith("~/")) return path.join(homedir(), raw.slice(2));
145
+ return path.resolve(raw);
146
+ }
147
+
148
+ /** The directory a slot's panels (files, terminal) start from: the workspace the
149
+ * operator picked, falling back to the cwd the slot was spawned in. */
150
+ function sessionRoot(sessionId: string): { path: string; workspace: string | null } | null {
151
+ const live = mgr.list().find((s) => s.id === sessionId);
152
+ const row = live ?? store.getSession(sessionId);
153
+ if (!row) return null;
154
+ return { path: row.workspace || row.cwd, workspace: row.workspace ?? null };
155
+ }
156
+
157
+ function emit(evt: ServerEvent): void {
158
+ const wire = JSON.stringify(evt);
159
+ for (const { ws } of clients.values()) {
160
+ if (ws.readyState === WebSocket.OPEN) ws.send(wire);
161
+ }
162
+ // The same event stream that feeds the browser feeds the notify channel: turn-start/end,
163
+ // permission requests and errors become activities. One source of truth, no second
164
+ // observation path that could drift from what the operator sees.
165
+ notify.observe(evt);
166
+ }
167
+
168
+ const mgr = new SessionManager(store, emit);
169
+
170
+ /** The session list every client stays current with: the rows PLUS what is already waiting for
171
+ * an answer. Pending approvals are STATE, not just an event — a page that connects or refreshes
172
+ * after the request was raised still has to be able to show it. */
173
+ const sessionsEvent = (): ServerEvent => ({
174
+ t: "sessions", sessions: mgr.list(), pending: mgr.pendingPermissions(),
175
+ });
176
+
177
+ // ---- notify channel (docs/android-notify-contract.md) ----------------------------
178
+ // Owns pairing, the device websocket, the activity lifecycle and the button callbacks.
179
+ // It holds no agent logic: what an event MEANS is decided in notify/center.ts §ACP mapping,
180
+ // and everything it sends goes through one wire format.
181
+ const notify = new NotifyCenter({
182
+ dataDir: DATA_DIR,
183
+ log: (line: string) => console.log(line),
184
+ sessionTitle: (id: string) => mgr.list().find((s) => s.id === id)?.title ?? store.getSession(id)?.title ?? null,
185
+ // Operator credential = whatever already unlocks the cockpit (session cookie or machine token).
186
+ operator: (req, url) => Boolean(auth.authenticate(req.headers, url)),
187
+ // The companion artifact, so the phone panel in 设置 can show its size without a second request.
188
+ apkPath: APK_FILE,
189
+ // Username+password pairing: the phone types the same login the browser uses. The check and
190
+ // its rate limit stay here, in auth.ts, so a phone cannot have a laxer door than the UI.
191
+ credentials: (username, password, ip) => {
192
+ const gate = auth.loginAllowed(ip);
193
+ if (!gate.allowed) {
194
+ return { ok: false, reason: `too many attempts, retry in ${Math.ceil(gate.retryAfterMs / 1000)}s` };
195
+ }
196
+ if (!auth.verifyCredentials(username, password)) {
197
+ auth.recordLoginFailure(ip);
198
+ return { ok: false, reason: "wrong username or password" };
199
+ }
200
+ auth.recordLoginSuccess(ip);
201
+ return { ok: true };
202
+ },
203
+ });
204
+
205
+ // One row per interaction. A notification button carries an opaque actionId plus the ref the
206
+ // publisher attached; the cockpit is the only place that knows what it means. Adding a new
207
+ // interaction is therefore a case here (or a new ref.type), never an app rebuild.
208
+ notify.onAction(({ ref, action, activity, device }) => {
209
+ console.log(`[notify] action device=${device.name} activity=${activity.activityId} action=${action.actionId} type=${ref?.type ?? "none"}`);
210
+ if (!ref) return; // the button existed but nobody claimed it: log and drop, never guess
211
+ switch (ref.type) {
212
+ case "permission":
213
+ // The lock screen answering an agent's permission prompt — the whole point of the channel.
214
+ mgr.respondPermission(String(ref.sessionId), String(ref.requestId), {
215
+ outcome: "selected",
216
+ optionId: String(ref.optionId),
217
+ }, {
218
+ optionKind: typeof ref.optionKind === "string" ? ref.optionKind : undefined,
219
+ signature: typeof ref.signature === "string" ? ref.signature : undefined,
220
+ });
221
+ return;
222
+ case "open":
223
+ return; // the app already opened the deeplink; nothing to do server-side
224
+ default:
225
+ console.log(`[notify] no handler for ref.type=${ref.type}`);
226
+ }
227
+ });
228
+
229
+ // Operator settings (voice, theme, call knobs, talk-to-agent prefs) live in the store's
230
+ // `settings` table: the UI owns them and env only bootstraps them (settings.ts). Read
231
+ // before anything asks.
232
+ initSettings(DATA_DIR, store);
233
+ // Dynamic hotwords are mined from the transcript — the store's business, not voice.ts's,
234
+ // so the lookup is injected rather than imported.
235
+ setHotwordSource((sessionId) => hotwordsFor(store, sessionId).map((h) => h.word));
236
+
237
+ /** The tail of the session being dictated into, for DashScope's context enhancement
238
+ * ("根据上下文"): user/assistant turns only, capped at 5 by the API. */
239
+ function dictationContext(sessionId?: string): { role: "user" | "assistant"; text: string }[] {
240
+ if (!sessionId) return [];
241
+ const out: { role: "user" | "assistant"; text: string }[] = [];
242
+ for (const row of store.messagesTail(sessionId, 8).messages) {
243
+ if (row.kind !== "user" && row.kind !== "agent") continue;
244
+ const text = String((row.payload as { text?: string })?.text ?? "").trim().slice(0, 200);
245
+ if (text) out.push({ role: row.kind === "user" ? "user" : "assistant", text });
246
+ }
247
+ return out.slice(-5);
248
+ }
249
+
250
+ /**
251
+ * Single prompt entry point (REST + WS both use it).
252
+ * A failed turn must never be silent: the UI used to show a bare
253
+ * "-32603 Internal error" with nothing in the server log, and it also never
254
+ * emitted turn-end, so the bubble stayed "running" forever (QA#11).
255
+ */
256
+ function runPrompt(
257
+ sessionId: string,
258
+ text: string,
259
+ attachments: PromptAttachment[] = [],
260
+ interrupt = false,
261
+ ): void {
262
+ void mgr.prompt(sessionId, text, attachments, { interrupt }).catch((e: unknown) => {
263
+ const msg = describeAcpError(e);
264
+ const tail = mgr.stderrTail(sessionId);
265
+ console.error(`[agentus] prompt failed for ${sessionId}: ${msg}${tail ? `\n agent stderr tail:\n ${tail}` : ""}`);
266
+ emit({ t: "message", message: {
267
+ seq: -1, sessionId, kind: "meta", payload: { text: `turn failed: ${msg}` }, createdAt: Date.now(),
268
+ } });
269
+ emit({ t: "turn-end", sessionId, error: msg });
270
+ });
271
+ }
272
+
273
+ // A single bad request must never take the cockpit down: one unhandled
274
+ // rejection used to exit the process and silently drop every live session
275
+ // (QA#19). Installed only AFTER we own the port — a startup failure
276
+ // (EADDRINUSE etc.) must still crash loudly instead of lingering as a
277
+ // zombie with no listener (QA#22).
278
+ function installSafetyNet(): void {
279
+ process.on("unhandledRejection", (reason) => {
280
+ console.error("[agentus] unhandled rejection (kept alive):", reason);
281
+ });
282
+ process.on("uncaughtException", (err) => {
283
+ console.error("[agentus] uncaught exception (kept alive):", err);
284
+ });
285
+ }
286
+
287
+ // AC5: reclaim orphans from previous run before serving anything.
288
+ const killed = mgr.reclaimOrphans();
289
+ if (killed) console.log(`[agentus] reclaimed ${killed} orphan agent process(es)`);
290
+
291
+ // ---- REST ----
292
+ /** Raw body for uploads (audio for /api/stt). Capped: a dictation clip is small,
293
+ * and an uncapped reader is a memory DoS on a public tunnel. */
294
+ async function readBody(req: IncomingMessage, maxBytes = 25 * 1024 * 1024): Promise<Buffer> {
295
+ const chunks: Buffer[] = [];
296
+ let total = 0;
297
+ for await (const chunk of req) {
298
+ const buf = chunk as Buffer;
299
+ total += buf.length;
300
+ if (total > maxBytes) throw new Error(`body too large (> ${Math.round(maxBytes / 1048576)}MB)`);
301
+ chunks.push(buf);
302
+ }
303
+ return Buffer.concat(chunks);
304
+ }
305
+
306
+ async function readJson(req: IncomingMessage): Promise<Record<string, unknown>> {
307
+ const chunks: Buffer[] = [];
308
+ for await (const c of req) chunks.push(c as Buffer);
309
+ const raw = Buffer.concat(chunks).toString("utf8");
310
+ return raw ? (JSON.parse(raw) as Record<string, unknown>) : {};
311
+ }
312
+
313
+ function send(res: ServerResponse, code: number, body: unknown): void {
314
+ res.writeHead(code, { "content-type": "application/json" });
315
+ res.end(JSON.stringify(body));
316
+ }
317
+
318
+ function serveStatic(req: IncomingMessage, res: ServerResponse): void {
319
+ // SPA fallback for web build; only GET
320
+ if (!fs.existsSync(WEB_DIST)) {
321
+ res.writeHead(404);
322
+ res.end("web build not found (run `npm run build -w @agentus/web`)");
323
+ return;
324
+ }
325
+ const url = new URL(req.url ?? "/", "http://x");
326
+ let file = path.join(WEB_DIST, url.pathname === "/" ? "index.html" : url.pathname);
327
+ if (!file.startsWith(WEB_DIST)) { res.writeHead(403); res.end(); return; }
328
+ if (!fs.existsSync(file) || fs.statSync(file).isDirectory()) file = path.join(WEB_DIST, "index.html");
329
+ const ext = path.extname(file);
330
+ const types: Record<string, string> = {
331
+ ".html": "text/html", ".js": "text/javascript", ".css": "text/css",
332
+ ".svg": "image/svg+xml", ".png": "image/png", ".webmanifest": "application/manifest+json",
333
+ ".json": "application/json", ".ico": "image/x-icon",
334
+ };
335
+ res.writeHead(200, {
336
+ "content-type": types[ext] ?? "application/octet-stream",
337
+ // The HTML must never be cached: it names the hashed bundle, so a cached index.html keeps serving
338
+ // the OLD app after a rebuild — which is how a shipped fix fails to reach the phone (the WebView
339
+ // keeps the page for days otherwise). The hashed assets themselves are immutable and cacheable.
340
+ "cache-control": ext === ".html" ? "no-cache, must-revalidate" : "public, max-age=31536000, immutable",
341
+ });
342
+ fs.createReadStream(file).pipe(res);
343
+ }
344
+
345
+ // One request handler, two listeners: plain HTTP on PORT (LAN, loopback, curl/scripts)
346
+ // and TLS on TLS_PORT (the port a public tunnel points at). Shared on purpose — auth,
347
+ // routes and static serving must not drift between the two.
348
+ const handleRequest = async (req: IncomingMessage, res: ServerResponse) => {
349
+ const url = new URL(req.url ?? "/", "http://x");
350
+ try {
351
+ // The notify channel authenticates by itself — a rotating pairing code for /pair, the
352
+ // device token for everything else — so it runs AHEAD of the operator gate. That is the
353
+ // whole point: a phone tapping "allow" on the lock screen has no browser session.
354
+ if (url.pathname.startsWith("/api/notify") && (await notify.handleHttp(req, res, url))) return undefined;
355
+
356
+ // The plan MCP tool's door (design-plan-service.md §6-B). AHEAD of the operator gate, like
357
+ // /api/notify, because its caller is a child process of an AGENT: it has no browser session, and
358
+ // the outer HTTP Basic lock is not something we could hand it without putting operator
359
+ // credentials into every agent's environment. It carries its own credential instead — a token
360
+ // minted per agent process and delivered through the ACP handshake (§4) — plus a loopback-only
361
+ // rule, so the token never crosses a network. The blast radius of a leaked token is one session's
362
+ // plan, which is the object the operator is already looking at.
363
+ if (url.pathname === "/api/plan/mcp" && req.method === "POST") {
364
+ const ip = clientIp(req);
365
+ if (!LOOPBACK_IPS.has(ip)) return send(res, 403, { error: "the plan tool is loopback-only" });
366
+ const body = await readJson(req);
367
+ const token = String(req.headers["x-agentus-plan-token"] ?? "");
368
+ const sessionId = String(body.session ?? "");
369
+ if (body.op === "read") {
370
+ const out = mgr.planForMcp(sessionId, token);
371
+ return out.ok ? send(res, 200, { plan: out.plan }) : send(res, 403, { error: out.error });
372
+ }
373
+ if (body.op === "update") {
374
+ const out = mgr.writePlanFromMcp(sessionId, token, { items: body.items, explanation: body.explanation });
375
+ if (!out.ok) return send(res, 403, { error: out.error });
376
+ return send(res, 200, { accepted: out.accepted, reason: out.reason, plan: out.plan });
377
+ }
378
+ return send(res, 400, { error: "op must be 'read' or 'update'" });
379
+ }
380
+
381
+ // ---- outer lock: optional HTTP Basic, in front of EVERYTHING (static, /healthz,
382
+ // /api, login page). This is the layer that a public tunnel needs, because a
383
+ // tunnel's own "auth_pass" does not necessarily gate HTTP (see auth.ts).
384
+ if (!auth.verifyBasic(req.headers.authorization)) {
385
+ res.writeHead(401, auth.basicChallenge());
386
+ return res.end("Agentus: authentication required\n");
387
+ }
388
+
389
+ // GET /cert.crt — the ISSUER to install into a phone's trust store (the "proceed
390
+ // anyway" click, once per device, is the whole cost of TOFU; this makes it a download).
391
+ // With make-cert.sh's root+leaf setup this is the root, so the leaf can be rotated
392
+ // yearly without every device having to re-install.
393
+ // PUBLIC material only: the key never leaves <DATA>/tls/.
394
+ if (url.pathname === "/cert.crt") {
395
+ if (!TLS_READY) return send(res, 404, { error: "tls is off on this server" });
396
+ const der = fs.readFileSync(fs.existsSync(TLS_CA) ? TLS_CA : TLS_CERT);
397
+ res.writeHead(200, {
398
+ // Apple's installer claims this type and opens the profile flow directly;
399
+ // Android/Chrome just downloads the file.
400
+ "content-type": "application/x-x509-ca-cert",
401
+ "content-disposition": 'attachment; filename="agentus.crt"',
402
+ "content-length": String(der.length),
403
+ });
404
+ return res.end(der);
405
+ }
406
+
407
+ // ---- the phone's onboarding page + the APK, both behind the operator login ----------
408
+ // The pairing code lives on this page, so it is NOT public: an unauthenticated visitor is
409
+ // sent to the login screen (the SPA at /) and comes back with a session cookie.
410
+ if (url.pathname === "/notify" || url.pathname === "/notify/" || url.pathname === "/agentus-companion.apk") {
411
+ const who = auth.authenticate(req.headers, url);
412
+ if (!who) {
413
+ res.writeHead(302, { location: "/" });
414
+ return res.end("login required\n");
415
+ }
416
+ if (url.pathname === "/agentus-companion.apk") {
417
+ if (!fs.existsSync(APK_FILE)) return send(res, 404, { error: "no apk built yet" });
418
+ res.writeHead(200, {
419
+ "content-type": "application/vnd.android.package-archive",
420
+ "content-length": String(fs.statSync(APK_FILE).size),
421
+ "content-disposition": 'attachment; filename="agentus-companion.apk"',
422
+ "cache-control": "no-store",
423
+ });
424
+ fs.createReadStream(APK_FILE).pipe(res);
425
+ return undefined;
426
+ }
427
+ const secure = Boolean((req.socket as { encrypted?: boolean }).encrypted)
428
+ || String(req.headers["x-forwarded-proto"] ?? "").split(",")[0].trim() === "https";
429
+ const host = String(req.headers.host ?? "localhost");
430
+ const origin = `${secure ? "https" : "http"}://${host}`;
431
+ const pairUri = `agentus://pair?u=${encodeURIComponent(origin)}&c=${notify.code}`;
432
+ const apkPath = APK_FILE;
433
+ const apkSize = fs.existsSync(apkPath) ? `${(fs.statSync(apkPath).size / 1048576).toFixed(1)} MB` : "尚未构建";
434
+ const rows = notify.devices().map((d) =>
435
+ `<tr><td>${d.name}</td><td>${d.platform} ${d.sdkInt}</td><td>${d.capabilities.join(" ")}</td>` +
436
+ `<td>${d.lastSeenAt ? new Date(d.lastSeenAt).toLocaleString() : "—"}</td></tr>`).join("");
437
+ res.writeHead(200, { "content-type": "text/html; charset=utf-8", "cache-control": "no-store" });
438
+ return res.end(`<!doctype html><meta charset="utf-8">
439
+ <meta name="viewport" content="width=device-width,initial-scale=1">
440
+ <title>Agentus 手机通知</title>
441
+ <style>body{font:15px/1.6 -apple-system,system-ui,sans-serif;margin:24px auto;max-width:40em;padding:0 16px}
442
+ code{background:#f2f2ef;padding:3px 7px;border-radius:5px;word-break:break-all;font-size:13px}
443
+ button{font:inherit;padding:7px 12px;margin:4px 6px 4px 0;border-radius:8px;border:1px solid #ccc;background:#fff}
444
+ td{padding:3px 12px 3px 0;font-size:12px;font-family:ui-monospace,monospace}
445
+ h1{font-size:20px} h3{font-size:15px;margin-top:28px} li{margin:6px 0}</style>
446
+ <h1>手机通知(Android 伴侣)</h1>
447
+ <ol>
448
+ <li>装 App:<a href="/agentus-companion.apk">agentus-companion.apk</a> (${apkSize})<br>
449
+ <small>手机浏览器会提示"安装未知应用",允许一次即可。${fs.existsSync(apkPath) ? `<br>sha256 <code>${createHash("sha256").update(fs.readFileSync(apkPath)).digest("hex")}</code>` : ""}</small></li>
450
+ <li>打开 App,把这一行粘进首屏(或直接填地址 ${origin} + 你的用户名密码):<br>
451
+ <code id="uri">${pairUri}</code>
452
+ <button onclick="navigator.clipboard.writeText(document.getElementById('uri').textContent)">复制</button></li>
453
+ <li>授予通知权限。上岛(灵动岛)两条路:<b>Android 16</b> 打开 App 里那个「实时动态」系统开关;
454
+ <b>小米/澎湃</b>点 App 里的「小米焦点通知权限」看是否放行(澎湃默认不给三方应用焦点通知,不放行就只有普通通知)。</li>
455
+ <li>折叠屏(MIX Fold 4 之类):展开/合上都会自适应——外屏单栏、内屏双栏,正在填的表单不会丢。</li>
456
+ <li>手机用蜂窝网连不上:多半是 safe-nat 白名单没放行(表现是连接被重置,或运营商代理伪装成 502)。
457
+ 在 <code>http://i207f47592.wicp.vip:10086</code> 面板「白名单 → 当前访问者 → 加入白名单」把自己加上。</li>
458
+ </ol>
459
+ <p><button onclick="post('/api/notify/probe')">发一条探针通知到我的手机</button>
460
+ <button onclick="post('/api/notify/pair-code/rotate').then(()=>location.reload())">换一个配对码</button></p>
461
+ <h3>已配对设备</h3>
462
+ <table>${rows || "<tr><td>(还没有设备配对)</td></tr>"}</table>
463
+ <p style="color:#888;font-size:12px">配对码 <b>${notify.code}</b> · 服务端 ${origin}</p>
464
+ <script>
465
+ async function post(path){const r=await fetch(path,{method:'POST'});alert(r.ok?'已发送':'失败 '+r.status)}
466
+ </script>`);
467
+ }
468
+
469
+ // ---- auth gate. Everything under /api except the login/me endpoints is
470
+ // closed; the SPA shell, its assets and /healthz stay public so the login
471
+ // page itself can load (the browser has no cookie yet at that point).
472
+ if (auth.requiresAuth(url.pathname)) {
473
+ const who = auth.authenticate(req.headers, url);
474
+ if (!who) {
475
+ res.writeHead(401, { "content-type": "application/json" });
476
+ return res.end(JSON.stringify({ error: "unauthorized", authRequired: true }));
477
+ }
478
+ (req as IncomingMessage & { principal?: auth.AuthPrincipal }).principal = who;
479
+ }
480
+
481
+ if (url.pathname === "/api/auth/me" && req.method === "GET") {
482
+ const who = auth.authenticate(req.headers, url);
483
+ const st = auth.status(); // live: the operator can change these while the app is up
484
+ return send(res, 200, {
485
+ authEnabled: st.enabled,
486
+ authenticated: Boolean(who),
487
+ kind: who?.kind ?? null,
488
+ username: who?.username ?? null,
489
+ // the account the cockpit would accept right now (what the settings page shows)
490
+ configuredUsername: st.username,
491
+ credentialSource: st.source,
492
+ minPasswordLen: auth.MIN_PASSWORD_LEN,
493
+ expiresAt: who?.expiresAt ?? null,
494
+ usingDefaultPassword: st.usingDefaultPassword,
495
+ });
496
+ }
497
+ if (url.pathname === "/api/auth/login" && req.method === "POST") {
498
+ const secure = auth.isSecureRequest(req.headers);
499
+ const body = await readJson(req);
500
+ const username = String(body.username ?? "");
501
+ const password = String(body.password ?? "");
502
+ const ip = clientIp(req);
503
+ const gate = auth.loginAllowed(ip);
504
+ if (!gate.allowed) {
505
+ // 429 so the UI can say "slow down" rather than "wrong password"
506
+ res.writeHead(429, { "content-type": "application/json", "retry-after": String(Math.ceil(gate.retryAfterMs / 1000)) });
507
+ return res.end(JSON.stringify({ error: "too_many_attempts", retryAfterMs: gate.retryAfterMs }));
508
+ }
509
+ if (!auth.verifyCredentials(username, password)) {
510
+ auth.recordLoginFailure(ip);
511
+ const left = auth.loginLimits().maxFails;
512
+ const after = auth.loginAllowed(ip);
513
+ console.warn(`[agentus] failed login for "${username}" from ${ip}${after.allowed ? "" : " (locked out)"}`);
514
+ return send(res, 401, { error: "bad_credentials", hint: `username/password rejected (limit ${left} tries per IP)` });
515
+ }
516
+ auth.recordLoginSuccess(ip);
517
+ const st = auth.status();
518
+ const { value, expiresAt } = auth.issueSession(st.username, Date.now(), { ip, ua: String(req.headers["user-agent"] ?? "") });
519
+ console.log(`[agentus] login ok: ${st.username} from ${ip}`);
520
+ res.writeHead(200, {
521
+ "content-type": "application/json",
522
+ "set-cookie": auth.sessionCookie(value, Math.floor(st.sessionTtlMs / 1000), secure),
523
+ });
524
+ return res.end(JSON.stringify({ username: st.username, expiresAt, usingDefaultPassword: st.usingDefaultPassword }));
525
+ }
526
+ if (url.pathname === "/api/auth/credentials" && req.method === "POST") {
527
+ // Behind the same gate as the rest of /api (it is not in PUBLIC_API), so a caller
528
+ // here already holds a session or the machine token — yet the change still demands
529
+ // the current password: handing the cockpit over deserves a second proof.
530
+ const body = await readJson(req);
531
+ const out = auth.changeCredentials({
532
+ currentPassword: String(body.currentPassword ?? ""),
533
+ username: body.username === undefined ? undefined : String(body.username),
534
+ newPassword: body.newPassword === undefined || body.newPassword === null ? undefined : String(body.newPassword),
535
+ });
536
+ if (!out.ok) return send(res, out.status, { error: out.error });
537
+ console.log(`[agentus] credentials changed: user "${out.username}" (epoch ${out.epoch}) from ${clientIp(req)}`);
538
+ // every older cookie is dead now (the epoch moved) — hand this caller a fresh one
539
+ const st = auth.status();
540
+ const { value, expiresAt } = auth.issueSession(st.username, Date.now(), { ip: clientIp(req), ua: String(req.headers["user-agent"] ?? "") });
541
+ res.writeHead(200, {
542
+ "content-type": "application/json",
543
+ "set-cookie": auth.sessionCookie(value, Math.max(1, Math.floor((expiresAt - Date.now()) / 1000)), auth.isSecureRequest(req.headers)),
544
+ });
545
+ return res.end(JSON.stringify({
546
+ ok: true,
547
+ username: st.username,
548
+ credentialSource: st.source,
549
+ usingDefaultPassword: st.usingDefaultPassword,
550
+ expiresAt,
551
+ }));
552
+ }
553
+ if (url.pathname === "/api/auth/sessions" && req.method === "GET") {
554
+ const who = (req as IncomingMessage & { principal?: auth.AuthPrincipal }).principal;
555
+ return send(res, 200, {
556
+ sessions: auth.listSessions(who?.jti),
557
+ ttlMs: auth.sessionTtlMs(),
558
+ });
559
+ }
560
+ if (url.pathname === "/api/auth/sessions/revoke" && req.method === "POST") {
561
+ const body = await readJson(req);
562
+ const jti = String(body.jti ?? "");
563
+ const who = (req as IncomingMessage & { principal?: auth.AuthPrincipal }).principal;
564
+ if (jti && jti === who?.jti) {
565
+ // revoking yourself is "log out", not "kick this device" — say so instead of
566
+ // silently leaving the browser holding a dead cookie
567
+ return send(res, 400, { error: "that is this session — use log out" });
568
+ }
569
+ if (!auth.revokeSessionById(jti)) return send(res, 404, { error: "no such session" });
570
+ console.log(`[agentus] session revoked: ${jti} from ${clientIp(req)}`);
571
+ return send(res, 200, { ok: true });
572
+ }
573
+ if (url.pathname === "/api/auth/sessions/revoke-others" && req.method === "POST") {
574
+ const who = (req as IncomingMessage & { principal?: auth.AuthPrincipal }).principal;
575
+ const count = auth.revokeOtherSessions(who?.jti);
576
+ console.log(`[agentus] logged out ${count} other session(s), asked by ${clientIp(req)}`);
577
+ return send(res, 200, { ok: true, count });
578
+ }
579
+ if (url.pathname === "/api/auth/locked-ips" && req.method === "GET") {
580
+ return send(res, 200, { locks: auth.listIpLocks(), maxFails: auth.loginLimits().maxFails, lockMs: auth.loginLimits().lockMs });
581
+ }
582
+ if (url.pathname === "/api/auth/locked-ips" && req.method === "DELETE") {
583
+ const ip = String(url.searchParams.get("ip") ?? "").trim();
584
+ if (ip) {
585
+ const known = auth.unlockIp(ip);
586
+ if (!known) return send(res, 404, { error: "that IP is not locked" });
587
+ console.log(`[agentus] lock lifted for ${ip} (asked by ${clientIp(req)})`);
588
+ return send(res, 200, { ok: true, ip });
589
+ }
590
+ const count = auth.unlockAllIps();
591
+ console.log(`[agentus] all login locks lifted (${count}) by ${clientIp(req)}`);
592
+ return send(res, 200, { ok: true, count });
593
+ }
594
+ if (url.pathname === "/api/auth/logout" && req.method === "POST") {
595
+ // Revoke, don't just clear: see auth.revokeSession
596
+ const cookie = auth.parseCookies(String(req.headers.cookie ?? ""))[auth.AUTH_COOKIE_NAME];
597
+ const revoked = auth.revokeSession(cookie);
598
+ console.log(`[agentus] logout from ${clientIp(req)}${revoked ? "" : " (no live session)"}`);
599
+ res.writeHead(200, { "content-type": "application/json", "set-cookie": auth.clearedCookie(auth.isSecureRequest(req.headers)) });
600
+ return res.end(JSON.stringify({ ok: true, revoked }));
601
+ }
602
+ if (url.pathname === "/healthz") {
603
+ return send(res, 200, { ok: true, ts: Date.now(), backends: store.listBackends().map((r) => r.id) });
604
+ }
605
+ // ---- backend registry (M6) --------------------------------------------------------------
606
+ // A row says which COMMAND, which HERMES_HOME and which profile a slot spawns with; `plan`
607
+ // resolves the home the same way the spawn does, so the panel shows the real path (a row
608
+ // with no home of its own runs against the operator's own ~/.hermes).
609
+ if (url.pathname === "/api/backends" && req.method === "GET") {
610
+ return send(
611
+ res,
612
+ 200,
613
+ store.listBackends().map((row) => {
614
+ const plan = planFor(row);
615
+ return {
616
+ id: row.id, label: row.label, kind: row.kind, cmd: row.cmd, args: row.args,
617
+ env: Object.keys(row.env ?? {}), home: plan.home, profile: row.profile,
618
+ cwd: row.cwd, notes: row.notes, builtin: row.builtin,
619
+ // Which channel carries this backend's plan (P1). Sent with the list because the
620
+ // settings page shows it next to the row: it is the one declaration an operator may
621
+ // want to flip when an agent gains (or loses) native plan support.
622
+ nativePlanSource: row.nativePlanSource,
623
+ warnings: plan.warnings,
624
+ health: row.health, handshake: row.handshake,
625
+ };
626
+ }),
627
+ );
628
+ }
629
+ if (url.pathname === "/api/backends" && req.method === "POST") {
630
+ const body = (await readJson(req)) as Record<string, unknown>;
631
+ const { row, error } = coerceRow(body);
632
+ if (!row) return send(res, 400, { error });
633
+ if (store.getBackend(row.id)) return send(res, 409, { error: `backend ${row.id} already exists` });
634
+ store.upsertBackend(row);
635
+ const plan = planFor(row);
636
+ return send(res, 201, { ok: true, id: row.id, warnings: plan.warnings });
637
+ }
638
+ const beMatch = url.pathname.match(/^\/api\/backends\/([\w.-]+)(\/.*)?$/);
639
+ if (beMatch) {
640
+ const beId = beMatch[1];
641
+ const beSub = beMatch[2] ?? "";
642
+ const existing = store.getBackend(beId);
643
+ if (!existing) return send(res, 404, { error: `unknown backend: ${beId}` });
644
+ if (req.method === "GET" && beSub === "") {
645
+ const plan = planFor(existing);
646
+ return send(res, 200, { ...existing, home: plan.home, warnings: plan.warnings });
647
+ }
648
+ if ((req.method === "PATCH" || req.method === "PUT") && beSub === "") {
649
+ const body = (await readJson(req)) as Record<string, unknown>;
650
+ const { row, error } = coerceRow({ ...body, id: beId }, existing);
651
+ if (!row) return send(res, 400, { error });
652
+ store.upsertBackend(row);
653
+ const plan = planFor(row);
654
+ return send(res, 200, { ok: true, id: row.id, warnings: plan.warnings });
655
+ }
656
+ if (req.method === "DELETE" && beSub === "") {
657
+ // A row that OPEN sessions still name must stay: deleting it would leave them pointing
658
+ // at nothing and still expected to resume. Closed (archived) sessions keep the id for
659
+ // display only, so they do not block deletion.
660
+ const used = store.countOpenSessionsForBackend(beId);
661
+ if (used > 0) {
662
+ return send(res, 409, { error: `backend ${beId} is used by ${used} open session(s) — close or delete them first` });
663
+ }
664
+ store.deleteBackend(beId);
665
+ return send(res, 200, { ok: true, deleted: beId });
666
+ }
667
+ if (req.method === "POST" && beSub === "/inspect") {
668
+ // "Which tree does this row actually run?" — resolved command + the `Install directory:`
669
+ // line that names the code tree + the home it will write into. Blocked rows are NOT
670
+ // probed (probing would run the command the guard just refused).
671
+ //
672
+ // The verdict is also written back onto the row (kind=manual) so it survives the tab:
673
+ // the list can say "checked 3m ago, manual, 412ms" and a failure stays visible instead
674
+ // of disappearing with the panel that produced it.
675
+ try {
676
+ const report = await inspectRow(existing);
677
+ store.recordBackendCheck(
678
+ beId,
679
+ checkedHealth(existing.health, {
680
+ status: report.status,
681
+ kind: "manual",
682
+ errorCode: report.errorCode,
683
+ message: report.error ?? report.acpCheck?.output ?? null,
684
+ latencyMs: report.latencyMs,
685
+ }),
686
+ );
687
+ return send(res, 200, report);
688
+ } catch (e) {
689
+ const message = e instanceof Error ? e.message : String(e);
690
+ store.recordBackendCheck(
691
+ beId,
692
+ checkedHealth(existing.health, {
693
+ status: "offline", kind: "manual", errorCode: classifyError(e), message,
694
+ }),
695
+ );
696
+ return send(res, 500, { error: message });
697
+ }
698
+ }
699
+ }
700
+ if (url.pathname === "/api/sessions" && req.method === "GET") {
701
+ // `pending` rides along because it is STATE, not an event: a page that loads or refreshes
702
+ // after the request was raised never saw the event, and would otherwise show nothing.
703
+ return send(res, 200, { live: mgr.list(), archived: mgr.archived(), pending: mgr.pendingPermissions() });
704
+ }
705
+ // The session's plan object — what the cockpit's card renders. STATE too, and the one piece of
706
+ // it that cannot be replayed: after the agent process died there are no frames left to read,
707
+ // and this row is the only thing that still knows the plan (design-plan-service.md §1/§8).
708
+ const planReq = /^\/api\/sessions\/([^/]+)\/plan$/.exec(url.pathname);
709
+ if (planReq && req.method === "GET") {
710
+ return send(res, 200, { plan: store.getPlan(decodeURIComponent(planReq[1])) });
711
+ }
712
+ // Directory browser for the new-slot workspace picker (dirs only, one level).
713
+ if (url.pathname === "/api/fs/dirs" && req.method === "GET") {
714
+ try {
715
+ // `sessionId` resolves the slot's workspace; `path` wins when both are given
716
+ // (clicking through the tree is not a workspace change).
717
+ const sessionId = url.searchParams.get("sessionId");
718
+ const explicit = url.searchParams.get("path");
719
+ const root = sessionId ? sessionRoot(sessionId) : null;
720
+ const listing = listDirs(explicit ?? root?.path ?? null, { includeFiles: url.searchParams.get("files") === "1" });
721
+ return send(res, 200, { ...listing, root: root?.path ?? null, recent: store.recentCwds(8) });
722
+ } catch (e) {
723
+ if (e instanceof FsError) {
724
+ return send(res, e.code === "not_found" ? 404 : 400, { error: e.message, code: e.code });
725
+ }
726
+ throw e;
727
+ }
728
+ }
729
+ // Read-only file preview for the workspace panel (▤ → Files).
730
+ if (url.pathname === "/api/fs/file" && req.method === "GET") {
731
+ try {
732
+ const req0 = url.searchParams.get("path");
733
+ if (!req0) return send(res, 400, { error: "path is required", code: "bad_path" });
734
+ const maxBytes = Number(url.searchParams.get("maxBytes") || 0) || undefined;
735
+ return send(res, 200, readTextFile(req0, { maxBytes }));
736
+ } catch (e) {
737
+ if (e instanceof FsError) {
738
+ return send(res, e.code === "not_found" ? 404 : 400, { error: e.message, code: e.code });
739
+ }
740
+ throw e;
741
+ }
742
+ }
743
+ // What IS this file? The panel asks before reading: an image or a PDF should not be pulled
744
+ // through the text endpoint to learn that it is not text, and `kind` decides which renderer
745
+ // and whether an editor is offered. Cheap: a stat plus a hash, no bytes decoded.
746
+ if (url.pathname === "/api/fs/stat" && req.method === "GET") {
747
+ try {
748
+ return send(res, 200, statFile(url.searchParams.get("path")));
749
+ } catch (e) {
750
+ if (e instanceof FsError) {
751
+ return send(res, e.code === "not_found" ? 404 : 400, { error: e.message, code: e.code });
752
+ }
753
+ throw e;
754
+ }
755
+ }
756
+ // The file's RAW BYTES, for the renderers that need the real thing: an <img> in a reply,
757
+ // a PDF in the preview panel, an audio/video tag. Before this existed there was no way to
758
+ // show a local image at all (the read endpoint decodes utf-8 text and calls binary files
759
+ // "binary file"), so every `![shot](/Users/…/shot.png)` an agent wrote was a broken image.
760
+ //
761
+ // Same trust model as the endpoints above: any path the server process can read, because
762
+ // the operator can already point an agent at any directory and browse it via /api/fs/dirs.
763
+ // Behind the same auth gate. `inline` on purpose — a preview must not turn into a download.
764
+ if (url.pathname === "/api/fs/raw" && req.method === "GET") {
765
+ try {
766
+ const req0 = url.searchParams.get("path");
767
+ if (!req0) return send(res, 400, { error: "path is required", code: "bad_path" });
768
+ const info = statFile(req0);
769
+ res.writeHead(200, {
770
+ "content-type": info.contentType,
771
+ "content-length": String(info.size),
772
+ "cache-control": "no-store",
773
+ "content-disposition": "inline",
774
+ });
775
+ const stream = fs.createReadStream(info.path);
776
+ stream.on("error", () => res.destroy());
777
+ // The operator may close the panel mid-stream; an unhandled 'error' on the response
778
+ // would crash the process for a click that is not an error.
779
+ res.on("close", () => stream.destroy());
780
+ return stream.pipe(res);
781
+ } catch (e) {
782
+ if (e instanceof FsError) {
783
+ return send(res, e.code === "not_found" ? 404 : 400, { error: e.message, code: e.code });
784
+ }
785
+ throw e;
786
+ }
787
+ }
788
+ // Save an edit made in the panel. 409 carries the CURRENT bytes when the file changed on
789
+ // disk while the operator was typing, so the UI can offer to reload instead of choosing
790
+ // silently whose work to lose (see writeTextFile).
791
+ if (url.pathname === "/api/fs/file" && (req.method === "PUT" || req.method === "POST")) {
792
+ const body = await readJson(req);
793
+ try {
794
+ const path0 = typeof body.path === "string" ? body.path : "";
795
+ if (!path0) return send(res, 400, { error: "path is required", code: "bad_path" });
796
+ const content = typeof body.content === "string" ? body.content : "";
797
+ const ifHash = typeof body.ifHash === "string" ? body.ifHash : undefined;
798
+ return send(res, 200, { ok: true, ...writeTextFile(path0, content, { ifHash }) });
799
+ } catch (e) {
800
+ if (e instanceof ChangedOnDiskError) {
801
+ return send(res, 409, {
802
+ error: "changed_on_disk",
803
+ path: e.target,
804
+ hash: e.currentHash,
805
+ content: e.currentContent,
806
+ truncated: e.truncated,
807
+ hint: "the file changed on disk since you opened it",
808
+ });
809
+ }
810
+ if (e instanceof FsError) {
811
+ return send(res, e.code === "not_found" ? 404 : 400, { error: e.message, code: e.code });
812
+ }
813
+ throw e;
814
+ }
815
+ }
816
+ // The windows remembered per model (the settings page and scripts read this).
817
+ if (url.pathname === "/api/context-limits" && req.method === "GET") {
818
+ return send(res, 200, { limits: store.listModelLimits() });
819
+ }
820
+ // Operator settings: the page reads and writes this. Secrets only ever leave
821
+ // through a mask (publicSettings) — an unchanged mask round-trips as "keep".
822
+ if (url.pathname === "/api/settings" && req.method === "GET") {
823
+ return send(res, 200, publicSettings());
824
+ }
825
+ if (url.pathname === "/api/settings" && (req.method === "PUT" || req.method === "POST")) {
826
+ const body = await readJson(req);
827
+ try {
828
+ if (body.voice && typeof body.voice === "object") saveSettings(body.voice as Record<string, unknown>);
829
+ if (body.theme && typeof body.theme === "object") saveTheme(body.theme as Record<string, unknown>);
830
+ if (body.call && typeof body.call === "object") saveCall(body.call as Record<string, unknown>);
831
+ if (body.prefs && typeof body.prefs === "object") savePrefs(body.prefs as Record<string, unknown>);
832
+ if (!body.voice && !body.theme && !body.call && !body.prefs) saveSettings(body);
833
+ return send(res, 200, publicSettings());
834
+ } catch (e) {
835
+ return send(res, 400, { error: String((e as Error)?.message ?? e) });
836
+ }
837
+ }
838
+ // The model ids the configured endpoint really serves, for the settings dropdowns
839
+ // ("其它的也可选"): ask the endpoint instead of shipping a catalogue.
840
+ if (url.pathname === "/api/voice/models" && req.method === "GET") {
841
+ try {
842
+ return send(res, 200, await listVoiceModels());
843
+ } catch (e) {
844
+ const code = e instanceof VoiceError && e.code === "not_configured" ? 501 : 502;
845
+ return send(res, code, { error: String((e as Error)?.message ?? e) });
846
+ }
847
+ }
848
+ // Which hotwords the next recognition would carry, and where each came from: the
849
+ // settings page shows this instead of asking the operator to trust a textarea.
850
+ if (url.pathname === "/api/voice/hotwords" && req.method === "GET") {
851
+ const sid = url.searchParams.get("sessionId") || undefined;
852
+ const words = hotwordsFor(store, sid);
853
+ return send(res, 200, {
854
+ words: words.map((h) => ({ word: h.word, weight: h.weight, origin: h.origin })),
855
+ fixed: words.filter((h) => h.origin === "fixed").length,
856
+ dynamic: words.filter((h) => h.origin === "dynamic").length,
857
+ });
858
+ }
859
+ // What the browser should offer: it can always speak/read text itself, and the
860
+ // server adds a configured provider (voice.ts) — 百炼 or any OpenAI-compatible one.
861
+ if (url.pathname === "/api/voice" && req.method === "GET") {
862
+ return send(res, 200, voiceCapabilities());
863
+ }
864
+ if (url.pathname === "/api/tts" && req.method === "POST") {
865
+ const body = await readJson(req);
866
+ const text = String(body.text ?? "").trim();
867
+ if (!text) return send(res, 400, { error: "text is required" });
868
+ if (text.length > 4000) return send(res, 400, { error: "text too long (4000 chars max)" });
869
+ try {
870
+ const { contentType, audio } = await synthesize(text, {
871
+ voice: typeof body.voice === "string" ? body.voice : undefined,
872
+ speed: typeof body.speed === "number" ? body.speed : undefined,
873
+ });
874
+ res.writeHead(200, { "content-type": contentType, "content-length": String(audio.length), "cache-control": "no-store" });
875
+ return res.end(audio);
876
+ } catch (e) {
877
+ if (e instanceof VoiceError) {
878
+ // Leave a trace, or the only symptom is a voice that changed mid-reply (the client falls
879
+ // back to the browser voice for that sentence). Measured cause so far: 429
880
+ // Throttling.RateQuota from 百炼 when more than three clips are asked for at once.
881
+ console.error(`[agentus] tts failed: ${e.code} ${e.message.slice(0, 160)}`);
882
+ return send(res, e.code === "not_configured" ? 501 : 502, { error: e.message, code: e.code });
883
+ }
884
+ throw e;
885
+ }
886
+ }
887
+ if (url.pathname === "/api/stt" && req.method === "POST") {
888
+ const audio = await readBody(req);
889
+ if (!audio.length) return send(res, 400, { error: "empty audio body" });
890
+ try {
891
+ const text = await transcribe(audio, String(req.headers["content-type"] ?? "audio/webm"), {
892
+ filename: typeof req.headers["x-agentus-filename"] === "string" ? req.headers["x-agentus-filename"] : undefined,
893
+ language: url.searchParams.get("language") || undefined,
894
+ // the session tells the recogniser what this conversation is about (hotwords)
895
+ sessionId: url.searchParams.get("sessionId") || undefined,
896
+ });
897
+ return send(res, 200, { text });
898
+ } catch (e) {
899
+ if (e instanceof VoiceError) return send(res, e.code === "not_configured" ? 501 : 502, { error: e.message, code: e.code });
900
+ throw e;
901
+ }
902
+ }
903
+ // Terminal sessions for the workspace panel: list + kill (start/IO is the WS,
904
+ // because a shell is a stream, not a request/response).
905
+ if (url.pathname === "/api/term" && req.method === "GET") {
906
+ return send(res, 200, { sessions: terms.list() });
907
+ }
908
+ if (url.pathname === "/api/sessions" && req.method === "POST") {
909
+ const body = await readJson(req);
910
+ const backendRaw = String(body.backend ?? "");
911
+ const beRow = store.getBackend(backendRaw);
912
+ const backend = beRow || Object.prototype.hasOwnProperty.call(BACKENDS, backendRaw)
913
+ ? (backendRaw as BackendId)
914
+ : null;
915
+ if (!backend) return send(res, 400, { error: `unknown backend: ${backendRaw}` });
916
+ let cwd = String(body.cwd ?? "").trim();
917
+ // A row may carry a default working directory: that is where its kind of work belongs
918
+ // (e.g. a "hermes fork, dev tree" row that should always start in the checkout).
919
+ if (!cwd && beRow?.cwd) cwd = beRow.cwd;
920
+ if (!cwd || cwd === "~") cwd = homedir();
921
+ else if (cwd.startsWith("~/")) cwd = path.join(homedir(), cwd.slice(2));
922
+ if (!fs.existsSync(cwd) || !fs.statSync(cwd).isDirectory()) {
923
+ return send(res, 400, { error: `cwd not a directory: ${cwd}` });
924
+ }
925
+ const info = await mgr.create(backend, cwd, body.title ? String(body.title) : undefined);
926
+ emit(sessionsEvent());
927
+ return send(res, 201, info);
928
+ }
929
+ const sessMatch = url.pathname.match(/^\/api\/sessions\/([\w-]+)(\/.*)?$/);
930
+ if (sessMatch) {
931
+ const id = sessMatch[1];
932
+ const sub = sessMatch[2] ?? "";
933
+ if (req.method === "DELETE" && sub === "") {
934
+ // awaited on purpose: closeSession is async and a floating rejection
935
+ // here used to kill the whole server (QA#19, unhandled rejection)
936
+ if (mgr.hasSession(id)) {
937
+ await mgr.closeSession(id);
938
+ return send(res, 200, { closed: id });
939
+ }
940
+ // not live: either a cold slot the operator wants gone, or an unknown id.
941
+ // Purging is what makes the rail's "on disk · N" list manageable (M4).
942
+ if (store.deleteSession(id)) {
943
+ emit(sessionsEvent());
944
+ return send(res, 200, { purged: id });
945
+ }
946
+ return send(res, 404, { error: `no such session: ${id}` });
947
+ }
948
+ if (req.method === "POST" && sub === "/resume") {
949
+ // AC5's other half: bring a cold slot back to life (respawn + loadSession)
950
+ try {
951
+ const info = await mgr.resume(id);
952
+ emit(sessionsEvent());
953
+ return send(res, 200, info);
954
+ } catch (e) {
955
+ // A cold slot the agent can no longer adopt is NOT a server fault and NOT an outage:
956
+ // 409 with the reason and BOTH homes, so the client can say why and offer the only
957
+ // useful action (delete it). Before this the operator got a slot that said "ready" and
958
+ // silently ate every message (measured 2026-10-06 — see SessionUnavailable).
959
+ if (e instanceof SessionUnavailable) {
960
+ emit(sessionsEvent());
961
+ return send(res, 409, {
962
+ error: e.message,
963
+ code: e.code,
964
+ sessionHome: e.sessionHome,
965
+ rowHome: e.rowHome,
966
+ });
967
+ }
968
+ throw e;
969
+ }
970
+ }
971
+ if (req.method === "POST" && sub === "/restart") {
972
+ // Swap the agent process under a LIVE slot — the operator's "make this slot run the code I
973
+ // just changed" button. Same session, same picks, new pid. A cold slot has no process to
974
+ // replace: that is /resume, and saying so beats a 500.
975
+ if (!mgr.hasSession(id)) return send(res, 404, { error: `session ${id} has no running agent — resume it instead` });
976
+ try {
977
+ const info = await mgr.restart(id);
978
+ emit(sessionsEvent());
979
+ return send(res, 200, info);
980
+ } catch (e) {
981
+ // Same refusal shape as /resume: the agent could not adopt the session in this home, and
982
+ // the only useful action is the same one (delete the dead slot).
983
+ if (e instanceof SessionUnavailable) {
984
+ emit(sessionsEvent());
985
+ return send(res, 409, {
986
+ error: e.message,
987
+ code: e.code,
988
+ sessionHome: e.sessionHome,
989
+ rowHome: e.rowHome,
990
+ });
991
+ }
992
+ throw e;
993
+ }
994
+ }
995
+ if (req.method === "GET" && sub === "/messages") {
996
+ // Paging contract (M4):
997
+ // no params / tail=1 -> NEWEST page, oldest-first, hasOlder says more exist above
998
+ // before=<seq> -> the previous page above that seq
999
+ // after=<seq> -> forward replay rows from a reconnect anchor (WS path)
1000
+ // Page size is env-tunable so the paging path is testable without 500+ messages.
1001
+ const pageSize = Number(process.env.AGENTUS_HISTORY_PAGE || 500);
1002
+ const before = url.searchParams.get("before");
1003
+ const after = url.searchParams.get("after");
1004
+ if (before != null) {
1005
+ const page = store.messagesBefore(id, Number(before), Math.min(pageSize, 200));
1006
+ return send(res, 200, { messages: page.messages, hasOlder: page.hasMore });
1007
+ }
1008
+ if (after != null && !url.searchParams.has("tail")) {
1009
+ const msgs = store.messagesAfter(id, Number(after), pageSize);
1010
+ return send(res, 200, { messages: msgs, hasOlder: false });
1011
+ }
1012
+ const page = store.messagesTail(id, pageSize);
1013
+ return send(res, 200, { messages: page.messages, hasOlder: page.hasOlder });
1014
+ }
1015
+ if (req.method === "POST" && sub === "/prompt") {
1016
+ const body = await readJson(req);
1017
+ const text = String(body.text ?? "").trim();
1018
+ const attachments = Array.isArray(body.attachments) ? (body.attachments as PromptAttachment[]) : [];
1019
+ if (!text && !attachments.length) return send(res, 400, { error: "empty prompt" });
1020
+ // fire & forget: stream arrives via WS; client watches turn-start/end
1021
+ void runPrompt(id, text, attachments, body.interrupt === true);
1022
+ return send(res, 202, { ok: true });
1023
+ }
1024
+ if (req.method === "POST" && sub === "/workspace") {
1025
+ const body = await readJson(req);
1026
+ const raw = String(body.path ?? "").trim();
1027
+ // Empty clears the override ("back to the cwd this slot was spawned in").
1028
+ let workspace: string | null = null;
1029
+ if (raw) {
1030
+ workspace = expandUserPath(raw);
1031
+ if (!fs.existsSync(workspace) || !fs.statSync(workspace).isDirectory()) {
1032
+ return send(res, 400, { error: `not a directory: ${workspace}` });
1033
+ }
1034
+ }
1035
+ const info = mgr.setWorkspace(id, workspace);
1036
+ emit(sessionsEvent());
1037
+ return send(res, 200, info);
1038
+ }
1039
+ if (req.method === "GET" && sub === "/export") {
1040
+ // ACP has no "export" (the protocol only carries the stream; AionUi does this over
1041
+ // its own storage too) — we render from OUR persisted rows. `?format=json` is the
1042
+ // lossless dump, markdown is the readable transcript. Works for cold sessions
1043
+ // without waking the agent: no live process needed, just the DB.
1044
+ const row = store.getSession(id);
1045
+ if (!row) return send(res, 404, { error: `no such session: ${id}` });
1046
+ const format = url.searchParams.get("format") === "json" ? "json" : "md";
1047
+ const header: ExportSessionHeader = {
1048
+ id: row.id, title: row.title, backend: row.backend, cwd: row.cwd,
1049
+ workspace: row.workspace ?? null, acpSessionId: row.acpSessionId,
1050
+ createdAt: row.createdAt,
1051
+ };
1052
+ const all = store.messagesTail(id, Number.MAX_SAFE_INTEGER).messages;
1053
+ const body = format === "json" ? renderJson(header, all) : renderMarkdown(header, all);
1054
+ res.writeHead(200, {
1055
+ "content-type": format === "json" ? "application/json; charset=utf-8" : "text/markdown; charset=utf-8",
1056
+ "content-disposition": `attachment; filename="${exportFilename(row.title, format === "json" ? "json" : "md").replace(/[^\x20-\x7e]/g, "_")}"`,
1057
+ });
1058
+ res.end(body);
1059
+ return undefined;
1060
+ }
1061
+ if (req.method === "POST" && sub === "/rename") {
1062
+ const body = await readJson(req);
1063
+ const raw = body.title;
1064
+ // absent/null or an all-blank string = clear back to the generated title
1065
+ const title = raw == null ? null : String(raw);
1066
+ try {
1067
+ const info = mgr.rename(id, title);
1068
+ emit(sessionsEvent());
1069
+ return send(res, 200, info);
1070
+ } catch (e) {
1071
+ const msg = String((e as Error)?.message ?? e);
1072
+ return send(res, /no such session/.test(msg) ? 404 : 400, { error: msg });
1073
+ }
1074
+ }
1075
+ if (req.method === "POST" && sub === "/title/regenerate") {
1076
+ try {
1077
+ // Ask the agent to name this conversation from its CURRENT content (via a throwaway
1078
+ // fork), falling back to the latest prompt when it cannot. The store's authority
1079
+ // rule decides whether the visible title moves; `via` says which producer answered.
1080
+ const info = await mgr.regenerateTitle(id);
1081
+ emit(sessionsEvent());
1082
+ return send(res, 200, { info, via: mgr.lastTitleVia });
1083
+ } catch (e) {
1084
+ const msg = describeAcpError(e);
1085
+ if (/no such session/.test(msg)) return send(res, 404, { error: msg });
1086
+ return send(res, 409, { error: msg });
1087
+ }
1088
+ }
1089
+ if (req.method === "POST" && sub === "/fork") {
1090
+ try {
1091
+ // forking a cold session resumes it first (the call has to reach a live agent)
1092
+ const info = await mgr.fork(id);
1093
+ emit(sessionsEvent());
1094
+ return send(res, 200, info);
1095
+ } catch (e) {
1096
+ const msg = describeAcpError(e);
1097
+ if (/no such session|unknown session/.test(msg)) return send(res, 404, { error: msg });
1098
+ return send(res, /mid-turn|not ready/.test(msg) ? 409 : 400, { error: msg });
1099
+ }
1100
+ }
1101
+ if (req.method === "POST" && sub === "/model") {
1102
+ const body = await readJson(req);
1103
+ const modelId = String(body.modelId ?? "").trim();
1104
+ if (!modelId) return send(res, 400, { error: "modelId is required" });
1105
+ try {
1106
+ return send(res, 200, await mgr.setModel(id, modelId));
1107
+ } catch (e) {
1108
+ const msg = describeAcpError(e);
1109
+ // 409: the slot exists but has no live agent to switch (resume first)
1110
+ return send(res, /not ready|resume it first/.test(msg) ? 409 : 400, { error: msg });
1111
+ }
1112
+ }
1113
+ if (req.method === "POST" && sub === "/context-limit") {
1114
+ const body = await readJson(req);
1115
+ const raw = body.limit;
1116
+ const limit = raw === null || raw === "" || raw === undefined ? null : Number(raw);
1117
+ if (limit !== null && (!Number.isFinite(limit) || limit <= 0)) {
1118
+ return send(res, 400, { error: "limit must be a positive number of tokens (or null to clear)" });
1119
+ }
1120
+ return send(res, 200, mgr.setContextLimit(id, limit, {
1121
+ remember: body.remember === true,
1122
+ forgetModel: body.forgetModel === true,
1123
+ }));
1124
+ }
1125
+ if (req.method === "POST" && sub === "/mode") {
1126
+ const body = await readJson(req);
1127
+ await mgr.setMode(id, String(body.modeId));
1128
+ return send(res, 200, { ok: true });
1129
+ }
1130
+ if (req.method === "POST" && sub === "/cancel") {
1131
+ // REST twin of the WS "cancel" command (API symmetry: QA#21 found the
1132
+ // endpoint missing, so scripted checks could not stop a turn)
1133
+ if (!mgr.hasSession(id)) return send(res, 404, { error: `no such session: ${id}` });
1134
+ await mgr.cancel(id);
1135
+ return send(res, 200, { ok: true });
1136
+ }
1137
+ if (req.method === "POST" && sub === "/permission") {
1138
+ const body = await readJson(req);
1139
+ const requestId = String(body.requestId ?? "");
1140
+ if (!requestId) return send(res, 400, { error: "requestId required" });
1141
+ const ok = mgr.respondPermission(id, requestId, (body.decision as PermissionDecision) ?? { outcome: "cancelled" }, {
1142
+ optionKind: typeof body.optionKind === "string" ? body.optionKind : undefined,
1143
+ signature: typeof body.signature === "string" ? body.signature : undefined,
1144
+ });
1145
+ if (!ok) return send(res, 404, { error: `no pending permission: ${requestId}` });
1146
+ return send(res, 200, { ok: true });
1147
+ }
1148
+ }
1149
+ if (url.pathname === "/api/version" && req.method === "GET") {
1150
+ // Which bundle the server is serving RIGHT NOW. A page compares this with the script it
1151
+ // actually loaded: a WebView (the Android app) or a tab left open across a deploy keeps
1152
+ // running the JS it loaded, and then "the fix isn't there" is indistinguishable from
1153
+ // "the fix is broken" — measured, and it cost a whole round.
1154
+ let asset: string | null = null;
1155
+ try {
1156
+ const html = fs.readFileSync(path.join(WEB_DIST, "index.html"), "utf8");
1157
+ asset = html.match(/\/assets\/index-([A-Za-z0-9_-]+)\.js/)?.[1] ?? null;
1158
+ } catch { /* no build on disk (dev server without dist) */ }
1159
+ return send(res, 200, { asset });
1160
+ }
1161
+ if (url.pathname.startsWith("/api/")) return send(res, 404, { error: "not_found" });
1162
+ return serveStatic(req, res);
1163
+ } catch (err) {
1164
+ // "no such X" is a client error, not a server fault (QA#24)
1165
+ const msg = String((err as Error)?.message ?? err);
1166
+ const code = /no such session|no pending permission/.test(msg) ? 404 : 500;
1167
+ return send(res, code, { error: msg });
1168
+ }
1169
+ };
1170
+
1171
+ const httpServer = createServer(handleRequest);
1172
+
1173
+ // ---- WebSocket ----
1174
+ // noServer + a manual upgrade hook: the handshake must be authenticated *before*
1175
+ // it becomes a socket. A browser cannot set headers on a WS handshake, so auth
1176
+ // rides on the cookie (sent automatically, same-origin) — or ?token= for scripts.
1177
+ const wss = new WebSocketServer({ noServer: true });
1178
+ // Bound to BOTH listeners (see the TLS block before listen): a browser on the TLS port
1179
+ // needs its wss sockets too, and this handler reads the cookie / ?token=, never the scheme.
1180
+ const handleUpgrade = (req: IncomingMessage, socket: Duplex, head: Buffer) => {
1181
+ const url = new URL(req.url ?? "/", "http://x");
1182
+ // The companion's socket carries its own device token in the query — a phone cannot set
1183
+ // headers on a handshake any more than a browser can — so it is checked before the
1184
+ // operator's cookie/Basic gate.
1185
+ if (url.pathname === "/api/notify/ws") {
1186
+ notify.handleUpgrade(req, socket, head);
1187
+ return;
1188
+ }
1189
+ if (url.pathname !== "/ws" && url.pathname !== "/ws/term" && url.pathname !== "/ws/asr") {
1190
+ socket.destroy();
1191
+ return;
1192
+ }
1193
+ if (!auth.verifyBasic(req.headers.authorization)) {
1194
+ // Same outer lock as HTTP: browsers resend cached Basic credentials on the
1195
+ // handshake, so a logged-in browser passes; anything else gets nothing.
1196
+ socket.write("HTTP/1.1 401 Unauthorized\r\nwww-authenticate: Basic realm=\"Agentus\"\r\nconnection: close\r\ncontent-length: 0\r\n\r\n");
1197
+ socket.destroy();
1198
+ return;
1199
+ }
1200
+ if (!auth.authenticate(req.headers, url)) {
1201
+ socket.write("HTTP/1.1 401 Unauthorized\r\nconnection: close\r\ncontent-length: 0\r\n\r\n");
1202
+ socket.destroy();
1203
+ return;
1204
+ }
1205
+ if (url.pathname === "/ws/term") {
1206
+ termWss.handleUpgrade(req, socket, head, (ws) => termWss.emit("connection", ws, req, url));
1207
+ return;
1208
+ }
1209
+ if (url.pathname === "/ws/asr") {
1210
+ asrWss.handleUpgrade(req, socket, head, (ws) => asrWss.emit("connection", ws, req, url));
1211
+ return;
1212
+ }
1213
+ wss.handleUpgrade(req, socket, head, (ws) => wss.emit("connection", ws, req));
1214
+ };
1215
+ httpServer.on("upgrade", handleUpgrade);
1216
+ wss.on("connection", (ws) => {
1217
+ const clientId = randomUUID().slice(0, 8);
1218
+ clients.set(clientId, { ws, lastSeen: new Map() });
1219
+ const sendEvt = (e: ServerEvent) => {
1220
+ if (ws.readyState === WebSocket.OPEN) ws.send(JSON.stringify(e));
1221
+ };
1222
+ sendEvt({ t: "hello", clientId, resumed: false });
1223
+ sendEvt(sessionsEvent());
1224
+
1225
+ ws.on("message", async (data) => {
1226
+ let cmd: ClientCommand;
1227
+ try {
1228
+ cmd = JSON.parse(String(data)) as ClientCommand;
1229
+ } catch {
1230
+ return;
1231
+ }
1232
+ try {
1233
+ switch (cmd.t) {
1234
+ case "resume": {
1235
+ // AC6: replay missed messages per session after client's lastSeq.
1236
+ // `partial: true` tells the client to MERGE into what it already
1237
+ // rendered — a rebuild from this tail would drop the pre-drop prefix
1238
+ // (QA#17: 26 chars vanished after a mid-turn reconnect).
1239
+ //
1240
+ // The tail starts a few rows BEFORE the client's lastSeq, because that seq is the last row the
1241
+ // client HOLDS, not the last version of it: a streamed reply is folded into one row that keeps
1242
+ // its seq, and a tool call's output lands after the call row exists — both GROW after the client
1243
+ // last saw them. Re-sending a row the client already has is free (the client re-applies it by
1244
+ // identity), while missing the tail of a grown row leaves a bubble frozen mid-sentence.
1245
+ const RESUME_OVERLAP = 8;
1246
+ for (const [sid, seq] of Object.entries(cmd.lastSeq)) {
1247
+ const msgs = store.messagesAfter(sid, Math.max(0, seq - RESUME_OVERLAP) - 1);
1248
+ if (msgs.length) sendEvt({ t: "messages", sessionId: sid, messages: msgs, hasMore: false, partial: true });
1249
+ }
1250
+ sendEvt(sessionsEvent());
1251
+ break;
1252
+ }
1253
+ case "prompt":
1254
+ void runPrompt(cmd.sessionId, cmd.text, cmd.attachments ?? [], cmd.interrupt === true);
1255
+ break;
1256
+ case "cancel":
1257
+ await mgr.cancel(cmd.sessionId);
1258
+ break;
1259
+ case "set-mode":
1260
+ await mgr.setMode(cmd.sessionId, cmd.modeId);
1261
+ break;
1262
+ case "set-config":
1263
+ await mgr.setConfig(cmd.sessionId, cmd.configId, cmd.value);
1264
+ break;
1265
+ case "set-model":
1266
+ await mgr.setModel(cmd.sessionId, cmd.modelId);
1267
+ break;
1268
+ case "respond-permission": {
1269
+ const applied = mgr.respondPermission(cmd.sessionId, cmd.requestId, cmd.decision, {
1270
+ optionKind: cmd.optionKind,
1271
+ signature: cmd.signature,
1272
+ });
1273
+ // The answer reached nobody: the request is gone (our own timeout, or the agent gave
1274
+ // up first). Tell the page, so the click has a visible outcome either way.
1275
+ if (!applied) sendEvt({ t: "permission-expired", requestId: cmd.requestId, sessionId: cmd.sessionId });
1276
+ break;
1277
+ }
1278
+ default:
1279
+ break;
1280
+ }
1281
+ } catch (e) {
1282
+ sendEvt({ t: "error", error: describeAcpError(e) });
1283
+ }
1284
+ });
1285
+ ws.on("close", () => clients.delete(clientId));
1286
+ });
1287
+
1288
+ // ---- the workspace panel's terminal -----------------------------------------
1289
+ // A shell is a stream, so it gets its own socket instead of riding the event bus:
1290
+ // one socket = one shell, and closing the socket (or the tab) kills the shell.
1291
+ const termWss = new WebSocketServer({ noServer: true });
1292
+ termWss.on("connection", (ws: WebSocket, _req: IncomingMessage, url: URL) => {
1293
+ const sessionId = url.searchParams.get("sessionId") ?? "";
1294
+ const root = sessionRoot(sessionId);
1295
+ if (!root) {
1296
+ ws.send(JSON.stringify({ t: "term-error", error: `no such session: ${sessionId}` }));
1297
+ ws.close();
1298
+ return;
1299
+ }
1300
+ let term: ReturnType<typeof terms.start> | null = null;
1301
+ try {
1302
+ // The client sends its viewport size on the socket URL: the pty must be born at the right size
1303
+ // (the shell reads it when it paints the first prompt). Later changes arrive as term-resize.
1304
+ const cols = Number(url.searchParams.get("cols") || 0);
1305
+ const rows = Number(url.searchParams.get("rows") || 0);
1306
+ term = terms.start(root.path, cols && rows ? { cols, rows } : undefined);
1307
+ } catch (e) {
1308
+ ws.send(JSON.stringify({ t: "term-error", error: String((e as Error)?.message ?? e) }));
1309
+ ws.close();
1310
+ return;
1311
+ }
1312
+ const session = term;
1313
+ const send = (msg: unknown): void => {
1314
+ if (ws.readyState === WebSocket.OPEN) ws.send(JSON.stringify(msg));
1315
+ };
1316
+ // The buffered tail goes out first, so a reconnect (or a second tab) lands on the scrollback the
1317
+ // way ttyd's RESUME does instead of a blank screen.
1318
+ if (session.buffer) send({ t: "term-data", id: session.id, data: session.buffer });
1319
+ send({ t: "term-ready", id: session.id, cwd: session.cwd, pid: session.pid, tty: session.tty });
1320
+ const onData = (chunk: string): void => send({ t: "term-data", id: session.id, data: chunk });
1321
+ session.subscribers.add(onData);
1322
+ session.child.on("exit", () => send({ t: "term-exit", id: session.id }));
1323
+ ws.on("message", (data) => {
1324
+ let cmd: { t?: string; data?: string; cols?: number; rows?: number };
1325
+ try {
1326
+ cmd = JSON.parse(String(data));
1327
+ } catch {
1328
+ return;
1329
+ }
1330
+ if (cmd.t === "term-input" && typeof cmd.data === "string") {
1331
+ try {
1332
+ session.child.stdin?.write(cmd.data);
1333
+ } catch (e) {
1334
+ send({ t: "term-error", error: `write failed: ${String((e as Error)?.message ?? e)}` });
1335
+ }
1336
+ } else if (cmd.t === "term-resize" && typeof cmd.cols === "number" && typeof cmd.rows === "number") {
1337
+ session.resize(cmd.cols, cmd.rows);
1338
+ } else if (cmd.t === "term-close") {
1339
+ terms.kill(session.id);
1340
+ ws.close();
1341
+ }
1342
+ });
1343
+ const cleanup = (): void => {
1344
+ session.subscribers.delete(onData);
1345
+ terms.kill(session.id);
1346
+ };
1347
+ ws.on("close", cleanup);
1348
+ ws.on("error", cleanup);
1349
+ });
1350
+
1351
+ // ---- dictation: the streaming ASR path ---------------------------------------
1352
+ // DashScope's streaming recogniser is a WebSocket that authenticates with a header,
1353
+ // which a browser cannot set on a handshake — and the key must not leave the server
1354
+ // anyway. So the browser streams 16 kHz mono PCM to us and we own the upstream socket.
1355
+ // One socket = one utterance; closing it finalises the task.
1356
+ const asrWss = new WebSocketServer({ noServer: true });
1357
+ asrWss.on("connection", (ws: WebSocket, _req: IncomingMessage, url: URL) => {
1358
+ const reply = (m: unknown): void => {
1359
+ if (ws.readyState === WebSocket.OPEN) ws.send(JSON.stringify(m));
1360
+ };
1361
+ const caps = voiceCapabilities().stt as { server?: boolean; streaming?: boolean; model?: string | null };
1362
+ if (!caps.streaming) {
1363
+ reply({ t: "asr-error", error: "streaming ASR is off (settings: provider \u767e\u70bc + a streaming model)" });
1364
+ ws.close();
1365
+ return;
1366
+ }
1367
+ const sessionId = url.searchParams.get("sessionId") || undefined;
1368
+ const words = hotwordsFor(store, sessionId);
1369
+ let live: ReturnType<typeof openDashscopeStream>;
1370
+ try {
1371
+ live = openDashscopeStream({
1372
+ vocabulary: vocabularyOf(words),
1373
+ context: dictationContext(sessionId),
1374
+ onPartial: (text) => reply({ t: "asr-partial", text }),
1375
+ onFinal: (text) => reply({ t: "asr-final", text }),
1376
+ onError: (error) => reply({ t: "asr-error", error }),
1377
+ onClose: () => {
1378
+ reply({ t: "asr-done" });
1379
+ // one socket = one utterance: end it here so the browser's next dictation starts
1380
+ // from a clean run-task instead of reusing a finished task id.
1381
+ setTimeout(() => { try { ws.close(); } catch { /* already gone */ } }, 120);
1382
+ },
1383
+ });
1384
+ } catch (e) {
1385
+ reply({ t: "asr-error", error: String((e as Error)?.message ?? e) });
1386
+ ws.close();
1387
+ return;
1388
+ }
1389
+ const stream = live;
1390
+ void stream.started
1391
+ .then(() => reply({
1392
+ t: "asr-ready",
1393
+ model: caps.model ?? null,
1394
+ hotwords: words.slice(0, 12).map((h) => h.word),
1395
+ hotwordCount: words.length,
1396
+ }))
1397
+ .catch((e: unknown) => reply({ t: "asr-error", error: String((e as Error)?.message ?? e) }));
1398
+ ws.on("message", (data: Buffer, isBinary: boolean) => {
1399
+ if (isBinary) {
1400
+ stream.sendAudio(Buffer.isBuffer(data) ? data : Buffer.from(data));
1401
+ return;
1402
+ }
1403
+ try {
1404
+ const cmd = JSON.parse(String(data)) as { t?: string };
1405
+ if (cmd.t === "asr-stop") stream.stop();
1406
+ } catch { /* malformed control frame: ignore */ }
1407
+ });
1408
+ const done = (): void => { try { stream.stop(); } catch { /* already closed */ } };
1409
+ ws.on("close", done);
1410
+ ws.on("error", done);
1411
+ });
1412
+
1413
+ // ---- graceful exit: SIGTERM children before we die (design.md §8-1) ----
1414
+ for (const sig of ["SIGINT", "SIGTERM"] as const) {
1415
+ process.on(sig, async () => {
1416
+ console.log(`[agentus] ${sig}: shutting down sessions`);
1417
+ await mgr.shutdown();
1418
+ const shells = terms.killAll();
1419
+ if (shells) console.log(`[agentus] killed ${shells} terminal shell(s)`);
1420
+ store.close();
1421
+ process.exit(0);
1422
+ });
1423
+ }
1424
+
1425
+ httpServer.listen(PORT, "0.0.0.0", () => {
1426
+ installSafetyNet(); // we own the port: from here on, survive per-request errors
1427
+ console.log(`[agentus-server] http://0.0.0.0:${PORT} (web dist: ${WEB_DIST})`);
1428
+ // Which directory a boot actually opened is the one fact that must never be guessed:
1429
+ // a wrong answer here reads as "my sessions are gone" and every other log line is fine.
1430
+ console.log(`[agentus-server] data dir: ${DATA_DIR} (${DATA_DIR_SOURCE})`
1431
+ + (DATA_DIR === REPO_DATA_DIR
1432
+ ? `\n[agentus-server] this install defaults to ${DEFAULT_DATA_DIR}; AGENTUS_DATA=${DEFAULT_DATA_DIR} moves it (a new dir starts with an empty session list)`
1433
+ : ""));
1434
+ // Auth state belongs in the boot log: "is this thing locked, and with which
1435
+ // password?" is the first question anyone asks when it is on a LAN.
1436
+ if (!authStatus.enabled) {
1437
+ console.warn("[agentus-server] ⚠ AGENTUS_AUTH=off — every visitor can drive your agents. Local dev only.");
1438
+ } else if (authStatus.usingDefaultPassword) {
1439
+ console.warn(
1440
+ `[agentus-server] ⚠ auth ON with the DEFAULT password (${auth.AUTH_DEFAULT_USERNAME}/${auth.AUTH_DEFAULT_PASSWORD}). `
1441
+ + "Anyone on this network can spawn agents in any directory.\n"
1442
+ + "[agentus-server] set AGENTUS_PASSWORD (or AGENTUS_PASSWORD_HASH) before exposing the port.",
1443
+ );
1444
+ } else {
1445
+ console.log(`[agentus-server] auth on: user "${authStatus.username}", ${Math.round(authStatus.sessionTtlMs / 86400000)}d sessions`);
1446
+ }
1447
+ if (authStatus.tokenFile) console.log(`[agentus-server] machine token: ${authStatus.tokenFile} (curl -H "Authorization: Bearer $(cat …)")`);
1448
+ const basic = auth.basicAuthConfig();
1449
+ console.log(basic
1450
+ ? `[agentus-server] HTTP Basic ON (user "${basic.user}") — every path, including /healthz and the WS upgrade`
1451
+ : "[agentus-server] HTTP Basic off (set AGENTUS_BASIC_AUTH=user:pass before exposing a tunnel)");
1452
+ // A wrong/absent dist used to fail silently: the browser's service worker served a stale
1453
+ // shell, every request looked fine, and the operator just saw a blank page (QA R36).
1454
+ // Say it out loud at boot instead.
1455
+ if (!fs.existsSync(path.join(WEB_DIST, "index.html"))) {
1456
+ console.error(
1457
+ `[agentus-server] ⚠ web build missing at ${WEB_DIST} — the UI will 404 (API still works).\n`
1458
+ + (fs.existsSync(path.resolve(HERE, "../../web/src"))
1459
+ ? `[agentus-server] build it with: NODE_ENV=development npm run build -w @agentus/web`
1460
+ : `[agentus-server] this package looks damaged (no prebuilt bundle) — reinstall it: npm i -g agentus@latest`),
1461
+ );
1462
+ }
1463
+ });
1464
+ // ---- optional TLS listener (self-signed) ----
1465
+ // A tunnel that terminates on the public internet must NOT be plain HTTP: the login
1466
+ // password and the session cookie would cross it in the clear. SEPARATE from PORT on
1467
+ // purpose, so the LAN path stays plain HTTP — no cert warning in the house, curl
1468
+ // unchanged. (Config constants live at the top, next to the other paths.)
1469
+ if (TLS_READY) {
1470
+ const httpsServer = createHttpsServer({ cert: fs.readFileSync(TLS_CERT), key: fs.readFileSync(TLS_KEY) }, handleRequest);
1471
+ httpsServer.on("upgrade", handleUpgrade);
1472
+ httpsServer.listen(TLS_PORT, "0.0.0.0", () => {
1473
+ console.log(`[agentus-server] https://0.0.0.0:${TLS_PORT} (self-signed, SAN ${TLS_CERT} — point a public tunnel at THIS port; ${PORT} stays for the LAN)`);
1474
+ });
1475
+ // A busy TLS port must not take the LAN listener down with it: log, keep serving.
1476
+ httpsServer.on("error", (err) => {
1477
+ console.error(`[agentus-server] tls listener failed on ${TLS_PORT}: ${(err as Error).message}`);
1478
+ });
1479
+ } else if (TLS_PORT > 0) {
1480
+ console.log(`[agentus-server] tls off: no cert at ${TLS_CERT} (create it with scripts/make-cert.sh, or set AGENTUS_TLS_PORT=0)`);
1481
+ }
1482
+
1483
+ httpServer.on("error", (err) => {
1484
+ console.error(`[agentus] cannot listen on ${PORT}: ${(err as Error).message}`);
1485
+ process.exit(1); // fail fast: never linger as a listener-less zombie
1486
+ });