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.
- package/README.md +18 -50
- package/README.zh-CN.md +12 -42
- package/bin/agentus.mjs +4 -4
- package/docs/dev/01-dev-loop.md +1 -2
- package/docs/dev/02-verify.md +0 -1
- package/docs/dev/03-release.md +13 -0
- package/docs/dev/04-recover.md +6 -26
- package/docs/dev/05-agent-driven.md +1 -1
- package/docs/dev/README.md +4 -5
- package/docs/installation.md +76 -30
- package/docs/installation.zh-CN.md +68 -29
- package/package.json +10 -9
- package/packages/server/mock/agent.mjs +46 -0
- package/packages/server/src/acp/session-manager.ts +99 -28
- package/packages/server/src/index.ts +55 -63
- package/packages/server/src/notify/center.ts +118 -26
- package/packages/server/src/notify/types.ts +25 -0
- package/packages/server/src/settings.ts +40 -12
- package/packages/shared/src/index.ts +40 -0
- package/packages/web/dist/assets/index-D-RQzauE.css +32 -0
- package/packages/web/dist/assets/index-N0FnNa4-.js +119 -0
- package/packages/web/dist/index.html +2 -2
- package/packages/web/dist/sw.js +39 -0
- package/packages/web/dist/assets/index-CCvfL56d.css +0 -32
- package/packages/web/dist/assets/index-DPCbsWh9.js +0 -283
- package/scripts/make-cert.sh +0 -101
|
@@ -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,
|
|
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
|
|
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
|
|
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,
|
|
397
|
-
//
|
|
398
|
-
//
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
//
|
|
932
|
-
//
|
|
933
|
-
|
|
934
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
-
}
|
|
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
|
-
|
|
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
|
-
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
|
|
1017
|
-
|
|
1018
|
-
|
|
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
|
|
297
|
-
const [
|
|
298
|
-
|
|
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
|
|
499
|
-
* stored —
|
|
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
|
-
|
|
503
|
-
|
|
504
|
-
const [
|
|
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
|
|
531
|
+
throw new Error(`invalid ${key}: ${JSON.stringify(patch[key])} (expected ${lo}…${hi} ${unit}, ${off})`);
|
|
507
532
|
}
|
|
508
|
-
a
|
|
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;
|