agentus 0.4.2 → 0.4.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 +3 -2
- package/README.zh-CN.md +3 -2
- package/docs/installation.md +4 -2
- package/docs/installation.zh-CN.md +4 -2
- package/package.json +1 -1
- package/packages/server/mock/agent.mjs +62 -8
- package/packages/server/src/acp/session-manager.ts +200 -1
- package/packages/server/src/index.ts +56 -1
- package/packages/server/src/notify/center.ts +127 -7
- package/packages/server/src/notify/webpush.ts +295 -0
- package/packages/server/src/settings.ts +12 -1
- package/packages/server/src/store/store.ts +50 -0
- package/packages/shared/src/index.ts +27 -2
- package/packages/web/dist/assets/{index-DSStmvd6.js → index-DwdrqRgR.js} +56 -54
- package/packages/web/dist/assets/index-xlniZD2Q.css +32 -0
- package/packages/web/dist/index.html +2 -2
- package/packages/web/dist/sw.js +44 -0
- package/packages/web/dist/assets/index-CJ4D6bZz.css +0 -32
|
@@ -17,6 +17,7 @@ import type { Duplex } from "node:stream";
|
|
|
17
17
|
import { WebSocketServer, WebSocket } from "ws";
|
|
18
18
|
import type { PermissionRequestView, PlanSnapshot, ServerEvent } from "../../../shared/src/index.js";
|
|
19
19
|
import { DEFAULT_NOTIFY_RULES } from "./types.js";
|
|
20
|
+
import { WebPushService } from "./webpush.js";
|
|
20
21
|
import type {
|
|
21
22
|
ActionCallback,
|
|
22
23
|
ActivityObject,
|
|
@@ -98,6 +99,33 @@ function tapTarget(url: string, prefer: OpenTarget["prefer"] = "app"): Pick<Acti
|
|
|
98
99
|
return { deeplink: url, open: { url, prefer: prefer ?? "auto" } };
|
|
99
100
|
}
|
|
100
101
|
|
|
102
|
+
/** The session a notice points at, from its `open` URL (`/?session=<id>`). */
|
|
103
|
+
function sessionOfOpen(open: string | null | undefined): string | null {
|
|
104
|
+
if (!open) return null;
|
|
105
|
+
try {
|
|
106
|
+
return new URL(open, "http://local").searchParams.get("session");
|
|
107
|
+
} catch {
|
|
108
|
+
return null;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** A subscription as the settings card may see it: the device, never the endpoint.
|
|
113
|
+
* The endpoint is a long-lived capability URL (whoever holds it can post to that browser's push
|
|
114
|
+
* service) — the card only needs to tell one device from another, so host + tail is enough. */
|
|
115
|
+
function publicSub(rec: { endpoint: string; ua?: string; createdAt: number; lastOkAt?: number; fails: number; lastError?: string }) {
|
|
116
|
+
let host = "";
|
|
117
|
+
try { host = new URL(rec.endpoint).host; } catch { host = "?"; }
|
|
118
|
+
return {
|
|
119
|
+
host,
|
|
120
|
+
tail: rec.endpoint.slice(-6),
|
|
121
|
+
ua: rec.ua,
|
|
122
|
+
createdAt: rec.createdAt,
|
|
123
|
+
lastOkAt: rec.lastOkAt,
|
|
124
|
+
fails: rec.fails,
|
|
125
|
+
lastError: rec.lastError,
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
|
|
101
129
|
/** How a finished turn reads on a lock screen: 「用时 1m12s」.
|
|
102
130
|
*
|
|
103
131
|
* The protocol's own answer (`stopReason: end_turn`) is a fact about the wire, not about the
|
|
@@ -166,6 +194,8 @@ export class NotifyCenter {
|
|
|
166
194
|
private onActionCb: ((e: ActionEvent) => void) | null = null;
|
|
167
195
|
/** The page channel's sink: index.ts fans this out to every cockpit websocket. */
|
|
168
196
|
private onNoticeCb: ((n: WebNotice) => void) | null = null;
|
|
197
|
+
/** The third channel: the only one that reaches a LOCKED screen (see webpush.ts). */
|
|
198
|
+
private readonly push: WebPushService;
|
|
169
199
|
private timers: NodeJS.Timeout[] = [];
|
|
170
200
|
private wss: WebSocketServer | null = null;
|
|
171
201
|
/** Which session the cockpit is showing, and whether its tab is visible. Deliberately in
|
|
@@ -278,6 +308,7 @@ export class NotifyCenter {
|
|
|
278
308
|
this.sessionPlan = opts.sessionPlan ?? null;
|
|
279
309
|
this.apkPath = opts.apkPath ?? null;
|
|
280
310
|
this.onFocus = opts.onFocus ?? null;
|
|
311
|
+
this.push = new WebPushService(opts.dataDir, { log: (l) => this.log(l) });
|
|
281
312
|
this.state = this.loadState();
|
|
282
313
|
this.events = this.loadEvents();
|
|
283
314
|
}
|
|
@@ -528,11 +559,56 @@ export class NotifyCenter {
|
|
|
528
559
|
this.onNoticeCb = cb;
|
|
529
560
|
}
|
|
530
561
|
|
|
531
|
-
/** Emit one page notice, gated on the web channel's own switches
|
|
562
|
+
/** Emit one page notice, gated on the web channel's own switches — and hand the same notice to the
|
|
563
|
+
* push services, so 「关屏也能收」 needs no second decision path (and cannot drift from the page's). */
|
|
532
564
|
private notice(n: Omit<WebNotice, "at">, rule: keyof Omit<NotifyWebRules, "enabled">): void {
|
|
533
565
|
const web = this.rules().web;
|
|
534
566
|
if (!web.enabled || !web[rule]) return;
|
|
567
|
+
this.emitNotice(n);
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
/** The page + push half of a notice, without any rule gate — for the two cleanups (「跑完了」撤掉
|
|
571
|
+
* 「运行中」卡、批准后撤掉批准卡) that must happen even when the rule that would have SENT the card
|
|
572
|
+
* is off. Taking a dead card off the screen is not an interruption, which is why these two calls
|
|
573
|
+
* skip the switches; the push side follows the same logic, or a locked phone would keep a card
|
|
574
|
+
* advertising a decision that was already made. */
|
|
575
|
+
private emitNotice(n: Omit<WebNotice, "at">): void {
|
|
535
576
|
this.onNoticeCb?.({ ...n, at: Date.now() });
|
|
577
|
+
this.pushNotice(n);
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
/** The push renderer of a notice. Narrower than the page channel on purpose:
|
|
581
|
+
*
|
|
582
|
+
* · 「运行中」 never pushes. A lock-screen card saying "still running" is noise by definition, and
|
|
583
|
+
* what was asked for is 完成 and 需要批准 — the two things that want a human.
|
|
584
|
+
* · a notice about the session he is LOOKING AT never pushes. The page channel leaves presence to
|
|
585
|
+
* the page (`document.hidden`); a push cannot see that, so the server judges here. A phone
|
|
586
|
+
* buzzing about the window already on screen is pure interruption.
|
|
587
|
+
* · a dismiss IS pushed: it takes the stale card off the lock screen (the worker closes the
|
|
588
|
+
* notification by tag), which is the same unconditional cleanup the other two channels do.
|
|
589
|
+
*
|
|
590
|
+
* Fire-and-forget: a push service being slow or dead must never delay the frame to the phone or the
|
|
591
|
+
* page. Each send records its own outcome on the subscription (fails / lastError) for the UI to show. */
|
|
592
|
+
private pushNotice(n: Omit<WebNotice, "at">): void {
|
|
593
|
+
if (!this.push.subscriptions().length) return;
|
|
594
|
+
if (n.kind === "agent_running") return; // never pushed, so a dismiss for it has nothing to close
|
|
595
|
+
const sessionId = sessionOfOpen(n.open);
|
|
596
|
+
if (sessionId && this.watching(sessionId)) return;
|
|
597
|
+
const dismiss = n.op === "dismiss";
|
|
598
|
+
const payload = dismiss
|
|
599
|
+
? { op: "dismiss", noticeId: n.noticeId }
|
|
600
|
+
: { op: "upsert", noticeId: n.noticeId, kind: n.kind, title: n.title, body: n.body, open: n.open, sticky: Boolean(n.sticky) };
|
|
601
|
+
// 审批卡要活到有人动手:TTL 一天,urgency high(叫醒设备)。完成/出错一小时就够。
|
|
602
|
+
const approval = n.kind === "approval";
|
|
603
|
+
void this.push
|
|
604
|
+
.broadcast(payload, dismiss
|
|
605
|
+
? { urgency: "very-low", ttlSec: 300 }
|
|
606
|
+
: { urgency: approval ? "high" : "normal", ttlSec: approval ? 86_400 : 3_600 })
|
|
607
|
+
.then((results) => {
|
|
608
|
+
const bad = results.filter((r) => !r.ok);
|
|
609
|
+
if (bad.length) this.log(`[notify] web push: ${bad.length}/${results.length} 未送达(${bad[0]?.error ?? ""})`);
|
|
610
|
+
})
|
|
611
|
+
.catch((e: Error) => this.log(`[notify] web push threw: ${e.message}`));
|
|
536
612
|
}
|
|
537
613
|
|
|
538
614
|
// ---- actions -------------------------------------------------------------
|
|
@@ -609,6 +685,54 @@ export class NotifyCenter {
|
|
|
609
685
|
}) && true;
|
|
610
686
|
}
|
|
611
687
|
|
|
688
|
+
// ---- 网页推送(关屏也能收)-----------------------------------------------------
|
|
689
|
+
// The public key has to reach the browser to subscribe; the SUBSCRIPTION LIST must not — it says
|
|
690
|
+
// how many devices listen to this cockpit. So the whole group sits behind the operator gate, the
|
|
691
|
+
// same reason the APK and the root certificate do.
|
|
692
|
+
if (p === "/api/notify/webpush" || p.startsWith("/api/notify/webpush/")) {
|
|
693
|
+
if (!isOperator()) return json(401, { error: "operator auth required" }) && true;
|
|
694
|
+
const subs = () => this.push.subscriptions().map(publicSub);
|
|
695
|
+
|
|
696
|
+
if (p === "/api/notify/webpush" && req.method === "GET") {
|
|
697
|
+
return json(200, { ok: true, publicKey: this.push.publicKey(), subs: subs() }) && true;
|
|
698
|
+
}
|
|
699
|
+
|
|
700
|
+
if (p === "/api/notify/webpush/subscribe" && req.method === "POST") {
|
|
701
|
+
const body = await readBody();
|
|
702
|
+
const rec = this.push.subscribe({
|
|
703
|
+
endpoint: body.endpoint,
|
|
704
|
+
keys: (body.keys ?? {}) as { p256dh?: unknown; auth?: unknown },
|
|
705
|
+
ua: body.ua,
|
|
706
|
+
});
|
|
707
|
+
if ("error" in rec) return json(400, { error: rec.error }) && true;
|
|
708
|
+
this.log(`[notify] 网页推送已订阅:${new URL(rec.endpoint).host}(共 ${this.push.subscriptions().length} 台)`);
|
|
709
|
+
return json(200, { ok: true, subs: subs() }) && true;
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
if (p === "/api/notify/webpush/unsubscribe" && req.method === "POST") {
|
|
713
|
+
const body = await readBody();
|
|
714
|
+
const removed = this.push.unsubscribe(String(body.endpoint ?? ""));
|
|
715
|
+
return json(200, { ok: true, removed, subs: subs() }) && true;
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
// The test button: proves the WHOLE chain (our VAPID key → push service → service worker →
|
|
719
|
+
// lock screen). A test that only logged to the server would prove none of it.
|
|
720
|
+
if (p === "/api/notify/webpush/test" && req.method === "POST") {
|
|
721
|
+
if (!this.push.subscriptions().length) return json(400, { error: "这台服务器还没有任何浏览器订阅" }) && true;
|
|
722
|
+
const results = await this.push.broadcast({
|
|
723
|
+
op: "upsert", noticeId: "test:webpush", kind: "agent_done", sticky: false,
|
|
724
|
+
title: "推送测试", body: "锁屏上能看到这条,就说明关屏也能收", open: "/",
|
|
725
|
+
}, { urgency: "high", ttlSec: 300 });
|
|
726
|
+
return json(200, {
|
|
727
|
+
ok: results.every((r) => r.ok),
|
|
728
|
+
results: results.map((r) => ({ ok: r.ok, status: r.status, error: r.error, gone: r.gone })),
|
|
729
|
+
subs: subs(),
|
|
730
|
+
}) && true;
|
|
731
|
+
}
|
|
732
|
+
|
|
733
|
+
return json(404, { error: "unknown webpush route" }) && true;
|
|
734
|
+
}
|
|
735
|
+
|
|
612
736
|
if (p === "/api/notify/presence" && req.method === "POST") {
|
|
613
737
|
// The cockpit telling us what it is showing. Operator-authenticated (the SPA has the
|
|
614
738
|
// cookie); a missing report just means presence goes stale and quiet-when-watching stops
|
|
@@ -1037,17 +1161,13 @@ export class NotifyCenter {
|
|
|
1037
1161
|
// taking a dead card off the screen is not an interruption, and gating it on a preference
|
|
1038
1162
|
// would leave a card nobody can answer.
|
|
1039
1163
|
this.dismiss(`perm:${evt.requestId}`);
|
|
1040
|
-
this.
|
|
1041
|
-
op: "dismiss", noticeId: `perm:${evt.requestId}`, kind: "approval", title: "", body: "", at: Date.now(),
|
|
1042
|
-
});
|
|
1164
|
+
this.emitNotice({ op: "dismiss", noticeId: `perm:${evt.requestId}`, kind: "approval", title: "", body: "" });
|
|
1043
1165
|
return;
|
|
1044
1166
|
case "turn-end": {
|
|
1045
1167
|
// The running card goes away unconditionally — a zombie "still running" on the phone is worse
|
|
1046
1168
|
// than no card at all, even when the frame that started it was suppressed.
|
|
1047
1169
|
this.dismiss(`turn:${evt.sessionId}`);
|
|
1048
|
-
this.
|
|
1049
|
-
op: "dismiss", noticeId: `turn:${evt.sessionId}`, kind: "agent_running", title: "", body: "", at: Date.now(),
|
|
1050
|
-
});
|
|
1170
|
+
this.emitNotice({ op: "dismiss", noticeId: `turn:${evt.sessionId}`, kind: "agent_running", title: "", body: "" });
|
|
1051
1171
|
// Both gates belong here and both were missing: `completion` was stored but never read, and
|
|
1052
1172
|
// presence was only consulted for turn-start — so the completion card fired while the operator was watching
|
|
1053
1173
|
// the very session (and, mid-call, with a sound: the reported bug).
|
|
@@ -0,0 +1,295 @@
|
|
|
1
|
+
// Web Push — the third renderer of the same notification decision.
|
|
2
|
+
//
|
|
3
|
+
// The three channels and why they exist:
|
|
4
|
+
//
|
|
5
|
+
// phone (WS) — the Android companion holds one long connection; only works while the phone is awake.
|
|
6
|
+
// page — the open tab draws a notification through the page's own Notification API.
|
|
7
|
+
// push (here) — the ONLY channel that survives a locked screen: the page is frozen by iOS/Android
|
|
8
|
+
// the moment it leaves the foreground, and no JS runs to notice anything. A push
|
|
9
|
+
// service holds the message until the device is reachable, wakes the service worker,
|
|
10
|
+
// and the worker draws the card. That is why this exists and the page renderer cannot
|
|
11
|
+
// replace it.
|
|
12
|
+
//
|
|
13
|
+
// Nothing here decides WHAT is worth notifying — `center.ts` owns that (one decision, three renderers).
|
|
14
|
+
// This module only knows how to hand bytes to a push service: encryption (RFC 8291 / aes128gcm) and
|
|
15
|
+
// VAPID authorization (RFC 8292).
|
|
16
|
+
//
|
|
17
|
+
// No dependency on `web-push`: the whole job is ~120 lines of node:crypto, and a dependency here would
|
|
18
|
+
// mean an npm install on a machine whose registry access is already the flakiest part of a release.
|
|
19
|
+
// The RFC's own test vector is checked by `scripts/qa/webpush-crypto.mts` — see the note at encryptPush().
|
|
20
|
+
import crypto from "node:crypto";
|
|
21
|
+
import fs from "node:fs";
|
|
22
|
+
import http from "node:http";
|
|
23
|
+
import https from "node:https";
|
|
24
|
+
import path from "node:path";
|
|
25
|
+
|
|
26
|
+
/** One browser's subscription, as `pushManager.subscribe()` returned it. */
|
|
27
|
+
export interface PushSubscriptionRecord {
|
|
28
|
+
endpoint: string;
|
|
29
|
+
/** base64url of the UA's uncompressed P-256 point (65 bytes) — the `keys.p256dh` the browser gave us. */
|
|
30
|
+
p256dh: string;
|
|
31
|
+
/** base64url of the 16-byte auth secret — `keys.auth`. */
|
|
32
|
+
auth: string;
|
|
33
|
+
ua?: string;
|
|
34
|
+
createdAt: number;
|
|
35
|
+
/** Last time a push was accepted by the push service (HTTP 201). */
|
|
36
|
+
lastOkAt?: number;
|
|
37
|
+
fails: number;
|
|
38
|
+
lastError?: string;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
interface VapidFile {
|
|
42
|
+
/** base64url uncompressed point (65 bytes) — what the browser needs as applicationServerKey. */
|
|
43
|
+
publicKey: string;
|
|
44
|
+
/** The private half, kept as a JWK because that is what createPrivateKey() eats. */
|
|
45
|
+
jwk: { kty: string; crv: string; x: string; y: string; d: string };
|
|
46
|
+
subject: string;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
interface SubscriptionFile {
|
|
50
|
+
subs: PushSubscriptionRecord[];
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// ---- base64url ----------------------------------------------------------------
|
|
54
|
+
|
|
55
|
+
export const b64u = (buf: Buffer): string => buf.toString("base64").replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
|
56
|
+
|
|
57
|
+
export const unb64u = (str: string): Buffer => Buffer.from(str.replace(/-/g, "+").replace(/_/g, "/"), "base64");
|
|
58
|
+
|
|
59
|
+
// ---- RFC 8291: encrypting one push message ------------------------------------
|
|
60
|
+
|
|
61
|
+
/** The message body a push service carries for us: `aes128gcm` single record (RFC 8188 §2, RFC 8291 §4).
|
|
62
|
+
*
|
|
63
|
+
* Layout of the body: 16-byte salt ‖ rs (u32 BE = 4096) ‖ idlen (u8 = 65) ‖ AS public key ‖ ciphertext.
|
|
64
|
+
* The plaintext gets the padding delimiter 0x02 appended, because it is the last (only) record.
|
|
65
|
+
*
|
|
66
|
+
* `opts` exists ONLY so the RFC 8291 §5 test vector can be reproduced byte-for-byte: it pins the salt
|
|
67
|
+
* and the application-server keypair. Production calls always pass no salt and a fresh keypair — the
|
|
68
|
+
* whole security property rests on both being random per message.
|
|
69
|
+
*
|
|
70
|
+
* Verified against that vector (Appendix A gives every intermediate: ecdh_secret, PRK_key, IKM, PRK,
|
|
71
|
+
* CEK, NONCE, and the final body). If this function and the vector disagree, the push is silently
|
|
72
|
+
* undecryptable by the browser and nothing else in the system would tell us — hence a test, not hope. */
|
|
73
|
+
export function encryptPush(
|
|
74
|
+
uaPublicRaw: Buffer,
|
|
75
|
+
authSecret: Buffer,
|
|
76
|
+
plaintext: Buffer,
|
|
77
|
+
opts: { salt?: Buffer; asKeys?: crypto.KeyObject[] } = {},
|
|
78
|
+
): Buffer {
|
|
79
|
+
const salt = opts.salt ?? crypto.randomBytes(16);
|
|
80
|
+
const pair = opts.asKeys ?? (() => {
|
|
81
|
+
const { publicKey, privateKey } = crypto.generateKeyPairSync("ec", { namedCurve: "P-256" });
|
|
82
|
+
return [privateKey, publicKey];
|
|
83
|
+
})();
|
|
84
|
+
const [asPrivate, asPublic] = pair;
|
|
85
|
+
|
|
86
|
+
const uaPublic = crypto.createPublicKey({
|
|
87
|
+
key: { kty: "EC", crv: "P-256", x: b64u(uaPublicRaw.subarray(1, 33)), y: b64u(uaPublicRaw.subarray(33, 65)) },
|
|
88
|
+
format: "jwk",
|
|
89
|
+
});
|
|
90
|
+
const ecdhSecret = crypto.diffieHellman({ privateKey: asPrivate, publicKey: uaPublic });
|
|
91
|
+
const asPoint = pointFromJwk(asPublic.export({ format: "jwk" }) as { x: string; y: string });
|
|
92
|
+
|
|
93
|
+
// IKM = HKDF-Extract(auth_secret, ecdh_secret) then Expand with "WebPush: info\0" ‖ ua ‖ as.
|
|
94
|
+
const keyInfo = Buffer.concat([Buffer.from("WebPush: info\0", "utf8"), uaPublicRaw, asPoint]);
|
|
95
|
+
const ikm = Buffer.from(crypto.hkdfSync("sha256", ecdhSecret, authSecret, keyInfo, 32));
|
|
96
|
+
|
|
97
|
+
// CEK/NONCE come from RFC 8188's HKDF with the per-message salt.
|
|
98
|
+
const cek = Buffer.from(crypto.hkdfSync("sha256", ikm, salt, Buffer.from("Content-Encoding: aes128gcm\0", "utf8"), 16));
|
|
99
|
+
const nonce = Buffer.from(crypto.hkdfSync("sha256", ikm, salt, Buffer.from("Content-Encoding: nonce\0", "utf8"), 12));
|
|
100
|
+
|
|
101
|
+
const record = Buffer.concat([plaintext, Buffer.from([0x02])]);
|
|
102
|
+
const cipher = crypto.createCipheriv("aes-128-gcm", cek, nonce);
|
|
103
|
+
const ct = Buffer.concat([cipher.update(record), cipher.final(), cipher.getAuthTag()]);
|
|
104
|
+
|
|
105
|
+
const rs = Buffer.alloc(4);
|
|
106
|
+
rs.writeUInt32BE(4096, 0);
|
|
107
|
+
return Buffer.concat([salt, rs, Buffer.from([asPoint.length]), asPoint, ct]);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function pointFromJwk(jwk: { x: string; y: string }): Buffer {
|
|
111
|
+
return Buffer.concat([Buffer.from([0x04]), unb64u(jwk.x), unb64u(jwk.y)]);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// ---- VAPID + delivery ---------------------------------------------------------
|
|
115
|
+
|
|
116
|
+
export interface SendResult { ok: boolean; status: number; error?: string; gone?: boolean }
|
|
117
|
+
|
|
118
|
+
export class WebPushService {
|
|
119
|
+
private readonly dir: string;
|
|
120
|
+
private readonly subject: string;
|
|
121
|
+
private readonly log: (line: string) => void;
|
|
122
|
+
private vapidCache: VapidFile | null = null;
|
|
123
|
+
private subsCache: PushSubscriptionRecord[] | null = null;
|
|
124
|
+
|
|
125
|
+
constructor(dataDir: string, opts: { subject?: string; log?: (line: string) => void } = {}) {
|
|
126
|
+
this.dir = path.join(dataDir, "notify");
|
|
127
|
+
this.subject = opts.subject ?? process.env.AGENTUS_VAPID_SUBJECT ?? "mailto:agentus@localhost";
|
|
128
|
+
this.log = opts.log ?? ((l) => console.log(l));
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
private get vapidFile(): string { return path.join(this.dir, "vapid.json"); }
|
|
132
|
+
private get subsFile(): string { return path.join(this.dir, "webpush.json"); }
|
|
133
|
+
|
|
134
|
+
/** The server's identity to push services. Generated on first use and then never rotated by itself:
|
|
135
|
+
* rotating the VAPID key invalidates every existing subscription, so that must stay a deliberate act. */
|
|
136
|
+
private vapid(): VapidFile {
|
|
137
|
+
if (this.vapidCache) return this.vapidCache;
|
|
138
|
+
try {
|
|
139
|
+
this.vapidCache = JSON.parse(fs.readFileSync(this.vapidFile, "utf8")) as VapidFile;
|
|
140
|
+
return this.vapidCache;
|
|
141
|
+
} catch { /* first boot */ }
|
|
142
|
+
const { publicKey, privateKey } = crypto.generateKeyPairSync("ec", { namedCurve: "P-256" });
|
|
143
|
+
const jwk = privateKey.export({ format: "jwk" }) as VapidFile["jwk"];
|
|
144
|
+
const record: VapidFile = { publicKey: b64u(pointFromJwk(jwk)), jwk, subject: this.subject };
|
|
145
|
+
fs.mkdirSync(this.dir, { recursive: true });
|
|
146
|
+
fs.writeFileSync(this.vapidFile, JSON.stringify(record, null, 1), { mode: 0o600 });
|
|
147
|
+
this.log(`[notify] generated a VAPID keypair (${this.vapidFile}) — back it up with the rest of <data>`);
|
|
148
|
+
this.vapidCache = record;
|
|
149
|
+
return record;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/** `applicationServerKey` for `pushManager.subscribe()`. */
|
|
153
|
+
publicKey(): string { return this.vapid().publicKey; }
|
|
154
|
+
|
|
155
|
+
subscriptions(): PushSubscriptionRecord[] {
|
|
156
|
+
if (this.subsCache) return this.subsCache;
|
|
157
|
+
try {
|
|
158
|
+
const raw = JSON.parse(fs.readFileSync(this.subsFile, "utf8")) as SubscriptionFile;
|
|
159
|
+
this.subsCache = Array.isArray(raw.subs) ? raw.subs : [];
|
|
160
|
+
} catch {
|
|
161
|
+
this.subsCache = [];
|
|
162
|
+
}
|
|
163
|
+
return this.subsCache;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
private save(): void {
|
|
167
|
+
fs.mkdirSync(this.dir, { recursive: true });
|
|
168
|
+
fs.writeFileSync(this.subsFile, JSON.stringify({ subs: this.subsCache ?? [] }, null, 1), { mode: 0o600 });
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/** Idempotent by endpoint: re-subscribing the same browser replaces its keys instead of piling up rows.
|
|
172
|
+
* A browser re-subscribes on every page load in some engines, so this must not grow. */
|
|
173
|
+
subscribe(input: { endpoint?: unknown; keys?: { p256dh?: unknown; auth?: unknown }; ua?: unknown }): PushSubscriptionRecord | { error: string } {
|
|
174
|
+
const endpoint = typeof input.endpoint === "string" ? input.endpoint : "";
|
|
175
|
+
const p256dh = typeof input.keys?.p256dh === "string" ? input.keys.p256dh : "";
|
|
176
|
+
const auth = typeof input.keys?.auth === "string" ? input.keys.auth : "";
|
|
177
|
+
if (!/^https?:\/\//.test(endpoint)) return { error: "endpoint must be an https URL from the browser" };
|
|
178
|
+
if (unb64u(p256dh).length !== 65) return { error: "keys.p256dh must be an uncompressed P-256 point (65 bytes)" };
|
|
179
|
+
if (unb64u(auth).length !== 16) return { error: "keys.auth must be 16 bytes" };
|
|
180
|
+
const subs = this.subscriptions();
|
|
181
|
+
const prior = subs.find((s) => s.endpoint === endpoint);
|
|
182
|
+
const rec: PushSubscriptionRecord = {
|
|
183
|
+
endpoint, p256dh, auth,
|
|
184
|
+
ua: typeof input.ua === "string" ? input.ua.slice(0, 200) : undefined,
|
|
185
|
+
createdAt: prior?.createdAt ?? Date.now(),
|
|
186
|
+
lastOkAt: prior?.lastOkAt,
|
|
187
|
+
fails: 0,
|
|
188
|
+
};
|
|
189
|
+
this.subsCache = [...subs.filter((s) => s.endpoint !== endpoint), rec];
|
|
190
|
+
this.save();
|
|
191
|
+
return rec;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
unsubscribe(endpoint: string): boolean {
|
|
195
|
+
const subs = this.subscriptions();
|
|
196
|
+
const next = subs.filter((s) => s.endpoint !== endpoint);
|
|
197
|
+
if (next.length === subs.length) return false;
|
|
198
|
+
this.subsCache = next;
|
|
199
|
+
this.save();
|
|
200
|
+
return true;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/** ES256 JWT over the endpoint's own origin (RFC 8292 §2). `aud` is per-endpoint on purpose: a token
|
|
204
|
+
* minted for one push service must not be replayable at another. */
|
|
205
|
+
private vapidAuth(endpoint: string, now = Date.now()): string {
|
|
206
|
+
const { publicKey, jwk } = this.vapid();
|
|
207
|
+
const header = b64u(Buffer.from(JSON.stringify({ typ: "JWT", alg: "ES256" })));
|
|
208
|
+
const claims = b64u(Buffer.from(JSON.stringify({
|
|
209
|
+
aud: new URL(endpoint).origin,
|
|
210
|
+
exp: Math.floor(now / 1000) + 12 * 3600,
|
|
211
|
+
sub: this.subject,
|
|
212
|
+
})));
|
|
213
|
+
const signingInput = `${header}.${claims}`;
|
|
214
|
+
const key = crypto.createPrivateKey({ key: jwk, format: "jwk" });
|
|
215
|
+
// ieee-p1363 = raw R‖S; JWS wants exactly that, DER would be rejected.
|
|
216
|
+
const sig = crypto.sign("sha256", Buffer.from(signingInput), { key, dsaEncoding: "ieee-p1363" });
|
|
217
|
+
return `vapid t=${signingInput}.${b64u(sig)}, k=${publicKey}`;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/** Hand one message to one push service. `urgency` maps to the RFC 8030 header the platforms honour:
|
|
221
|
+
* a lock-screen approval is `high` (deliver immediately, wake the device), a "finished" is `normal`,
|
|
222
|
+
* and taking a stale card back is `very-low` (do not wake anything for it). */
|
|
223
|
+
async send(
|
|
224
|
+
rec: PushSubscriptionRecord,
|
|
225
|
+
payload: unknown,
|
|
226
|
+
opts: { ttlSec?: number; urgency?: "very-low" | "low" | "normal" | "high"; timeoutMs?: number } = {},
|
|
227
|
+
): Promise<SendResult> {
|
|
228
|
+
let body: Buffer;
|
|
229
|
+
try {
|
|
230
|
+
body = encryptPush(unb64u(rec.p256dh), unb64u(rec.auth), Buffer.from(JSON.stringify(payload), "utf8"));
|
|
231
|
+
} catch (e) {
|
|
232
|
+
return { ok: false, status: 0, error: `encrypt: ${(e as Error).message}` };
|
|
233
|
+
}
|
|
234
|
+
let auth: string;
|
|
235
|
+
try {
|
|
236
|
+
auth = this.vapidAuth(rec.endpoint);
|
|
237
|
+
} catch (e) {
|
|
238
|
+
return { ok: false, status: 0, error: `vapid: ${(e as Error).message}` };
|
|
239
|
+
}
|
|
240
|
+
const url = new URL(rec.endpoint);
|
|
241
|
+
const mod = url.protocol === "http:" ? http : https;
|
|
242
|
+
return await new Promise<SendResult>((resolve) => {
|
|
243
|
+
const req = mod.request({
|
|
244
|
+
protocol: url.protocol,
|
|
245
|
+
hostname: url.hostname,
|
|
246
|
+
port: url.port || (url.protocol === "http:" ? 80 : 443),
|
|
247
|
+
path: `${url.pathname}${url.search}`,
|
|
248
|
+
method: "POST",
|
|
249
|
+
headers: {
|
|
250
|
+
authorization: auth,
|
|
251
|
+
"content-encoding": "aes128gcm",
|
|
252
|
+
"content-type": "application/octet-stream",
|
|
253
|
+
"content-length": String(body.length),
|
|
254
|
+
ttl: String(opts.ttlSec ?? 3600),
|
|
255
|
+
urgency: opts.urgency ?? "normal",
|
|
256
|
+
},
|
|
257
|
+
timeout: opts.timeoutMs ?? 8000,
|
|
258
|
+
}, (res) => {
|
|
259
|
+
const chunks: Buffer[] = [];
|
|
260
|
+
res.on("data", (c) => chunks.push(Buffer.from(c)));
|
|
261
|
+
res.on("end", () => {
|
|
262
|
+
const status = res.statusCode ?? 0;
|
|
263
|
+
const ok = status >= 200 && status < 300;
|
|
264
|
+
const text = Buffer.concat(chunks).toString("utf8").slice(0, 300);
|
|
265
|
+
const gone = status === 404 || status === 410;
|
|
266
|
+
if (ok) {
|
|
267
|
+
rec.lastOkAt = Date.now();
|
|
268
|
+
rec.fails = 0;
|
|
269
|
+
rec.lastError = undefined;
|
|
270
|
+
this.save();
|
|
271
|
+
} else {
|
|
272
|
+
rec.fails += 1;
|
|
273
|
+
rec.lastError = `${status} ${text}`.trim();
|
|
274
|
+
// 404/410 = the browser threw the subscription away (app uninstalled, permission reset).
|
|
275
|
+
// Keeping it would retry a dead endpoint forever and log a failure on every notification.
|
|
276
|
+
if (gone) this.unsubscribe(rec.endpoint);
|
|
277
|
+
else this.save();
|
|
278
|
+
}
|
|
279
|
+
resolve({ ok, status, error: ok ? undefined : `${status} ${text}`.trim(), gone });
|
|
280
|
+
});
|
|
281
|
+
});
|
|
282
|
+
req.on("timeout", () => { req.destroy(new Error("push service timed out")); });
|
|
283
|
+
req.on("error", (e) => resolve({ ok: false, status: 0, error: e.message }));
|
|
284
|
+
req.end(body);
|
|
285
|
+
});
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
/** Send the same payload to every subscription. Returns one result per device so the settings card can
|
|
289
|
+
* say "2 台设备都收到了" or which one is dead — a silent broadcast would hide a broken device. */
|
|
290
|
+
async broadcast(payload: unknown, opts: { ttlSec?: number; urgency?: "very-low" | "low" | "normal" | "high" } = {}): Promise<SendResult[]> {
|
|
291
|
+
const subs = this.subscriptions();
|
|
292
|
+
if (!subs.length) return [];
|
|
293
|
+
return await Promise.all(subs.map((s) => this.send(s, payload, opts)));
|
|
294
|
+
}
|
|
295
|
+
}
|
|
@@ -185,9 +185,15 @@ export interface AgentSettings {
|
|
|
185
185
|
* (`context`/`messages`/`plan`) is NOT gated: it cannot change anything, and it is what the
|
|
186
186
|
* 工作播报 observer runs on. */
|
|
187
187
|
cockpitSend: boolean;
|
|
188
|
+
/** Let a finished background process wake the agent with its own result (issue #22). ON by default:
|
|
189
|
+
* this is what the CLI and the gateway already do, and the failure it prevents is the silent one —
|
|
190
|
+
* a session that looks finished while a build is still running, whose result nobody ever reads. */
|
|
191
|
+
completionWake: boolean;
|
|
188
192
|
}
|
|
189
193
|
|
|
190
|
-
export const AGENT_DEFAULT: AgentSettings = {
|
|
194
|
+
export const AGENT_DEFAULT: AgentSettings = {
|
|
195
|
+
idleKillMin: 5, warmSlots: 5, warmTtlHours: 24, cockpitSend: false, completionWake: true,
|
|
196
|
+
};
|
|
191
197
|
|
|
192
198
|
/** The numeric half of `AgentSettings` — the keys the range-checked helpers may write. */
|
|
193
199
|
type AgentNumberKey = "idleKillMin" | "warmSlots" | "warmTtlHours";
|
|
@@ -419,6 +425,7 @@ function pickAgent(raw: unknown): AgentSettings {
|
|
|
419
425
|
take("warmSlots", AGENT_WARM_RANGE);
|
|
420
426
|
take("warmTtlHours", AGENT_WARM_TTL_RANGE);
|
|
421
427
|
if (typeof r.cockpitSend === "boolean") out.cockpitSend = r.cockpitSend;
|
|
428
|
+
if (typeof r.completionWake === "boolean") out.completionWake = r.completionWake;
|
|
422
429
|
}
|
|
423
430
|
return out;
|
|
424
431
|
}
|
|
@@ -718,6 +725,10 @@ export function saveAgent(patch: Record<string, unknown>): Settings {
|
|
|
718
725
|
if (typeof patch.cockpitSend !== "boolean") throw new Error("invalid cockpitSend: expected true or false");
|
|
719
726
|
a.cockpitSend = patch.cockpitSend;
|
|
720
727
|
}
|
|
728
|
+
if ("completionWake" in patch) {
|
|
729
|
+
if (typeof patch.completionWake !== "boolean") throw new Error("invalid completionWake: expected true or false");
|
|
730
|
+
a.completionWake = patch.completionWake;
|
|
731
|
+
}
|
|
721
732
|
cache = { ...cache, agent: a, updatedAt: Date.now() };
|
|
722
733
|
persist();
|
|
723
734
|
return cache;
|
|
@@ -924,6 +924,56 @@ export class Store {
|
|
|
924
924
|
return { ...m, seq };
|
|
925
925
|
}
|
|
926
926
|
|
|
927
|
+
/**
|
|
928
|
+
* 一条「状态行」落库(操作者 2026-10-18):回忆了几条记忆、上下文压缩了、有警告。
|
|
929
|
+
*
|
|
930
|
+
* 为什么它不是 `notes` 表而是一行 `messages`(kind `note`):状态行的位置信息就是 seq ——
|
|
931
|
+
* 「在用户那句话之后、在回复之前」。单独一张表要么丢掉这个位置,要么在客户端重建锚点,
|
|
932
|
+
* 而消息流本来就已经按 seq 排队、分页、回放,复用它是这里最省也最不容易错的做法。
|
|
933
|
+
*
|
|
934
|
+
* 为什么必须在这里折叠,而不是留给前端(前端 state.ts 原来是自己折的):压缩是「开始 → 每几秒
|
|
935
|
+
* 一个心跳 → 完成」,provider 等待也是十来秒一条。客户端折只解决"屏幕上的噪音",库里照样会
|
|
936
|
+
* 长出一串心跳。规则只有一条 —— **位置没变就折**:本会话最后一条消息如果是同类的状态行
|
|
937
|
+
* (说明这中间没有别的消息落下),就更新它,而不是再加一行;位置变了(新消息进来了)就另起一行。
|
|
938
|
+
* 于是「压缩中 → 完成」收敛成一行(保留开始时间),一个回合一次回忆也只留一行。
|
|
939
|
+
*/
|
|
940
|
+
appendNote(sessionId: string, note: { category: string; text: string; createdAt?: number }): StoredMessage {
|
|
941
|
+
const createdAt = note.createdAt ?? Date.now();
|
|
942
|
+
const edge = this.#db
|
|
943
|
+
.prepare("select coalesce(max(seq),0) as seq from messages where session_id = ?")
|
|
944
|
+
.get(sessionId) as { seq: number };
|
|
945
|
+
const last = this.#db
|
|
946
|
+
.prepare("select seq, payload, created_at from messages where session_id = ? and kind = 'note' order by seq desc limit 1")
|
|
947
|
+
.get(sessionId) as { seq: number; payload: string; created_at: number } | undefined;
|
|
948
|
+
if (last && last.seq === edge.seq) {
|
|
949
|
+
const prev = JSON.parse(last.payload) as { category?: string; text?: string; hits?: number };
|
|
950
|
+
if (prev.category === note.category) {
|
|
951
|
+
const payload = { category: note.category, text: note.text, hits: (prev.hits ?? 1) + 1 };
|
|
952
|
+
// `created_at` stays: it answers "这一轮什么时候开始回忆/压缩的", and the heartbeat is not news.
|
|
953
|
+
this.#db
|
|
954
|
+
.prepare("update messages set payload = ? where session_id = ? and seq = ?")
|
|
955
|
+
.run(JSON.stringify(payload), sessionId, last.seq);
|
|
956
|
+
return {
|
|
957
|
+
seq: last.seq, sessionId, kind: "note", payload, createdAt: last.created_at,
|
|
958
|
+
};
|
|
959
|
+
}
|
|
960
|
+
}
|
|
961
|
+
const row = this.appendMessage({
|
|
962
|
+
sessionId, kind: "note", payload: { category: note.category, text: note.text, hits: 1 }, createdAt,
|
|
963
|
+
});
|
|
964
|
+
// Bounded: a session left open for weeks must not keep every recall it ever did. 80 is well past
|
|
965
|
+
// what any transcript page shows, and a note is ~120 bytes.
|
|
966
|
+
const over = this.#db
|
|
967
|
+
.prepare("select seq from messages where session_id = ? and kind = 'note' order by seq desc limit -1 offset 80")
|
|
968
|
+
.all(sessionId) as { seq: number }[];
|
|
969
|
+
if (over.length) {
|
|
970
|
+
const del = this.#db.prepare("delete from messages where session_id = ? and seq = ?");
|
|
971
|
+
for (const r of over) del.run(sessionId, r.seq);
|
|
972
|
+
}
|
|
973
|
+
return row;
|
|
974
|
+
}
|
|
975
|
+
|
|
976
|
+
|
|
927
977
|
/**
|
|
928
978
|
* The streaming half of the transcript: one logical message, one row.
|
|
929
979
|
*
|
|
@@ -169,7 +169,7 @@ export interface PermissionDiff {
|
|
|
169
169
|
// ---- WS envelope (server -> browser) ----
|
|
170
170
|
export type ServerEvent =
|
|
171
171
|
| { t: "hello"; clientId: string; resumed: boolean }
|
|
172
|
-
| { t: "sessions"; sessions: SessionInfo[]; /** rows on disk with NO process and no archive flag — they belong in 工作空间 beside the live ones (a crashed slot must not look archived, and must not vanish from the rail either) */ cold?: SessionInfo[]; /** rows the operator explicitly archived (archivedAt set) — the 已归档 section, whatever their process state */ archived?: SessionInfo[]; /** requests already waiting for an answer, so a page that connects or refreshes learns about them (they are STATE; the events happened before it existed) */ pending?: PermissionRequestView[] }
|
|
172
|
+
| { t: "sessions"; sessions: SessionInfo[]; /** current background snapshots, for a page that connects AFTER the frame that carried them */ background?: { sessionId: string; tasks: BackgroundTask[] }[]; /** rows on disk with NO process and no archive flag — they belong in 工作空间 beside the live ones (a crashed slot must not look archived, and must not vanish from the rail either) */ cold?: SessionInfo[]; /** rows the operator explicitly archived (archivedAt set) — the 已归档 section, whatever their process state */ archived?: SessionInfo[]; /** requests already waiting for an answer, so a page that connects or refreshes learns about them (they are STATE; the events happened before it existed) */ pending?: PermissionRequestView[] }
|
|
173
173
|
| { t: "session"; session: SessionInfo }
|
|
174
174
|
| { t: "messages"; sessionId: string; messages: StoredMessage[]; hasMore: boolean; partial?: boolean }
|
|
175
175
|
| { t: "message"; message: StoredMessage; /** text to APPEND for a streamed block that grew (absent
|
|
@@ -214,6 +214,15 @@ export type ServerEvent =
|
|
|
214
214
|
* conversation from last week). Carrier on the ACP side: an empty thought chunk tagged
|
|
215
215
|
* `_meta.hermes.kind = "status"`. */
|
|
216
216
|
| { t: "status"; sessionId: string; text: string; statusKind?: string }
|
|
217
|
+
/** Background processes this session still has (issue #22) — the ACP equivalent of the desktop's
|
|
218
|
+
* status stack, and the reason a turn can end while work goes on.
|
|
219
|
+
*
|
|
220
|
+
* SNAPSHOT, replaced wholesale, never stored: the agent re-sends the whole list whenever it changes,
|
|
221
|
+
* and the last one wins. A row is 「running」 or 「exited」 (with `exitCode`) — the exit is reported
|
|
222
|
+
* once, because a process without `notify_on_complete` publishes no event and this frame is the only
|
|
223
|
+
* witness it ever ended. Empty `tasks` = nothing runs; the page shows nothing.
|
|
224
|
+
* Carrier on the ACP side: an empty thought chunk tagged `_meta.hermes.kind = "background"`. */
|
|
225
|
+
| { t: "background"; sessionId: string; tasks: BackgroundTask[] }
|
|
217
226
|
/** The session's plan object — the TRUTH the card renders, not the transcript's frames. Sent on
|
|
218
227
|
* every change, and the same object is available over GET /api/sessions/:id/plan, so a page that
|
|
219
228
|
* loads after the agent process died still renders the real plan instead of nothing. */
|
|
@@ -241,6 +250,18 @@ export type ServerEvent =
|
|
|
241
250
|
* this tab in the background (`document.hidden` — the page's own answer to 「我正在看就不弹」, made
|
|
242
251
|
* where the fact lives), (3) draw it with `noticeId` as the notification `tag`. Everything about WHAT
|
|
243
252
|
* happened and WHEN to say it is decided server-side. */
|
|
253
|
+
/** One background process as the agent reports it (issue #22). Field names are the wire's, not the
|
|
254
|
+
* registry's: `uptimeSeconds`/`exitCode` are normalised server-side so the page never has to know
|
|
255
|
+
* whether it is reading `list_sessions()` or a completion event. */
|
|
256
|
+
export interface BackgroundTask {
|
|
257
|
+
id: string;
|
|
258
|
+
command: string;
|
|
259
|
+
pid?: number | null;
|
|
260
|
+
status: "running" | "exited";
|
|
261
|
+
uptimeSeconds?: number;
|
|
262
|
+
exitCode?: number | null;
|
|
263
|
+
}
|
|
264
|
+
|
|
244
265
|
export interface WebNotice {
|
|
245
266
|
/** `upsert` = show (or replace) the card for `noticeId`; `dismiss` = take it away. */
|
|
246
267
|
op: "upsert" | "dismiss";
|
|
@@ -403,7 +424,11 @@ export type ClientCommand =
|
|
|
403
424
|
export interface StoredMessage {
|
|
404
425
|
seq: number; // monotonic per session — reconnect dedup anchor
|
|
405
426
|
sessionId: string;
|
|
406
|
-
|
|
427
|
+
/** `note` = a 「状态行」 that is not a message: 「recalled 8 memories」 / 「compacting」 / a warning.
|
|
428
|
+
* Deliberately a transcript row (not a separate table): its position IS the seq — after the
|
|
429
|
+
* operator's sentence, before the reply — and the page already orders, pages and replays by seq.
|
|
430
|
+
* `meta` stays what it was: a line the HOST says (a rejected send), never persisted. */
|
|
431
|
+
kind: "user" | "agent" | "thought" | "tool" | "plan" | "meta" | "note";
|
|
407
432
|
payload: unknown; // raw ACP content/update fragment (kept lossless)
|
|
408
433
|
toolCallId?: string; // set for kind=tool; upsert key
|
|
409
434
|
createdAt: number;
|