agentus 0.3.2 → 0.3.4

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.
@@ -1,7 +1,6 @@
1
1
  // Agentus server entry: HTTP (REST + static web build) + WebSocket relay.
2
2
  // The browser never talks to CLI subprocesses directly (design.md §1).
3
3
  import { createServer, type IncomingMessage, type ServerResponse } from "node:http";
4
- import { createServer as createHttpsServer } from "node:https";
5
4
  import type { Duplex } from "node:stream";
6
5
  import { createHash, randomUUID } from "node:crypto";
7
6
  import { homedir } from "node:os";
@@ -31,7 +30,7 @@ const PORT = Number(process.env.AGENTUS_PORT ?? 8788);
31
30
  // cwd-relative path would silently open a second database (the operator's slots would
32
31
  // "disappear"). Both entry points must land on the same store.
33
32
  const HERE = path.dirname(fileURLToPath(import.meta.url)); // …/packages/server/src
34
- // WHERE STATE LIVES — sqlite, credentials.json, auth.token, tls/, settings.json.
33
+ // WHERE STATE LIVES — sqlite, credentials.json, auth.token, settings.json.
35
34
  // 1. `AGENTUS_DATA` when it is set. Every launcher and dev/QA instance pins it, so the
36
35
  // rules below can never move a running install's data out from under it.
37
36
  // 2. `~/.agentus` once it holds something, or when there is no repo-side data yet.
@@ -41,7 +40,7 @@ const HERE = path.dirname(fileURLToPath(import.meta.url)); // …/packages/serve
41
40
  // directory won and how to move).
42
41
  // NOTHING may write inside node_modules: for an installed package
43
42
  // `<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.
43
+ // transcript and the login with it.
45
44
  const DEFAULT_DATA_DIR = path.join(homedir(), ".agentus");
46
45
  const REPO_DATA_DIR = path.resolve(HERE, "../.data");
47
46
  const hasData = (dir: string): boolean => {
@@ -69,19 +68,6 @@ const APK_FILE = process.env.AGENTUS_APK
69
68
  path.join(DATA_DIR, "agentus-companion.apk"),
70
69
  ].find((p) => fs.existsSync(p))
71
70
  ?? 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
71
  // `agentus --where` (bin/agentus.mjs) asks the ONE place that decides these paths to say
86
72
  // what it decided, then exits — before any listener, store or subprocess exists. Print and
87
73
  // exit here rather than re-deriving the same rules in the CLI: "where is my data" must not
@@ -92,12 +78,11 @@ if (process.env.AGENTUS_PRINT_PATHS) {
92
78
  console.log(`[agentus] data dir ${DATA_DIR} (${DATA_DIR_SOURCE})`);
93
79
  console.log(`[agentus] web bundle ${WEB_DIST} (${has(path.join(WEB_DIST, "index.html"))})`);
94
80
  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
81
  console.log(`[agentus] http port ${PORT}`);
97
82
  process.exit(0);
98
83
  }
99
84
 
100
- fs.mkdirSync(DATA_DIR, { recursive: true, mode: 0o700 }); // 0700: credentials + TLS key live here
85
+ fs.mkdirSync(DATA_DIR, { recursive: true, mode: 0o700 }); // 0700: credentials live here
101
86
  const store = new Store(path.join(DATA_DIR, "agentus.sqlite"));
102
87
  // M6: an existing cockpit must behave exactly as before, so the registry starts as a copy of
103
88
  // the builtin (env-driven) rows and the operator edits from there. Seeding is one-shot: an
@@ -223,16 +208,25 @@ notify.onAction(({ ref, action, activity, device }) => {
223
208
  console.log(`[notify] action device=${device.name} activity=${activity.activityId} action=${action.actionId} type=${ref?.type ?? "none"}`);
224
209
  if (!ref) return; // the button existed but nobody claimed it: log and drop, never guess
225
210
  switch (ref.type) {
226
- case "permission":
211
+ case "permission": {
227
212
  // The lock screen answering an agent's permission prompt — the whole point of the channel.
228
- mgr.respondPermission(String(ref.sessionId), String(ref.requestId), {
213
+ const applied = mgr.respondPermission(String(ref.sessionId), String(ref.requestId), {
229
214
  outcome: "selected",
230
215
  optionId: String(ref.optionId),
231
216
  }, {
232
217
  optionKind: typeof ref.optionKind === "string" ? ref.optionKind : undefined,
233
218
  signature: typeof ref.signature === "string" ? ref.signature : undefined,
234
219
  });
220
+ // The request is gone (our timeout, the agent gave up, its process died). Take the card off
221
+ // the phone instead of leaving a 「待你确认」 that no longer has a request behind it — the
222
+ // browser gets `permission-expired` for the same click, and a phone that keeps a dead button
223
+ // is the same defect one device over.
224
+ if (!applied) {
225
+ console.log(`[notify] permission answer dropped for ${ref.requestId}: card dismissed`);
226
+ notify.dismiss(`perm:${ref.requestId}`);
227
+ }
235
228
  return;
229
+ }
236
230
  case "open":
237
231
  return; // the app already opened the deeplink; nothing to do server-side
238
232
  default:
@@ -240,6 +234,19 @@ notify.onAction(({ ref, action, activity, device }) => {
240
234
  }
241
235
  });
242
236
 
237
+ // The page channel of the same decision: one `notify` frame to every cockpit websocket.
238
+ //
239
+ // No cursor, no replay, and no per-client state — deliberately unlike the device socket above. The
240
+ // phone replays because it was PAIRED and may have been asleep in a pocket; a browser tab that missed
241
+ // frames has a transcript to catch up in, and a wall of stale cards after a reload is worse than none.
242
+ // Every gate that matters (which kinds, whether the channel is on) was applied in the notify centre.
243
+ notify.onNotice((notice) => {
244
+ const wire = JSON.stringify({ t: "notify", notice });
245
+ for (const { ws } of clients.values()) {
246
+ if (ws.readyState === WebSocket.OPEN) ws.send(wire);
247
+ }
248
+ });
249
+
243
250
  // Operator settings (voice, theme, call knobs, talk-to-agent prefs) live in the store's
244
251
  // `settings` table: the UI owns them and env only bootstraps them (settings.ts). Read
245
252
  // before anything asks.
@@ -270,6 +277,11 @@ setHotwordSource((sessionId) => hotwordsFor(store, sessionId).map((h) => h.word)
270
277
  };
271
278
  const envIdle = envNum(process.env.AGENTUS_IDLE_KILL_MIN);
272
279
  const envIdleMs = envIdle != null ? envIdle * 60_000 : null;
280
+ // 热备 (warm slots): same ABSENT≠0 discipline as above. The TTL takes a FRACTION of a minute so the
281
+ // warm-EXPIRY path can be exercised in seconds rather than by waiting out a day.
282
+ const envWarm = envNum(process.env.AGENTUS_WARM_SLOTS);
283
+ const envWarmTtlMin = envNum(process.env.AGENTUS_WARM_TTL_MIN);
284
+ const envWarmTtlMs = envWarmTtlMin != null ? envWarmTtlMin * 60_000 : null;
273
285
  const envSweep = envNum(process.env.AGENTUS_IDLE_SWEEP_MS);
274
286
  const sweepMs = envSweep != null && envSweep >= 1000 ? envSweep : 30_000;
275
287
  const source = envIdleMs != null ? "AGENTUS_IDLE_KILL_MIN" : "settings.agent.idleKillMin";
@@ -278,8 +290,18 @@ setHotwordSource((sessionId) => hotwordsFor(store, sessionId).map((h) => h.word)
278
290
  envIdleMs != null ? envIdle : agentSettings().idleKillMin
279
291
  } min (${source}), sweep ${Math.round(sweepMs / 1000)}s, 0 = off`,
280
292
  );
293
+ // The warm set is the other half of the same decision (which slots survive being quiet) and it is
294
+ // NEVER inferred from the idle value: 「最近 N 个即使空闲也保留」 and 「其余空闲多久收」 are two
295
+ // answers. Printing both with their sources is what turns 「为什么它被收了」 into one glance.
296
+ console.log(
297
+ `[agentus] warm slots: ${envWarm ?? agentSettings().warmSlots} kept up to ${
298
+ envWarmTtlMs != null ? envWarmTtlMs / 3_600_000 : agentSettings().warmTtlHours
299
+ } h untouched (${envWarm != null || envWarmTtlMs != null ? "AGENTUS_WARM_*" : "settings.agent"}), 0 = off / never`,
300
+ );
281
301
  mgr.startIdleReaper({
282
302
  idleMs: () => envIdleMs ?? agentSettings().idleKillMin * 60_000,
303
+ warmSlots: () => envWarm ?? agentSettings().warmSlots,
304
+ warmTtlMs: () => envWarmTtlMs ?? agentSettings().warmTtlHours * 3_600_000,
283
305
  watched: (id) => notify.operatorWatching(id),
284
306
  intervalMs: sweepMs,
285
307
  });
@@ -393,9 +415,10 @@ function serveStatic(req: IncomingMessage, res: ServerResponse): void {
393
415
  fs.createReadStream(file).pipe(res);
394
416
  }
395
417
 
396
- // One request handler, two listeners: plain HTTP on PORT (LAN, loopback, curl/scripts)
397
- // and TLS on TLS_PORT (the port a public tunnel points at). Shared on purpose — auth,
398
- // routes and static serving must not drift between the two.
418
+ // One request handler, plain HTTP on PORT (LAN, loopback, curl/scripts).
419
+ // TLS is NOT terminated in-process: put a TLS-terminating reverse proxy (or a tunnel that
420
+ // does its own `public_tls`) in FRONT of this port. That keeps the LAN path warning-free
421
+ // and `curl`/scripts byte-identical, and keeps cert lifecycle out of the app.
399
422
  const handleRequest = async (req: IncomingMessage, res: ServerResponse) => {
400
423
  const url = new URL(req.url ?? "/", "http://x");
401
424
  try {
@@ -437,24 +460,6 @@ const handleRequest = async (req: IncomingMessage, res: ServerResponse) => {
437
460
  return res.end("Agentus: authentication required\n");
438
461
  }
439
462
 
440
- // GET /cert.crt — the ISSUER to install into a phone's trust store (the "proceed
441
- // anyway" click, once per device, is the whole cost of TOFU; this makes it a download).
442
- // With make-cert.sh's root+leaf setup this is the root, so the leaf can be rotated
443
- // yearly without every device having to re-install.
444
- // PUBLIC material only: the key never leaves <DATA>/tls/.
445
- if (url.pathname === "/cert.crt") {
446
- if (!TLS_READY) return send(res, 404, { error: "tls is off on this server" });
447
- const der = fs.readFileSync(fs.existsSync(TLS_CA) ? TLS_CA : TLS_CERT);
448
- res.writeHead(200, {
449
- // Apple's installer claims this type and opens the profile flow directly;
450
- // Android/Chrome just downloads the file.
451
- "content-type": "application/x-x509-ca-cert",
452
- "content-disposition": 'attachment; filename="agentus.crt"',
453
- "content-length": String(der.length),
454
- });
455
- return res.end(der);
456
- }
457
-
458
463
  // ---- the phone's onboarding page + the APK, both behind the operator login ----------
459
464
  // The pairing code lives on this page, so it is NOT public: an unauthenticated visitor is
460
465
  // sent to the login screen (the SPA at /) and comes back with a session cookie.
@@ -1235,7 +1240,14 @@ async function post(path){const r=await fetch(path,{method:'POST'});alert(r.ok?'
1235
1240
  const html = fs.readFileSync(path.join(WEB_DIST, "index.html"), "utf8");
1236
1241
  asset = html.match(/\/assets\/index-([A-Za-z0-9_-]+)\.js/)?.[1] ?? null;
1237
1242
  } catch { /* no build on disk (dev server without dist) */ }
1238
- return send(res, 200, { asset });
1243
+ // …and `dev`: WHICH INSTANCE this is. Only two things in the UI care, and both are about
1244
+ // keeping a QA backend out of the operator's way — the mock agent shows up in the new-session
1245
+ // form on a dev instance only. The marker is the dev runner's own environment (`DEV_TREE`,
1246
+ // written by scripts/dev.sh; also AGENTUS_DEV=1 for a launcher of one's own); the live
1247
+ // launcher sets neither, so live answers false. An instance-level fact, so it rides the
1248
+ // endpoint that already describes the instance rather than every backend row.
1249
+ const dev = Boolean(process.env.DEV_TREE) || process.env.AGENTUS_DEV === "1";
1250
+ return send(res, 200, { asset, dev });
1239
1251
  }
1240
1252
  if (url.pathname.startsWith("/api/")) return send(res, 404, { error: "not_found" });
1241
1253
  return serveStatic(req, res);
@@ -1254,8 +1266,7 @@ const httpServer = createServer(handleRequest);
1254
1266
  // it becomes a socket. A browser cannot set headers on a WS handshake, so auth
1255
1267
  // rides on the cookie (sent automatically, same-origin) — or ?token= for scripts.
1256
1268
  const wss = new WebSocketServer({ noServer: true });
1257
- // Bound to BOTH listeners (see the TLS block before listen): a browser on the TLS port
1258
- // needs its wss sockets too, and this handler reads the cookie / ?token=, never the scheme.
1269
+ // Bound to the single HTTP listener; this handler reads the cookie / ?token=, never the scheme.
1259
1270
  const handleUpgrade = (req: IncomingMessage, socket: Duplex, head: Buffer) => {
1260
1271
  const url = new URL(req.url ?? "/", "http://x");
1261
1272
  // The companion's socket carries its own device token in the query — a phone cannot set
@@ -1581,25 +1592,6 @@ httpServer.listen(PORT, "0.0.0.0", () => {
1581
1592
  );
1582
1593
  }
1583
1594
  });
1584
- // ---- optional TLS listener (self-signed) ----
1585
- // A tunnel that terminates on the public internet must NOT be plain HTTP: the login
1586
- // password and the session cookie would cross it in the clear. SEPARATE from PORT on
1587
- // purpose, so the LAN path stays plain HTTP — no cert warning in the house, curl
1588
- // unchanged. (Config constants live at the top, next to the other paths.)
1589
- if (TLS_READY) {
1590
- const httpsServer = createHttpsServer({ cert: fs.readFileSync(TLS_CERT), key: fs.readFileSync(TLS_KEY) }, handleRequest);
1591
- httpsServer.on("upgrade", handleUpgrade);
1592
- httpsServer.listen(TLS_PORT, "0.0.0.0", () => {
1593
- 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)`);
1594
- });
1595
- // A busy TLS port must not take the LAN listener down with it: log, keep serving.
1596
- httpsServer.on("error", (err) => {
1597
- console.error(`[agentus-server] tls listener failed on ${TLS_PORT}: ${(err as Error).message}`);
1598
- });
1599
- } else if (TLS_PORT > 0) {
1600
- console.log(`[agentus-server] tls off: no cert at ${TLS_CERT} (create it with scripts/make-cert.sh, or set AGENTUS_TLS_PORT=0)`);
1601
- }
1602
-
1603
1595
  httpServer.on("error", (err) => {
1604
1596
  console.error(`[agentus] cannot listen on ${PORT}: ${(err as Error).message}`);
1605
1597
  process.exit(1); // fail fast: never linger as a listener-less zombie
@@ -26,8 +26,10 @@ import type {
26
26
  NotifyFrame,
27
27
  NotifyRef,
28
28
  NotifyRules,
29
+ NotifyWebRules,
29
30
  Presence,
30
31
  OpenTarget,
32
+ WebNotice,
31
33
  } from "./types.js";
32
34
 
33
35
  export interface NotifyCenterOptions {
@@ -152,6 +154,8 @@ export class NotifyCenter {
152
154
  /** actionKey -> when it was consumed, so a button can only fire once. */
153
155
  private readonly consumed = new Map<string, number>();
154
156
  private onActionCb: ((e: ActionEvent) => void) | null = null;
157
+ /** The page channel's sink: index.ts fans this out to every cockpit websocket. */
158
+ private onNoticeCb: ((n: WebNotice) => void) | null = null;
155
159
  private timers: NodeJS.Timeout[] = [];
156
160
  private wss: WebSocketServer | null = null;
157
161
  /** Which session the cockpit is showing, and whether its tab is visible. Deliberately in
@@ -163,14 +167,34 @@ export class NotifyCenter {
163
167
 
164
168
  /** The operator's switches, always complete (a state file may predate them). */
165
169
  rules(): NotifyRules {
166
- return { ...DEFAULT_NOTIFY_RULES, ...(this.state.rules ?? {}) };
170
+ // A state file written before the web channel existed has no `web` key at all, so the stored
171
+ // value is really partial — the cast says that out loud instead of pretending otherwise.
172
+ const stored = this.state.rules as Partial<NotifyRules> | undefined;
173
+ // `web` is merged one level deeper than the rest: a partial patch (one switch from the page) must
174
+ // not drop the other two — a nested object replaced wholesale is how a channel silently forgets
175
+ // its settings.
176
+ return {
177
+ ...DEFAULT_NOTIFY_RULES,
178
+ ...(stored ?? {}),
179
+ web: { ...DEFAULT_NOTIFY_RULES.web, ...(stored?.web ?? {}) },
180
+ };
167
181
  }
168
182
 
169
183
  setRules(patch: Partial<NotifyRules>): NotifyRules {
170
- const next: NotifyRules = { ...this.rules(), ...patch };
184
+ const current = this.rules();
185
+ const next: NotifyRules = {
186
+ ...current,
187
+ ...patch,
188
+ web: { ...current.web, ...(patch.web ?? {}) },
189
+ };
171
190
  this.state.rules = next;
172
191
  this.save();
173
- this.log(`[notify] rules turnStart=${next.turnStart} approval=${next.approval} completion=${next.completion} quietWhenWatching=${next.quietWhenWatching}`);
192
+ const w = next.web;
193
+ this.log(
194
+ `[notify] rules turnStart=${next.turnStart} approval=${next.approval} completion=${next.completion} ` +
195
+ `quietWhenWatching=${next.quietWhenWatching} | web enabled=${w.enabled} turnStart=${w.turnStart} ` +
196
+ `approval=${w.approval} completion=${w.completion}`,
197
+ );
174
198
  return next;
175
199
  }
176
200
 
@@ -473,6 +497,27 @@ export class NotifyCenter {
473
497
  return this.events.filter((e) => e.seq > cursor && e.activity.op !== "clear_all");
474
498
  }
475
499
 
500
+ // ---- the page channel (browser notifications) -----------------------------
501
+ //
502
+ // The same decision, a second renderer. Kept here (not in the page) so that strings, dedup and the
503
+ // card lifecycle have ONE owner: the page cannot re-derive "what happened" from the transcript, and
504
+ // a rule change must apply to both channels without shipping a bundle.
505
+ //
506
+ // What this channel deliberately does NOT do: consult presence. The page knows whether it is the
507
+ // tab in front (`document.hidden`) and stays quiet by itself, which is the same judgement made where
508
+ // the fact lives — and it must stay that way, because one browser tab cannot see the other machine.
509
+
510
+ onNotice(cb: (n: WebNotice) => void): void {
511
+ this.onNoticeCb = cb;
512
+ }
513
+
514
+ /** Emit one page notice, gated on the web channel's own switches. */
515
+ private notice(n: Omit<WebNotice, "at">, rule: keyof Omit<NotifyWebRules, "enabled">): void {
516
+ const web = this.rules().web;
517
+ if (!web.enabled || !web[rule]) return;
518
+ this.onNoticeCb?.({ ...n, at: Date.now() });
519
+ }
520
+
476
521
  // ---- actions -------------------------------------------------------------
477
522
 
478
523
  onAction(cb: (e: ActionEvent) => void): void {
@@ -927,11 +972,11 @@ export class NotifyCenter {
927
972
  const title = (sessionId: string): string => this.sessionTitle?.(sessionId) ?? sessionId.slice(0, 8);
928
973
  const link = (sessionId: string): string => `/?session=${encodeURIComponent(sessionId)}`;
929
974
  switch (evt.t) {
930
- case "turn-start":
931
- // The rules gate BEFORE anything else: an event that is switched off must not even be
932
- // evaluated against presence (and a suppressed event must not be mistaken for a bug).
933
- if (!this.rules().turnStart || this.watching(evt.sessionId)) return;
934
- this.publish({
975
+ case "turn-start": {
976
+ // Rules gate the PHONE first, and that is why the page's copy is emitted before the gate: the
977
+ // two channels have independent switches, and an early return for one must not starve the
978
+ // other. Both render the SAME object, so the words can never drift apart.
979
+ const running: ActivityObject = {
935
980
  schema: 1, op: "upsert", activityId: `turn:${evt.sessionId}`, revision: this.nextRevision(`turn:${evt.sessionId}`),
936
981
  kind: "agent_running", priority: "low", ongoing: true, promotable: true,
937
982
  title: "Agentus · 运行中",
@@ -939,29 +984,55 @@ export class NotifyCenter {
939
984
  progress: { indeterminate: true },
940
985
  channel: { id: "agent_running", name: "任务运行中", importance: "low", sound: false },
941
986
  smallIcon: "agentus", ...tapTarget(link(evt.sessionId)), visibility: "public",
942
- });
987
+ };
988
+ this.notice({
989
+ op: "upsert", noticeId: `turn:${evt.sessionId}`, kind: "agent_running",
990
+ // The browser card drops the `Agentus ·` prefix the phone title carries: a browser already
991
+ // shows the origin and the icon, so the product's own name would spend the visible line.
992
+ title: "运行中", body: running.body ?? "", open: link(evt.sessionId),
993
+ }, "turnStart");
994
+ if (!this.rules().turnStart || this.watching(evt.sessionId)) return;
995
+ this.publish(running);
943
996
  return;
944
- case "permission":
997
+ }
998
+ case "permission": {
945
999
  // Never suppressed by presence: this is a decision waiting on a human, and the whole
946
1000
  // point of the phone being in the loop is answering it while away from the desk.
1001
+ const { activity, actionRefs } = this.approvalActivity(evt.request, title(evt.request.sessionId), link(evt.request.sessionId));
1002
+ this.notice({
1003
+ op: "upsert", noticeId: `perm:${evt.request.requestId}`, kind: "approval",
1004
+ title: activity.title, body: activity.body ?? "", open: link(evt.request.sessionId),
1005
+ // Sticky: a browser card that fades away while the agent is still blocked is the same
1006
+ // failure as a dead button on a lock screen.
1007
+ sticky: true,
1008
+ }, "approval");
947
1009
  if (!this.rules().approval) return;
948
- this.publishApproval(evt.request, title(evt.request.sessionId), link(evt.request.sessionId));
1010
+ this.publish(activity, { actionRefs });
949
1011
  return;
1012
+ }
950
1013
  case "permission-resolved":
951
1014
  // Always clear the card, even if the request itself was never published — a stale
952
- // "waiting for you" on the phone is worse than no notification at all.
1015
+ // "waiting for you" on the phone is worse than no notification at all. The page's card goes
1016
+ // away for the same reason and with the same unconditionality — and it is NOT switch-gated:
1017
+ // taking a dead card off the screen is not an interruption, and gating it on a preference
1018
+ // would leave a card nobody can answer.
953
1019
  this.dismiss(`perm:${evt.requestId}`);
1020
+ this.onNoticeCb?.({
1021
+ op: "dismiss", noticeId: `perm:${evt.requestId}`, kind: "approval", title: "", body: "", at: Date.now(),
1022
+ });
954
1023
  return;
955
1024
  case "turn-end": {
956
1025
  // The running card goes away unconditionally — a zombie "still running" on the phone is worse
957
1026
  // than no card at all, even when the frame that started it was suppressed.
958
1027
  this.dismiss(`turn:${evt.sessionId}`);
1028
+ this.onNoticeCb?.({
1029
+ op: "dismiss", noticeId: `turn:${evt.sessionId}`, kind: "agent_running", title: "", body: "", at: Date.now(),
1030
+ });
959
1031
  // Both gates belong here and both were missing: `completion` was stored but never read, and
960
1032
  // presence was only consulted for turn-start — so the completion card fired while the operator was watching
961
1033
  // the very session (and, mid-call, with a sound: the reported bug).
962
- if (!this.rules().completion || this.watching(evt.sessionId)) return;
963
1034
  const failed = Boolean(evt.error);
964
- this.publish({
1035
+ const done: ActivityObject = {
965
1036
  schema: 1, op: "upsert", activityId: `done:${evt.sessionId}:${Date.now()}`, revision: 1,
966
1037
  kind: failed ? "error" : "agent_done", priority: "high", ongoing: false, promotable: false,
967
1038
  title: failed ? "执行失败" : "已完成",
@@ -974,10 +1045,23 @@ export class NotifyCenter {
974
1045
  },
975
1046
  actions: [{ id: "open", label: "查看", style: "primary" }],
976
1047
  smallIcon: "agentus", ...tapTarget(link(evt.sessionId)), visibility: "private",
977
- }, { actionRefs: { open: { type: "open", sessionId: evt.sessionId } } });
1048
+ };
1049
+ // The page's card is keyed per SESSION (`done:<id>`), unlike the phone's per-occurrence id:
1050
+ // a browser card carries a `tag`, so one per session is what makes the newest replace the
1051
+ // previous one instead of stacking a wall of 「已完成」. Nothing is lost — the transcript is
1052
+ // the history; the card only has to say what just happened.
1053
+ this.notice({
1054
+ op: "upsert", noticeId: `done:${evt.sessionId}`, kind: failed ? "error" : "agent_done",
1055
+ title: done.title, body: done.body ?? "", open: link(evt.sessionId),
1056
+ }, "completion");
1057
+ if (!this.rules().completion || this.watching(evt.sessionId)) return;
1058
+ this.publish(done, { actionRefs: { open: { type: "open", sessionId: evt.sessionId } } });
978
1059
  return;
979
1060
  }
980
1061
  case "error":
1062
+ // Phone-only, on purpose. This is the cockpit's own error with no session in scope, and on a
1063
+ // desktop it is already ON SCREEN — the page is drawing that error right now. A browser card
1064
+ // repeating something the operator is looking at is the one thing this channel must not do.
981
1065
  this.publish({
982
1066
  schema: 1, op: "upsert", activityId: `error:${Date.now()}`, revision: 1, kind: "error",
983
1067
  priority: "high", ongoing: false, promotable: false, title: "Agentus 报错",
@@ -991,7 +1075,12 @@ export class NotifyCenter {
991
1075
  }
992
1076
  }
993
1077
 
994
- private publishApproval(request: PermissionRequestView, sessionTitle: string, link: string): void {
1078
+ /** The approval card, built but not published: the phone's activity and the page's notice are two
1079
+ * renderings of ONE object, so the wording lives here and nowhere else. */
1080
+ private approvalActivity(request: PermissionRequestView, sessionTitle: string, link: string): {
1081
+ activity: ActivityObject;
1082
+ actionRefs: Record<string, NotifyRef>;
1083
+ } {
995
1084
  const actions = request.options.slice(0, 3).map((o) => ({
996
1085
  id: o.optionId,
997
1086
  label: o.name.slice(0, 24),
@@ -1006,15 +1095,18 @@ export class NotifyCenter {
1006
1095
  };
1007
1096
  }
1008
1097
  const tool = splitToolTitle(request.toolCallTitle);
1009
- this.publish({
1010
- schema: 1, op: "upsert", activityId: `perm:${request.requestId}`, revision: 1, kind: "approval",
1011
- priority: "high", ongoing: true, promotable: true,
1012
- title: tool.head ? `待你确认:${tool.head.slice(0, 60)}` : "待你确认",
1013
- body: nameAndDetail(sessionTitle, tool.target || request.kind),
1014
- channel: { id: "agent_approval", name: "权限请求", importance: "high", sound: true, vibration: true },
1015
- actions: actions.length ? actions : [{ id: "open", label: "查看", style: "primary" }],
1016
- input: { enabled: true, placeholder: "或直接回一句" },
1017
- smallIcon: "agentus", ...tapTarget(link), visibility: "private",
1018
- }, { actionRefs });
1098
+ return {
1099
+ activity: {
1100
+ schema: 1, op: "upsert", activityId: `perm:${request.requestId}`, revision: 1, kind: "approval",
1101
+ priority: "high", ongoing: true, promotable: true,
1102
+ title: tool.head ? `待你确认:${tool.head.slice(0, 60)}` : "待你确认",
1103
+ body: nameAndDetail(sessionTitle, tool.target || request.kind),
1104
+ channel: { id: "agent_approval", name: "权限请求", importance: "high", sound: true, vibration: true },
1105
+ actions: actions.length ? actions : [{ id: "open", label: "查看", style: "primary" }],
1106
+ input: { enabled: true, placeholder: "或直接回一句" },
1107
+ smallIcon: "agentus", ...tapTarget(link), visibility: "private",
1108
+ },
1109
+ actionRefs,
1110
+ };
1019
1111
  }
1020
1112
  }
@@ -200,6 +200,21 @@ export interface NotifyRules {
200
200
  completion: boolean;
201
201
  /** Do not push while the operator is looking at that very session in the cockpit. */
202
202
  quietWhenWatching: boolean;
203
+ /** The OTHER channel: browser notifications on a machine that has the cockpit open. Same events,
204
+ * rendered by the page (see WebNotice). Its own switches because the two channels are used in
205
+ * different places: a "running" card belongs on a lock screen and is noise on a desktop that is
206
+ * already showing the transcript, and a desktop has no `quietWhenWatching` need — the page knows
207
+ * its own tab is in front and stays silent by itself. */
208
+ web: NotifyWebRules;
209
+ }
210
+
211
+ /** The web channel's own view of the same three events. */
212
+ export interface NotifyWebRules {
213
+ /** The whole channel: off means the server sends pages nothing, whatever the per-kind switches say. */
214
+ enabled: boolean;
215
+ turnStart: boolean;
216
+ approval: boolean;
217
+ completion: boolean;
203
218
  }
204
219
 
205
220
  export const DEFAULT_NOTIFY_RULES: NotifyRules = {
@@ -207,8 +222,18 @@ export const DEFAULT_NOTIFY_RULES: NotifyRules = {
207
222
  approval: true,
208
223
  completion: true,
209
224
  quietWhenWatching: true,
225
+ // On by default, but only for the two events worth interrupting a desktop for: a decision waiting
226
+ // on a human, and a reply that finished while the tab was in the background.
227
+ web: { enabled: true, turnStart: false, approval: true, completion: true },
210
228
  };
211
229
 
230
+ /**
231
+ * Server -> page: one browser notification. The type itself lives in `shared` (it is part of the
232
+ * browser protocol now — it rides the cockpit's websocket as `{t:"notify"}`), and is re-exported here
233
+ * so the notify centre keeps one import site.
234
+ */
235
+ export type { WebNotice } from "../../../shared/src/index.js";
236
+
212
237
  /** Which session the cockpit is showing right now (in-memory only — presence is ephemeral). */
213
238
  export interface Presence {
214
239
  sessionId: string | null;
@@ -124,13 +124,28 @@ export interface AgentSettings {
124
124
  /** minutes of no activity before a live slot's agent process is stopped to reclaim memory;
125
125
  * 0 = never. The row and its transcript always survive — this only stops the process. */
126
126
  idleKillMin: number;
127
+ /** 「热备」= how many of the most recently USED sessions keep their agent process no matter how
128
+ * quiet they go. A FLOOR, not a ceiling: it names the slots that may not be reclaimed for being
129
+ * idle, it does not bound how many may run. Ranked over live AND cold rows by the same clock the
130
+ * sweep uses — a slot already reaped must not hand its seat to a session nobody has touched in
131
+ * days. Archived rows never occupy a seat. 0 = off (every slot obeys idleKillMin). */
132
+ warmSlots: number;
133
+ /** Hours a warm slot may go untouched before even its exemption expires. Without this the warm set
134
+ * is a permanent memory floor (5 × ~137 MB RSS measured); with it, a cockpit left alone for a day
135
+ * comes back to nothing running. 0 = 不过期. */
136
+ warmTtlHours: number;
127
137
  }
128
138
 
129
- export const AGENT_DEFAULT: AgentSettings = { idleKillMin: 5 };
139
+ export const AGENT_DEFAULT: AgentSettings = { idleKillMin: 5, warmSlots: 5, warmTtlHours: 24 };
130
140
 
131
141
  /** What the settings page may offer: the ends of the number field. Server-side enforcement uses
132
142
  * the same pair, so a hand-written request cannot store a nonsense value. */
133
143
  export const AGENT_IDLE_RANGE: [number, number] = [0, 60];
144
+ /** 0 = off. The upper end is a memory decision (one live agent ≈ 137 MB RSS measured), not a limit
145
+ * the server has to defend. */
146
+ export const AGENT_WARM_RANGE: [number, number] = [0, 20];
147
+ /** 0 = 不过期; a week is the longest span worth offering on the page. */
148
+ export const AGENT_WARM_TTL_RANGE: [number, number] = [0, 168];
134
149
 
135
150
  /** The NUMERIC knobs. `interruptMode` is a two-way choice, not a slider, so it is deliberately
136
151
  * outside both the range table and the loops that validate numbers. */
@@ -293,9 +308,15 @@ function pickCall(raw: unknown): CallSettings {
293
308
  function pickAgent(raw: unknown): AgentSettings {
294
309
  const out = { ...AGENT_DEFAULT };
295
310
  if (raw && typeof raw === "object") {
296
- const v = Number((raw as Record<string, unknown>).idleKillMin);
297
- const [lo, hi] = AGENT_IDLE_RANGE;
298
- if (Number.isFinite(v) && v >= lo && v <= hi) out.idleKillMin = v;
311
+ const r = raw as Record<string, unknown>;
312
+ const take = (key: keyof AgentSettings, range: [number, number]): void => {
313
+ const v = Number(r[key]);
314
+ const [lo, hi] = range;
315
+ if (Number.isFinite(v) && v >= lo && v <= hi) out[key] = v;
316
+ };
317
+ take("idleKillMin", AGENT_IDLE_RANGE);
318
+ take("warmSlots", AGENT_WARM_RANGE);
319
+ take("warmTtlHours", AGENT_WARM_TTL_RANGE);
299
320
  }
300
321
  return out;
301
322
  }
@@ -369,6 +390,8 @@ export function publicSettings(): Record<string, unknown> {
369
390
  agent: cache.agent,
370
391
  agentDefaults: AGENT_DEFAULT,
371
392
  agentIdleRange: AGENT_IDLE_RANGE,
393
+ agentWarmRange: AGENT_WARM_RANGE,
394
+ agentWarmTtlRange: AGENT_WARM_TTL_RANGE,
372
395
  // the defaults, so the UI can offer "reset to Bailian defaults"
373
396
  defaults: { ...DEFAULTS, apiKey: undefined, baseUrl: undefined },
374
397
  updatedAt: cache.updatedAt,
@@ -495,18 +518,23 @@ export function savePrefs(patch: Record<string, unknown>): Settings {
495
518
  }
496
519
 
497
520
  /** Agent-process knobs: its own update, like theme/call, so a bad number here cannot lock the
498
- * operator out of the page that would fix it. Range-checked against AGENT_IDLE_RANGE before it is
499
- * stored — this number decides whether agent processes get killed, so it is not a text field. */
521
+ * operator out of the page that would fix it. Range-checked against the section's range table
522
+ * before it is stored — these numbers decide whether agent processes get killed, so they are not
523
+ * text fields. */
500
524
  export function saveAgent(patch: Record<string, unknown>): Settings {
501
525
  const a = { ...cache.agent };
502
- if ("idleKillMin" in patch) {
503
- const raw = typeof patch.idleKillMin === "number" ? patch.idleKillMin : Number(patch.idleKillMin);
504
- const [lo, hi] = AGENT_IDLE_RANGE;
526
+ const set = (key: keyof AgentSettings, range: [number, number], unit: string, off: string): void => {
527
+ if (!(key in patch)) return;
528
+ const raw = typeof patch[key] === "number" ? (patch[key] as number) : Number(patch[key]);
529
+ const [lo, hi] = range;
505
530
  if (!Number.isFinite(raw) || raw < lo || raw > hi) {
506
- throw new Error(`invalid idleKillMin: ${JSON.stringify(patch.idleKillMin)} (expected ${lo}…${hi} minutes, 0 = off)`);
531
+ throw new Error(`invalid ${key}: ${JSON.stringify(patch[key])} (expected ${lo}…${hi} ${unit}, ${off})`);
507
532
  }
508
- a.idleKillMin = raw;
509
- }
533
+ a[key] = raw;
534
+ };
535
+ set("idleKillMin", AGENT_IDLE_RANGE, "minutes", "0 = off");
536
+ set("warmSlots", AGENT_WARM_RANGE, "slots", "0 = off");
537
+ set("warmTtlHours", AGENT_WARM_TTL_RANGE, "hours", "0 = never expires");
510
538
  cache = { ...cache, agent: a, updatedAt: Date.now() };
511
539
  persist();
512
540
  return cache;
@@ -194,8 +194,48 @@ export type ServerEvent =
194
194
  | { t: "plan"; sessionId: string; plan: PlanSnapshot }
195
195
  /** context-window usage for a slot (ACP usage_update) */
196
196
  | { t: "usage"; sessionId: string; usage: UsageView }
197
+ /** A browser notification for this machine (the web channel of the notify feature).
198
+ *
199
+ * Same decision as the phone's cards, second renderer: the server maps an ACP event to a
200
+ * title/body ONCE (notify/center.ts §ACP mapping) and hands the page a render-ready object, so the
201
+ * page never re-derives what happened. Nothing here is Android's `ActivityObject` — no channels,
202
+ * importances or actions; a browser ignores them, and sending fields a renderer cannot use is how a
203
+ * second contract starts.
204
+ *
205
+ * No cursor and no replay, unlike the phone's device socket: a tab that was closed has a
206
+ * transcript to catch up in, and a wall of stale cards after a reload is worse than none. */
207
+ | { t: "notify"; notice: WebNotice }
197
208
  | { t: "error"; error: string };
198
209
 
210
+ /**
211
+ * Server -> page, one browser notification (see the `notify` frame above).
212
+ *
213
+ * NOTE for whoever touches drawing: the page's ONLY jobs are (1) is this device allowed and willing
214
+ * (`Notification` exists, secure context, permission granted, the operator's local switch on), (2) is
215
+ * this tab in the background (`document.hidden` — the page's own answer to 「我正在看就不弹」, made
216
+ * where the fact lives), (3) draw it with `noticeId` as the notification `tag`. Everything about WHAT
217
+ * happened and WHEN to say it is decided server-side. */
218
+ export interface WebNotice {
219
+ /** `upsert` = show (or replace) the card for `noticeId`; `dismiss` = take it away. */
220
+ op: "upsert" | "dismiss";
221
+ /** The page's slot AND its notification `tag`: one id = one card, a newer one replaces the old.
222
+ * Chosen so a session's running/done cards replace themselves while each pending approval keeps
223
+ * its own (every one of those is a decision still waiting for an answer). */
224
+ noticeId: string;
225
+ /** Semantic kind, for the page's own choices (per-kind sound, and whether a click is expected). */
226
+ kind: "agent_running" | "approval" | "agent_done" | "error";
227
+ /** What happened (short — the line the OS draws first). */
228
+ title: string;
229
+ /** `<会话名> · <detail>`, the same shape the phone's cards use. */
230
+ body: string;
231
+ /** Where a click lands, relative to this server (`/?session=<id>`). The phone's tap target is the
232
+ * same URL, so "open the session that notified me" has one owner. */
233
+ open?: string | null;
234
+ /** True when the card must WAIT for the operator (an approval) rather than fade on its own. */
235
+ sticky?: boolean;
236
+ at: number;
237
+ }
238
+
199
239
  export interface TurnTrace {
200
240
  model?: string;
201
241
  provider?: string;