agentus 0.0.0-stage → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +202 -0
- package/NOTICE +11 -0
- package/README.md +476 -2
- package/README.zh-CN.md +396 -0
- package/bin/agentus.mjs +147 -0
- package/docs/android-notify-contract.md +658 -0
- package/docs/dev/01-dev-loop.md +186 -0
- package/docs/dev/02-verify.md +237 -0
- package/docs/dev/03-release.md +218 -0
- package/docs/dev/04-recover.md +199 -0
- package/docs/dev/05-agent-driven.md +114 -0
- package/docs/dev/README.md +65 -0
- package/docs/installation.md +366 -0
- package/docs/installation.zh-CN.md +350 -0
- package/docs/refs/aionui-acp-rules.md +46 -0
- package/docs/refs/hermes-studio-ui-ideas.md +191 -0
- package/docs/transcript-folding.md +139 -0
- package/package.json +76 -5
- package/packages/server/mcp/plan-server.mjs +217 -0
- package/packages/server/mock/agent.mjs +693 -0
- package/packages/server/package.json +22 -0
- package/packages/server/src/acp/backends.ts +196 -0
- package/packages/server/src/acp/errors.ts +46 -0
- package/packages/server/src/acp/registry.ts +579 -0
- package/packages/server/src/acp/session-manager.ts +1868 -0
- package/packages/server/src/auth.ts +688 -0
- package/packages/server/src/dashscope.ts +235 -0
- package/packages/server/src/fs.ts +379 -0
- package/packages/server/src/hotwords.ts +132 -0
- package/packages/server/src/index.ts +1486 -0
- package/packages/server/src/notify/center.ts +994 -0
- package/packages/server/src/notify/types.ts +222 -0
- package/packages/server/src/plan/plan.ts +126 -0
- package/packages/server/src/pty-helper.py +135 -0
- package/packages/server/src/settings.ts +469 -0
- package/packages/server/src/store/export.ts +209 -0
- package/packages/server/src/store/store.ts +1238 -0
- package/packages/server/src/term.ts +201 -0
- package/packages/server/src/title.ts +109 -0
- package/packages/server/src/voice.ts +251 -0
- package/packages/shared/package.json +14 -0
- package/packages/shared/src/index.ts +271 -0
- package/packages/web/dist/assets/index-D667PX2c.css +32 -0
- package/packages/web/dist/assets/index-d9NzwPFu.js +283 -0
- package/packages/web/dist/coding-agents/hermes.png +0 -0
- package/packages/web/dist/coding-agents/qoder.svg +5 -0
- package/packages/web/dist/icons/agentus-icon-inverse.svg +301 -0
- package/packages/web/dist/icons/agentus-icon.svg +301 -0
- package/packages/web/dist/icons/agentus-mark-inverse.svg +301 -0
- package/packages/web/dist/icons/agentus-mark.svg +301 -0
- package/packages/web/dist/icons/apple-touch-icon-180.png +0 -0
- package/packages/web/dist/icons/favicon.ico +0 -0
- package/packages/web/dist/icons/icon-192.png +0 -0
- package/packages/web/dist/icons/icon-512.png +0 -0
- package/packages/web/dist/icons/icon-maskable-512.png +0 -0
- package/packages/web/dist/index.html +30 -0
- package/packages/web/dist/manifest.webmanifest +17 -0
- package/packages/web/dist/sw.js +61 -0
- package/scripts/make-cert.sh +101 -0
|
@@ -0,0 +1,994 @@
|
|
|
1
|
+
// NotifyCenter — the server side of the notify contract (docs/android-notify-contract.md).
|
|
2
|
+
//
|
|
3
|
+
// Responsibilities, and nothing else:
|
|
4
|
+
// 1. pair devices (short code or the operator's machine token) and hand out device tokens
|
|
5
|
+
// 2. publish activities and fan them out over a WebSocket, with a monotonic cursor so a
|
|
6
|
+
// device that was asleep/asleep-behind-NAT can replay what it missed
|
|
7
|
+
// 3. route the button presses that come back (POST /api/notify/actions or a WS frame)
|
|
8
|
+
// to the host's handler — that is the whole "add an interaction = add a handler" trick
|
|
9
|
+
//
|
|
10
|
+
// It owns no agent logic: the ACP event -> activity mapping lives in the clearly marked
|
|
11
|
+
// section at the bottom, and everything it produces goes through the same publish().
|
|
12
|
+
import fs from "node:fs";
|
|
13
|
+
import path from "node:path";
|
|
14
|
+
import { randomBytes, timingSafeEqual } from "node:crypto";
|
|
15
|
+
import type { IncomingMessage, ServerResponse } from "node:http";
|
|
16
|
+
import type { Duplex } from "node:stream";
|
|
17
|
+
import { WebSocketServer, WebSocket } from "ws";
|
|
18
|
+
import type { PermissionRequestView, ServerEvent } from "../../../shared/src/index.js";
|
|
19
|
+
import { DEFAULT_NOTIFY_RULES } from "./types.js";
|
|
20
|
+
import type {
|
|
21
|
+
ActionCallback,
|
|
22
|
+
ActivityObject,
|
|
23
|
+
ActivityRecord,
|
|
24
|
+
DeviceCapabilities,
|
|
25
|
+
NotifyDevice,
|
|
26
|
+
NotifyFrame,
|
|
27
|
+
NotifyRef,
|
|
28
|
+
NotifyRules,
|
|
29
|
+
Presence,
|
|
30
|
+
OpenTarget,
|
|
31
|
+
} from "./types.js";
|
|
32
|
+
|
|
33
|
+
export interface NotifyCenterOptions {
|
|
34
|
+
/** `notify/` (state + event log) is created under here. */
|
|
35
|
+
dataDir: string;
|
|
36
|
+
/** Operator gate for the control routes. Absent = those routes are open (test bed only). */
|
|
37
|
+
operator?: (req: IncomingMessage, url: URL) => boolean;
|
|
38
|
+
/** Username+password pairing: the HOST owns the credential check and its rate limit. */
|
|
39
|
+
credentials?: (username: string, password: string, ip: string) => { ok: boolean; reason?: string };
|
|
40
|
+
/** Session title lookup, for activity subtitles. */
|
|
41
|
+
sessionTitle?: (sessionId: string) => string | null;
|
|
42
|
+
/** The companion APK, so the web panel can report its size (optional). */
|
|
43
|
+
apkPath?: string;
|
|
44
|
+
log?: (line: string) => void;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export interface ActionEvent {
|
|
48
|
+
action: ActionCallback;
|
|
49
|
+
activity: ActivityObject;
|
|
50
|
+
ref: NotifyRef | null;
|
|
51
|
+
device: NotifyDevice;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** What a publisher may attach to an activity: how to read its buttons later. */
|
|
55
|
+
export interface PublishOptions {
|
|
56
|
+
actionRefs?: Record<string, NotifyRef>;
|
|
57
|
+
/** Skip the WS fan-out (used by replay tests). */
|
|
58
|
+
silent?: boolean;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const REPLAY_LIMIT = 200; // how many activities stay replayable
|
|
62
|
+
const PAIR_ATTEMPTS = 10;
|
|
63
|
+
const PAIR_WINDOW_MS = 10 * 60_000;
|
|
64
|
+
const CODE_ALPHABET = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"; // no I/O/0/1
|
|
65
|
+
|
|
66
|
+
/** The same frame with its sound taken away — see `publish` (a live call has to stay quiet).
|
|
67
|
+
* `muted` is set too: the app cannot turn an existing Android channel quiet, so it has to route this
|
|
68
|
+
* frame to its own quiet channel instead (see Notifier.apply). */
|
|
69
|
+
function silentChannel(a: ActivityObject): ActivityObject {
|
|
70
|
+
if (!a.channel) return a;
|
|
71
|
+
return { ...a, channel: { ...a.channel, importance: "low", sound: false, vibration: false, muted: true } };
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
interface NotifyState {
|
|
75
|
+
code: string;
|
|
76
|
+
seq: number;
|
|
77
|
+
devices: NotifyDevice[];
|
|
78
|
+
/** The operator's switches. Absent in a state file written before this existed. */
|
|
79
|
+
rules?: NotifyRules;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** The tap target, in the contract's terms.
|
|
83
|
+
*
|
|
84
|
+
* `deeplink` is what an app built before the `open` object understands; `open` is the abstract form
|
|
85
|
+
* (a URL + how strongly the sender wants it in an app). Both are emitted from one place so they can
|
|
86
|
+
* never disagree, and the receiver keeps the final say (see OpenTarget).
|
|
87
|
+
*/
|
|
88
|
+
function tapTarget(url: string, prefer: OpenTarget["prefer"] = "app"): Pick<ActivityObject, "deeplink" | "open"> {
|
|
89
|
+
return { deeplink: url, open: { url, prefer: prefer ?? "auto" } };
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** How a finished turn reads on a lock screen: 「用时 1m12s」.
|
|
93
|
+
*
|
|
94
|
+
* The protocol's own answer (`stopReason: end_turn`) is a fact about the wire, not about the
|
|
95
|
+
* operator's wait — the ecosystem's version of this line is agent-notify's "Done in 12s" / AionUi's
|
|
96
|
+
* 「已完成本轮回复」. Empty string when the duration is unknown, and then the card simply says less. */
|
|
97
|
+
function durationText(durationMs?: number): string {
|
|
98
|
+
if (!durationMs || !Number.isFinite(durationMs) || durationMs < 0) return "";
|
|
99
|
+
const total = Math.round(durationMs / 1000);
|
|
100
|
+
if (total < 60) return `用时 ${Math.max(1, total)}s`;
|
|
101
|
+
const m = Math.floor(total / 60);
|
|
102
|
+
if (m < 60) return total % 60 ? `用时 ${m}m ${total % 60}s` : `用时 ${m}m`;
|
|
103
|
+
return `用时 ${Math.floor(m / 60)}h ${m % 60}m`;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** The session name rides FIRST IN THE BODY — never in `subtitle`.
|
|
107
|
+
*
|
|
108
|
+
* Android draws `contentText` (our body) on the COLLAPSED row and `subText` (our subtitle) only on
|
|
109
|
+
* the expanded card, so a name parked in subtitle is a name nobody sees when they glance at the
|
|
110
|
+
* shade. Measured on the emulator: `android.title=跑完了` / `android.subText=hello` while the one
|
|
111
|
+
* visible line read `stopReason: end_turn`. AionUi solved the same problem the same way (conversation
|
|
112
|
+
* name at the front of the body). So every kind shares one shape: title = what happened,
|
|
113
|
+
* body = `<会话名> · <detail>`. */
|
|
114
|
+
function nameAndDetail(name: string, ...detail: Array<string | undefined | null>): string {
|
|
115
|
+
return [name, ...detail.filter((d): d is string => Boolean(d && d.trim()))].join(" · ");
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** The agent's own tool title, split at its first ": ".
|
|
119
|
+
*
|
|
120
|
+
* The head goes on the state line (short — the Xiaomi island clips a title at 20 chars), and the
|
|
121
|
+
* long half is nearly always the target (`Approve edit: /var/folders/…`), which belongs beside the
|
|
122
|
+
* session name on the body line. Nothing is invented here: both halves are the agent's own words. */
|
|
123
|
+
function splitToolTitle(raw: string | undefined): { head: string; target: string } {
|
|
124
|
+
const text = (raw ?? "").trim();
|
|
125
|
+
const at = text.indexOf(": ");
|
|
126
|
+
if (at > 0 && at <= 24) return { head: text.slice(0, at), target: text.slice(at + 2).trim() };
|
|
127
|
+
return { head: text, target: "" };
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
export class NotifyCenter {
|
|
131
|
+
readonly dir: string;
|
|
132
|
+
private readonly stateFile: string;
|
|
133
|
+
private readonly eventsFile: string;
|
|
134
|
+
private readonly log: (line: string) => void;
|
|
135
|
+
private readonly operator: ((req: IncomingMessage, url: URL) => boolean) | null;
|
|
136
|
+
private readonly credentials: ((username: string, password: string, ip: string) => { ok: boolean; reason?: string }) | null;
|
|
137
|
+
private readonly sessionTitle: ((sessionId: string) => string | null) | null;
|
|
138
|
+
/** The APK the phone installs (optional): the web panel reports its size from here. */
|
|
139
|
+
private readonly apkPath: string | null = null;
|
|
140
|
+
|
|
141
|
+
private state: NotifyState;
|
|
142
|
+
private events: ActivityRecord[] = [];
|
|
143
|
+
/** deviceId -> live socket */
|
|
144
|
+
private readonly sockets = new Map<string, WebSocket>();
|
|
145
|
+
private readonly pairAttempts = new Map<string, { count: number; until: number }>();
|
|
146
|
+
/** actionKey -> when it was consumed, so a button can only fire once. */
|
|
147
|
+
private readonly consumed = new Map<string, number>();
|
|
148
|
+
private onActionCb: ((e: ActionEvent) => void) | null = null;
|
|
149
|
+
private timers: NodeJS.Timeout[] = [];
|
|
150
|
+
private wss: WebSocketServer | null = null;
|
|
151
|
+
/** Which session the cockpit is showing, and whether its tab is visible. Deliberately in
|
|
152
|
+
* memory: presence is a fact about right now, not something to restore after a restart. */
|
|
153
|
+
private presence: Presence = { sessionId: null, visible: false, at: 0 };
|
|
154
|
+
/** How long a presence report stays believable without a refresh (the SPA re-reports every
|
|
155
|
+
* 30s, so anything older than this means the tab was closed / the machine went to sleep). */
|
|
156
|
+
private static readonly WATCH_TTL_MS = 90_000;
|
|
157
|
+
|
|
158
|
+
/** The operator's switches, always complete (a state file may predate them). */
|
|
159
|
+
rules(): NotifyRules {
|
|
160
|
+
return { ...DEFAULT_NOTIFY_RULES, ...(this.state.rules ?? {}) };
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
setRules(patch: Partial<NotifyRules>): NotifyRules {
|
|
164
|
+
const next: NotifyRules = { ...this.rules(), ...patch };
|
|
165
|
+
this.state.rules = next;
|
|
166
|
+
this.save();
|
|
167
|
+
this.log(`[notify] rules turnStart=${next.turnStart} approval=${next.approval} completion=${next.completion} quietWhenWatching=${next.quietWhenWatching}`);
|
|
168
|
+
return next;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/** The cockpit says which session it is looking at; null/visible=false means "nobody is". */
|
|
172
|
+
setPresence(sessionId: string | null, visible: boolean, call = false): Presence {
|
|
173
|
+
this.presence = {
|
|
174
|
+
sessionId: sessionId || null,
|
|
175
|
+
visible: Boolean(visible),
|
|
176
|
+
call: Boolean(call),
|
|
177
|
+
at: Date.now(),
|
|
178
|
+
};
|
|
179
|
+
return this.presence;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
presenceNow(): Presence {
|
|
183
|
+
return { ...this.presence };
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* Is the operator on a VOICE CALL right now (any session)? Page-reported, same TTL as presence.
|
|
188
|
+
*
|
|
189
|
+
* Strictly stronger than "watching", which is why it is its own thing: on a call the replies are
|
|
190
|
+
* already being read aloud by that very call, AND the call's own capture is a plain AudioRecord with
|
|
191
|
+
* no echo cancellation — so a notification tone goes straight into the agent's microphone. Both
|
|
192
|
+
* reasons point the same way: while a call is live, nothing may make a sound.
|
|
193
|
+
*/
|
|
194
|
+
private onCall(): boolean {
|
|
195
|
+
return Boolean(this.presence.call) && Date.now() - this.presence.at < NotifyCenter.WATCH_TTL_MS;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/** True when a notification for this session would interrupt the operator looking at it. */
|
|
199
|
+
private watching(sessionId: string): boolean {
|
|
200
|
+
if (!this.rules().quietWhenWatching) return false;
|
|
201
|
+
if (this.presence.sessionId !== sessionId) return false;
|
|
202
|
+
if (Date.now() - this.presence.at >= NotifyCenter.WATCH_TTL_MS) return false;
|
|
203
|
+
// A call counts even when nobody has touched the screen for minutes: on a call the hands are free
|
|
204
|
+
// and the page cannot know the difference between "listening" and "gone". The page's own idle
|
|
205
|
+
// heuristic would switch presence off mid-conversation — which is how a "跑完了" card with sound
|
|
206
|
+
// landed in the middle of a call.
|
|
207
|
+
return this.presence.visible || Boolean(this.presence.call);
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
constructor(opts: NotifyCenterOptions) {
|
|
211
|
+
this.dir = path.join(opts.dataDir, "notify");
|
|
212
|
+
fs.mkdirSync(this.dir, { recursive: true });
|
|
213
|
+
this.stateFile = path.join(this.dir, "state.json");
|
|
214
|
+
this.eventsFile = path.join(this.dir, "events.json");
|
|
215
|
+
this.log = opts.log ?? ((line: string) => console.log(line));
|
|
216
|
+
this.operator = opts.operator ?? null;
|
|
217
|
+
this.credentials = opts.credentials ?? null;
|
|
218
|
+
this.sessionTitle = opts.sessionTitle ?? null;
|
|
219
|
+
this.apkPath = opts.apkPath ?? null;
|
|
220
|
+
this.state = this.loadState();
|
|
221
|
+
this.events = this.loadEvents();
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
// ---- persistence ---------------------------------------------------------
|
|
225
|
+
|
|
226
|
+
private loadState(): NotifyState {
|
|
227
|
+
try {
|
|
228
|
+
const raw = JSON.parse(fs.readFileSync(this.stateFile, "utf8")) as Partial<NotifyState>;
|
|
229
|
+
if (typeof raw.code === "string" && raw.code.length >= 6) {
|
|
230
|
+
return {
|
|
231
|
+
code: raw.code,
|
|
232
|
+
seq: Number(raw.seq) || 0,
|
|
233
|
+
devices: Array.isArray(raw.devices) ? raw.devices as NotifyDevice[] : [],
|
|
234
|
+
// a state file from before rules existed means "all on" — the old behaviour
|
|
235
|
+
rules: { ...DEFAULT_NOTIFY_RULES, ...(raw.rules ?? {}) },
|
|
236
|
+
};
|
|
237
|
+
}
|
|
238
|
+
} catch { /* first boot */ }
|
|
239
|
+
return { code: this.mintCode(), seq: 0, devices: [], rules: { ...DEFAULT_NOTIFY_RULES } };
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
private loadEvents(): ActivityRecord[] {
|
|
243
|
+
try {
|
|
244
|
+
const raw = JSON.parse(fs.readFileSync(this.eventsFile, "utf8")) as ActivityRecord[];
|
|
245
|
+
return Array.isArray(raw) ? raw.slice(-REPLAY_LIMIT) : [];
|
|
246
|
+
} catch {
|
|
247
|
+
return [];
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
private save(): void {
|
|
252
|
+
this.write(this.stateFile, JSON.stringify(this.state, null, 2));
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
private saveEvents(): void {
|
|
256
|
+
this.write(this.eventsFile, JSON.stringify(this.events.slice(-REPLAY_LIMIT), null, 2));
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
private write(file: string, body: string): void {
|
|
260
|
+
const tmp = `${file}.tmp`;
|
|
261
|
+
fs.writeFileSync(tmp, body, { mode: 0o600 });
|
|
262
|
+
fs.renameSync(tmp, file);
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
private mintCode(): string {
|
|
266
|
+
const bytes = randomBytes(8);
|
|
267
|
+
let out = "";
|
|
268
|
+
for (let i = 0; i < 8; i++) out += CODE_ALPHABET[bytes[i] % CODE_ALPHABET.length];
|
|
269
|
+
return out;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
// ---- pairing -------------------------------------------------------------
|
|
273
|
+
|
|
274
|
+
get code(): string {
|
|
275
|
+
return this.state.code;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
rotateCode(): string {
|
|
279
|
+
this.state.code = this.mintCode();
|
|
280
|
+
this.save();
|
|
281
|
+
return this.state.code;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
devices(): NotifyDevice[] {
|
|
285
|
+
return this.state.devices.map((d) => ({ ...d, token: d.token }));
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
revoke(deviceId: string): boolean {
|
|
289
|
+
const before = this.state.devices.length;
|
|
290
|
+
this.state.devices = this.state.devices.filter((d) => d.deviceId !== deviceId);
|
|
291
|
+
if (this.state.devices.length === before) return false;
|
|
292
|
+
const sock = this.sockets.get(deviceId);
|
|
293
|
+
if (sock) {
|
|
294
|
+
this.finish(sock, "revoked");
|
|
295
|
+
this.sockets.delete(deviceId);
|
|
296
|
+
}
|
|
297
|
+
this.save();
|
|
298
|
+
return true;
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
/** Pairing: either the rotating short code (typed/pasted by the operator) or the
|
|
302
|
+
* machine token (scripts). A re-pair from the same device name replaces the old row. */
|
|
303
|
+
pair(body: Record<string, unknown>, opts: { ip: string; machineTokenOk: boolean }): { device: NotifyDevice } | { error: string } {
|
|
304
|
+
const code = String(body.code ?? "").trim().toUpperCase();
|
|
305
|
+
const username = String(body.username ?? "").trim();
|
|
306
|
+
const password = String(body.password ?? "");
|
|
307
|
+
|
|
308
|
+
// Three ways in, in descending order of what a phone can actually do: the operator's own
|
|
309
|
+
// credential (scripts), the rotating short code (paste), or the operator's login
|
|
310
|
+
// (username+password typed in the app) — the last one is what a phone in a hotel uses.
|
|
311
|
+
let admitted = opts.machineTokenOk;
|
|
312
|
+
if (!admitted && code) admitted = code === this.state.code;
|
|
313
|
+
if (!admitted && username && password && this.credentials) {
|
|
314
|
+
const check = this.credentials(username, password, opts.ip);
|
|
315
|
+
if (!check.ok) return { error: check.reason ?? "bad username or password" };
|
|
316
|
+
admitted = true;
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
if (!admitted) {
|
|
320
|
+
// Code guessing is rate-limited per IP here; a wrong password is already accounted for
|
|
321
|
+
// by the host's own login limiter, so it must not consume this budget too.
|
|
322
|
+
const now = Date.now();
|
|
323
|
+
const gate = this.pairAttempts.get(opts.ip);
|
|
324
|
+
if (gate && gate.until > now && gate.count >= PAIR_ATTEMPTS) {
|
|
325
|
+
return { error: "too many attempts, try later" };
|
|
326
|
+
}
|
|
327
|
+
if (code || !username) {
|
|
328
|
+
const next = gate && gate.until > now ? gate : { count: 0, until: now + PAIR_WINDOW_MS };
|
|
329
|
+
next.count += 1;
|
|
330
|
+
this.pairAttempts.set(opts.ip, next);
|
|
331
|
+
return { error: "bad pairing code" };
|
|
332
|
+
}
|
|
333
|
+
return { error: "wrong username or password" };
|
|
334
|
+
}
|
|
335
|
+
const caps = (body.capabilities ?? []) as string[];
|
|
336
|
+
const name = String(body.deviceName ?? "device").slice(0, 60);
|
|
337
|
+
const prior = this.state.devices.find((d) => d.name === name);
|
|
338
|
+
if (prior) this.revoke(prior.deviceId);
|
|
339
|
+
const device: NotifyDevice = {
|
|
340
|
+
deviceId: `dev_${randomBytes(4).toString("hex")}`,
|
|
341
|
+
token: randomBytes(24).toString("hex"),
|
|
342
|
+
name,
|
|
343
|
+
platform: (body.platform as NotifyDevice["platform"]) ?? "android",
|
|
344
|
+
sdkInt: Number(body.sdkInt) || 0,
|
|
345
|
+
appVersion: String(body.appVersion ?? "0.0.0"),
|
|
346
|
+
schema: Number(body.schema) || 1,
|
|
347
|
+
capabilities: Array.isArray(caps) ? caps.map(String) : [],
|
|
348
|
+
createdAt: Date.now(),
|
|
349
|
+
lastSeenAt: null,
|
|
350
|
+
cursor: 0,
|
|
351
|
+
};
|
|
352
|
+
this.state.devices.push(device);
|
|
353
|
+
this.save();
|
|
354
|
+
this.log(`[notify] paired ${device.name} (${device.deviceId}) sdk=${device.sdkInt} caps=[${device.capabilities.join(",")}]`);
|
|
355
|
+
return { device };
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
private deviceByToken(token: string | undefined | null): NotifyDevice | null {
|
|
359
|
+
if (!token) return null;
|
|
360
|
+
const want = Buffer.from(String(token));
|
|
361
|
+
for (const d of this.state.devices) {
|
|
362
|
+
const have = Buffer.from(d.token);
|
|
363
|
+
if (have.length === want.length && timingSafeEqual(have, want)) return d;
|
|
364
|
+
}
|
|
365
|
+
return null;
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
private deviceById(deviceId: string): NotifyDevice | null {
|
|
369
|
+
return this.state.devices.find((d) => d.deviceId === deviceId) ?? null;
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
// ---- publishing ----------------------------------------------------------
|
|
373
|
+
|
|
374
|
+
/** Publish one activity. Returns the assigned seq (the cursor). */
|
|
375
|
+
publish(activity: ActivityObject, opts: PublishOptions = {}): number {
|
|
376
|
+
if (activity.op === "dismiss") {
|
|
377
|
+
const seq = ++this.state.seq;
|
|
378
|
+
this.remember({ seq, activity, actionRefs: {}, publishedAt: Date.now() });
|
|
379
|
+
if (!opts.silent) this.fanout(seq, activity);
|
|
380
|
+
return seq;
|
|
381
|
+
}
|
|
382
|
+
if (activity.op === "clear_all") {
|
|
383
|
+
const seq = ++this.state.seq;
|
|
384
|
+
this.events = [];
|
|
385
|
+
this.state.seq = seq;
|
|
386
|
+
this.remember({ seq, activity, actionRefs: {}, publishedAt: Date.now() });
|
|
387
|
+
if (!opts.silent) this.fanout(seq, activity);
|
|
388
|
+
return seq;
|
|
389
|
+
}
|
|
390
|
+
const seq = ++this.state.seq;
|
|
391
|
+
// While a call is live the frame still goes out — the operator must be able to SEE it and act on it —
|
|
392
|
+
// but silently. Not a style choice: the call's capture is a plain AudioRecord (no echo cancellation),
|
|
393
|
+
// so the notification tone is recorded by the very microphone the agent is listening on.
|
|
394
|
+
const wire = this.onCall() ? silentChannel(activity) : activity;
|
|
395
|
+
// re-publishing an activity clears its replay guard: the buttons work again
|
|
396
|
+
for (const key of [...this.consumed.keys()]) if (key.startsWith(`${activity.activityId}:`)) this.consumed.delete(key);
|
|
397
|
+
this.remember({ seq, activity: wire, actionRefs: opts.actionRefs ?? {}, publishedAt: Date.now() });
|
|
398
|
+
if (!opts.silent) this.fanout(seq, wire);
|
|
399
|
+
return seq;
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
dismiss(activityId: string): number {
|
|
403
|
+
return this.publish({ schema: 1, op: "dismiss", activityId, revision: this.nextRevision(activityId), title: "" });
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
private nextRevision(activityId: string): number {
|
|
407
|
+
const prior = [...this.events].reverse().find((e) => e.activity.activityId === activityId);
|
|
408
|
+
return (prior?.activity.revision ?? 0) + 1;
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
private remember(rec: ActivityRecord): void {
|
|
412
|
+
this.events.push(rec);
|
|
413
|
+
if (this.events.length > REPLAY_LIMIT) this.events = this.events.slice(-REPLAY_LIMIT);
|
|
414
|
+
this.save();
|
|
415
|
+
this.saveEvents();
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
private fanout(seq: number, activity: ActivityObject): void {
|
|
419
|
+
for (const [deviceId, ws] of this.sockets) {
|
|
420
|
+
if (ws.readyState !== WebSocket.OPEN) continue;
|
|
421
|
+
// capability gate: a device that cannot render progress/actions gets the plain shape
|
|
422
|
+
const device = this.deviceById(deviceId);
|
|
423
|
+
this.send(ws, { t: "activity", seq, activity: device ? this.degrade(activity, device) : activity });
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
/** Trim what a device cannot use. The server never branches on app version — only
|
|
428
|
+
* on the capabilities the device reported at pairing. */
|
|
429
|
+
private degrade(activity: ActivityObject, device: NotifyDevice): ActivityObject {
|
|
430
|
+
const caps = new Set(device.capabilities);
|
|
431
|
+
const out: ActivityObject = { ...activity };
|
|
432
|
+
if (!caps.has("progress")) out.progress = null;
|
|
433
|
+
if (!caps.has("actions")) out.actions = [];
|
|
434
|
+
if (!caps.has("remote_input")) out.input = null;
|
|
435
|
+
if (!caps.has("icon_url")) out.iconUrl = null;
|
|
436
|
+
if (!caps.has("channels")) out.channel = null;
|
|
437
|
+
if (!caps.has("deeplink")) {
|
|
438
|
+
// one capability covers both spellings: `deeplink` is the old alias of `open`
|
|
439
|
+
out.deeplink = null;
|
|
440
|
+
out.open = null;
|
|
441
|
+
}
|
|
442
|
+
if (!caps.has("live_update")) out.promotable = false;
|
|
443
|
+
return out;
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
replaySince(cursor: number): ActivityRecord[] {
|
|
447
|
+
return this.events.filter((e) => e.seq > cursor && e.activity.op !== "clear_all");
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
// ---- actions -------------------------------------------------------------
|
|
451
|
+
|
|
452
|
+
onAction(cb: (e: ActionEvent) => void): void {
|
|
453
|
+
this.onActionCb = cb;
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
/** Returns null when accepted, else the reason it was refused. */
|
|
457
|
+
private accept(cb: ActionCallback, device: NotifyDevice): string | null {
|
|
458
|
+
const rec = [...this.events].reverse().find((e) => e.activity.activityId === cb.activityId && e.activity.op !== "dismiss");
|
|
459
|
+
if (!rec) return "unknown activity";
|
|
460
|
+
const ref = rec.actionRefs[cb.actionId];
|
|
461
|
+
if (!ref && !(rec.activity.actions ?? []).some((a) => a.id === cb.actionId)) return "unknown action";
|
|
462
|
+
const key = `${cb.activityId}:${cb.actionId}`;
|
|
463
|
+
if (this.consumed.has(key)) return "already handled";
|
|
464
|
+
this.consumed.set(key, Date.now());
|
|
465
|
+
this.onActionCb?.({
|
|
466
|
+
action: { ...cb, deviceId: device.deviceId },
|
|
467
|
+
activity: rec.activity,
|
|
468
|
+
ref: ref ?? null,
|
|
469
|
+
device,
|
|
470
|
+
});
|
|
471
|
+
return null;
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
// ---- HTTP ----------------------------------------------------------------
|
|
475
|
+
|
|
476
|
+
/** Returns true when the request belonged to the notify centre. */
|
|
477
|
+
async handleHttp(req: IncomingMessage, res: ServerResponse, url: URL): Promise<boolean> {
|
|
478
|
+
const p = url.pathname;
|
|
479
|
+
if (!p.startsWith("/api/notify")) return false;
|
|
480
|
+
const json = (code: number, body: unknown): true => {
|
|
481
|
+
const wire = JSON.stringify(body);
|
|
482
|
+
res.writeHead(code, { "content-type": "application/json; charset=utf-8", "cache-control": "no-store" });
|
|
483
|
+
res.end(wire);
|
|
484
|
+
return true;
|
|
485
|
+
};
|
|
486
|
+
const readBody = async (): Promise<Record<string, unknown>> => {
|
|
487
|
+
const chunks: Buffer[] = [];
|
|
488
|
+
for await (const c of req) chunks.push(Buffer.from(c));
|
|
489
|
+
if (!chunks.length) return {};
|
|
490
|
+
try {
|
|
491
|
+
return JSON.parse(Buffer.concat(chunks).toString("utf8")) as Record<string, unknown>;
|
|
492
|
+
} catch {
|
|
493
|
+
return {};
|
|
494
|
+
}
|
|
495
|
+
};
|
|
496
|
+
const isOperator = (): boolean => (this.operator ? this.operator(req, url) : true);
|
|
497
|
+
|
|
498
|
+
if (p === "/api/notify/settings") {
|
|
499
|
+
if (!isOperator()) return json(401, { error: "operator auth required" }) && true;
|
|
500
|
+
const scheme = this.isTls(req) ? "https" : "http";
|
|
501
|
+
const base = `${scheme}://${String(req.headers.host ?? "localhost")}`;
|
|
502
|
+
if (req.method === "POST") {
|
|
503
|
+
const body = await readBody();
|
|
504
|
+
const rules = this.setRules((body.rules ?? {}) as Partial<NotifyRules>);
|
|
505
|
+
return json(200, { ok: true, rules }) && true;
|
|
506
|
+
}
|
|
507
|
+
const presence = this.presenceNow();
|
|
508
|
+
return json(200, {
|
|
509
|
+
rules: this.rules(),
|
|
510
|
+
presence,
|
|
511
|
+
watching: Boolean(presence.sessionId && this.watching(presence.sessionId)),
|
|
512
|
+
code: this.state.code,
|
|
513
|
+
pairUri: `agentus://pair?u=${encodeURIComponent(base)}&c=${this.state.code}`,
|
|
514
|
+
apkUrl: "/agentus-companion.apk",
|
|
515
|
+
apkBytes: this.apkPath && fs.existsSync(this.apkPath) ? fs.statSync(this.apkPath).size : null,
|
|
516
|
+
devices: this.state.devices.map((d) => ({
|
|
517
|
+
deviceId: d.deviceId, name: d.name, platform: d.platform, sdkInt: d.sdkInt,
|
|
518
|
+
appVersion: d.appVersion, capabilities: d.capabilities, createdAt: d.createdAt,
|
|
519
|
+
lastSeenAt: d.lastSeenAt, cursor: d.cursor, online: this.sockets.has(d.deviceId),
|
|
520
|
+
})),
|
|
521
|
+
}) && true;
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
if (p === "/api/notify/presence" && req.method === "POST") {
|
|
525
|
+
// The cockpit telling us what it is showing. Operator-authenticated (the SPA has the
|
|
526
|
+
// cookie); a missing report just means presence goes stale and quiet-when-watching stops
|
|
527
|
+
// applying, which is the safe direction.
|
|
528
|
+
if (!isOperator()) return json(401, { error: "operator auth required" }) && true;
|
|
529
|
+
const body = await readBody();
|
|
530
|
+
const presence = this.setPresence(
|
|
531
|
+
body.sessionId == null ? null : String(body.sessionId),
|
|
532
|
+
body.visible !== false,
|
|
533
|
+
body.call === true,
|
|
534
|
+
);
|
|
535
|
+
return json(200, {
|
|
536
|
+
ok: true, presence,
|
|
537
|
+
watching: Boolean(presence.sessionId && this.watching(presence.sessionId)),
|
|
538
|
+
}) && true;
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
if (p === "/api/notify/push" && req.method === "POST") {
|
|
542
|
+
if (!isOperator()) return json(401, { error: "operator auth required" }) && true;
|
|
543
|
+
const body = await readBody();
|
|
544
|
+
// A third-party sender may ask for a plain web link; `open.prefer` is the contract's way to say
|
|
545
|
+
// that (docs/android-notify-contract.md §5.2). Anything else keeps the cockpit default.
|
|
546
|
+
const prefer = (body.open as { prefer?: unknown } | undefined)?.prefer;
|
|
547
|
+
const seq = this.pushNow({
|
|
548
|
+
sessionId: body.sessionId ? String(body.sessionId) : null,
|
|
549
|
+
title: String(body.title ?? "来自驾驶舱的推送"),
|
|
550
|
+
body: body.body ? String(body.body) : undefined,
|
|
551
|
+
kind: body.kind === "approval" ? "approval" : body.kind === "island" ? "island" : "note",
|
|
552
|
+
path: body.path === "aosp" || body.path === "xiaomi" ? String(body.path) : undefined,
|
|
553
|
+
openPrefer: prefer === "web" || prefer === "auto" || prefer === "app" ? prefer : undefined,
|
|
554
|
+
});
|
|
555
|
+
return json(200, { ok: true, seq }) && true;
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
if (p === "/api/notify/health") {
|
|
559
|
+
return json(200, { ok: true, code: isOperator() ? this.state.code : null, devices: this.state.devices.length, seq: this.state.seq }) && true;
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
if (p === "/api/notify/pair" && req.method === "POST") {
|
|
563
|
+
const body = await readBody();
|
|
564
|
+
// Two ways in: the rotating short code (what a human pastes into the app), or the
|
|
565
|
+
// operator's own credential (scripts/CI — whoever can already drive the server).
|
|
566
|
+
const machineTokenOk = isOperator();
|
|
567
|
+
const out = this.pair(body, { ip: this.ip(req), machineTokenOk });
|
|
568
|
+
if ("error" in out) return json(403, out) && true;
|
|
569
|
+
const scheme = this.isTls(req) ? "https" : "http";
|
|
570
|
+
const host = String(req.headers.host ?? "localhost");
|
|
571
|
+
const base = `${scheme}://${host}`;
|
|
572
|
+
return json(200, {
|
|
573
|
+
deviceId: out.device.deviceId,
|
|
574
|
+
deviceToken: out.device.token,
|
|
575
|
+
baseUrl: base,
|
|
576
|
+
wsUrl: `${scheme === "https" ? "wss" : "ws"}://${host}/api/notify/ws`,
|
|
577
|
+
serverTime: Date.now(),
|
|
578
|
+
}) && true;
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
if (p === "/api/notify/pair-code" && req.method === "GET") {
|
|
582
|
+
if (!isOperator()) return json(401, { error: "operator auth required" }) && true;
|
|
583
|
+
const scheme = this.isTls(req) ? "https" : "http";
|
|
584
|
+
const host = String(req.headers.host ?? "localhost");
|
|
585
|
+
const base = `${scheme}://${host}`;
|
|
586
|
+
return json(200, {
|
|
587
|
+
code: this.state.code,
|
|
588
|
+
pairUri: `agentus://pair?u=${encodeURIComponent(base)}&c=${this.state.code}`,
|
|
589
|
+
baseUrl: base,
|
|
590
|
+
wsUrl: `${scheme === "https" ? "wss" : "ws"}://${host}/api/notify/ws`,
|
|
591
|
+
}) && true;
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
if (p === "/api/notify/pair-code/rotate" && req.method === "POST") {
|
|
595
|
+
if (!isOperator()) return json(401, { error: "operator auth required" }) && true;
|
|
596
|
+
return json(200, { code: this.rotateCode() }) && true;
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
if (p === "/api/notify/devices" && req.method === "GET") {
|
|
600
|
+
if (!isOperator()) return json(401, { error: "operator auth required" }) && true;
|
|
601
|
+
return json(200, {
|
|
602
|
+
devices: this.state.devices.map((d) => ({
|
|
603
|
+
deviceId: d.deviceId, name: d.name, platform: d.platform, sdkInt: d.sdkInt,
|
|
604
|
+
appVersion: d.appVersion, capabilities: d.capabilities, createdAt: d.createdAt,
|
|
605
|
+
lastSeenAt: d.lastSeenAt, cursor: d.cursor, online: this.sockets.has(d.deviceId),
|
|
606
|
+
diag: d.diag ?? null,
|
|
607
|
+
})),
|
|
608
|
+
}) && true;
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
if (p === "/api/notify/devices/revoke" && req.method === "POST") {
|
|
612
|
+
if (!isOperator()) return json(401, { error: "operator auth required" }) && true;
|
|
613
|
+
const body = await readBody();
|
|
614
|
+
return json(200, { ok: this.revoke(String(body.deviceId ?? "")) }) && true;
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
if (p === "/api/notify/activities" && req.method === "POST") {
|
|
618
|
+
if (!isOperator()) return json(401, { error: "operator auth required" }) && true;
|
|
619
|
+
const body = await readBody();
|
|
620
|
+
const input = body.activity as Partial<ActivityObject> | undefined;
|
|
621
|
+
// A dismissal legitimately carries no title; an upsert must have one, or nothing renders.
|
|
622
|
+
const needsTitle = input?.op !== "dismiss";
|
|
623
|
+
if (!input?.activityId || (needsTitle && !input.title)) {
|
|
624
|
+
return json(400, { error: "activityId required (and title, unless op=dismiss)" }) && true;
|
|
625
|
+
}
|
|
626
|
+
const activity = { ...input, schema: 1 as const, op: input.op ?? "upsert", revision: input.revision ?? 1 } as ActivityObject;
|
|
627
|
+
const seq = this.publish(activity);
|
|
628
|
+
return json(200, { ok: true, seq }) && true;
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
if (p === "/api/notify/probe" && req.method === "POST") {
|
|
632
|
+
// Operator OR a paired device: the probe sends a canned sequence and is how the
|
|
633
|
+
// phone's own "test notification" button works without shipping the operator token.
|
|
634
|
+
const asDevice = this.deviceByToken(this.tokenOf(req, url)) !== null;
|
|
635
|
+
if (!isOperator() && !asDevice) return json(401, { error: "operator or device auth required" }) && true;
|
|
636
|
+
return json(200, { ok: true, queued: this.probe() }) && true;
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
if (p === "/api/notify/events" && req.method === "GET") {
|
|
640
|
+
if (!isOperator()) return json(401, { error: "operator auth required" }) && true;
|
|
641
|
+
const since = Number(url.searchParams.get("since") ?? 0);
|
|
642
|
+
return json(200, { seq: this.state.seq, events: this.replaySince(since) }) && true;
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
if (p === "/api/notify/diag" && req.method === "POST") {
|
|
646
|
+
// The device reporting on itself. Device-authenticated, and deliberately dumb: it stores the
|
|
647
|
+
// text and the version, nothing else. "上岛没反应" or "语音报错" then has a cause in the state
|
|
648
|
+
// file instead of a guess over chat.
|
|
649
|
+
const device = this.deviceByToken(this.tokenOf(req, url));
|
|
650
|
+
if (!device) return json(401, { error: "device token required" }) && true;
|
|
651
|
+
const body = await readBody();
|
|
652
|
+
const text = String(body.text ?? "").trim().slice(0, 4000);
|
|
653
|
+
if (!text) return json(400, { error: "text required" }) && true;
|
|
654
|
+
device.diag = { at: Date.now(), text };
|
|
655
|
+
if (body.appVersion) device.appVersion = String(body.appVersion).slice(0, 24);
|
|
656
|
+
device.lastSeenAt = Date.now();
|
|
657
|
+
this.save();
|
|
658
|
+
this.log(`[notify] diag from ${device.name} v${device.appVersion}: ${text.split("\n").find((l) => l.trim()) ?? ""}`);
|
|
659
|
+
return json(200, { ok: true }) && true;
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
if (p === "/api/notify/actions" && req.method === "POST") {
|
|
663
|
+
const device = this.deviceByToken(this.tokenOf(req, url));
|
|
664
|
+
if (!device) return json(401, { error: "device token required" }) && true;
|
|
665
|
+
const body = await readBody() as unknown as ActionCallback;
|
|
666
|
+
if (!body?.activityId || !body?.actionId) return json(400, { error: "activityId and actionId required" }) && true;
|
|
667
|
+
const refused = this.accept({ ...body, deviceId: device.deviceId, ts: body.ts ?? Date.now() }, device);
|
|
668
|
+
if (refused) return json(409, { ok: false, error: refused }) && true;
|
|
669
|
+
return json(200, { ok: true, note: "received" }) && true;
|
|
670
|
+
}
|
|
671
|
+
|
|
672
|
+
return false;
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
private ip(req: IncomingMessage): string {
|
|
676
|
+
const fwd = String(req.headers["x-forwarded-for"] ?? "").split(",")[0].trim();
|
|
677
|
+
return fwd || req.socket.remoteAddress || "unknown";
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
private isTls(req: IncomingMessage): boolean {
|
|
681
|
+
return Boolean((req.socket as { encrypted?: boolean }).encrypted) ||
|
|
682
|
+
String(req.headers["x-forwarded-proto"] ?? "").split(",")[0].trim() === "https";
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
private tokenOf(req: IncomingMessage, url: URL): string | null {
|
|
686
|
+
const bearer = req.headers.authorization?.startsWith("Bearer ") ? req.headers.authorization.slice(7) : null;
|
|
687
|
+
return bearer ?? url.searchParams.get("token");
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
// ---- WebSocket -----------------------------------------------------------
|
|
691
|
+
|
|
692
|
+
handleUpgrade(req: IncomingMessage, socket: Duplex, head: Buffer): boolean {
|
|
693
|
+
const url = new URL(req.url ?? "/", "http://x");
|
|
694
|
+
if (url.pathname !== "/api/notify/ws") return false;
|
|
695
|
+
const device = this.deviceByToken(this.tokenOf(req, url));
|
|
696
|
+
if (!device) {
|
|
697
|
+
socket.write("HTTP/1.1 401 Unauthorized\r\nconnection: close\r\ncontent-length: 0\r\n\r\n");
|
|
698
|
+
socket.destroy();
|
|
699
|
+
return true;
|
|
700
|
+
}
|
|
701
|
+
// The running build reports itself on every connect: the version stored at pairing time goes
|
|
702
|
+
// stale the moment the operator sideloads a new APK, and "which build is on the phone" is
|
|
703
|
+
// otherwise unanswerable from here.
|
|
704
|
+
const v = url.searchParams.get("v");
|
|
705
|
+
if (v) {
|
|
706
|
+
const clean = v.slice(0, 24).replace(/[^\w.+-]/g, "");
|
|
707
|
+
if (clean && clean !== device.appVersion) {
|
|
708
|
+
device.appVersion = clean;
|
|
709
|
+
this.save();
|
|
710
|
+
}
|
|
711
|
+
}
|
|
712
|
+
this.wss ??= new WebSocketServer({ noServer: true });
|
|
713
|
+
this.wss.handleUpgrade(req, socket, head, (ws) => this.attach(ws, device, Number(url.searchParams.get("since") ?? -1)));
|
|
714
|
+
return true;
|
|
715
|
+
}
|
|
716
|
+
|
|
717
|
+
private attach(ws: WebSocket, device: NotifyDevice, since: number): void {
|
|
718
|
+
const prior = this.sockets.get(device.deviceId);
|
|
719
|
+
if (prior && prior !== ws) this.finish(prior, "replaced");
|
|
720
|
+
this.sockets.set(device.deviceId, ws);
|
|
721
|
+
const row = this.deviceById(device.deviceId) ?? device;
|
|
722
|
+
row.lastSeenAt = Date.now();
|
|
723
|
+
this.save();
|
|
724
|
+
|
|
725
|
+
// The device's own cursor wins over the server's memory of it: a device that reinstalled
|
|
726
|
+
// passes since=0 and must get everything, one that already saw seq N passes N.
|
|
727
|
+
const cursor = since >= 0 ? Math.max(since, row.cursor) : row.cursor;
|
|
728
|
+
this.send(ws, { t: "hello", deviceId: row.deviceId, serverTime: Date.now(), cursor, live: since < 0 });
|
|
729
|
+
for (const rec of this.replaySince(cursor)) {
|
|
730
|
+
this.send(ws, { t: "activity", seq: rec.seq, activity: this.degrade(rec.activity, row) });
|
|
731
|
+
}
|
|
732
|
+
this.log(`[notify] ${row.name} connected (cursor ${cursor} -> replay ${this.replaySince(cursor).length})`);
|
|
733
|
+
|
|
734
|
+
ws.on("message", (data: Buffer) => {
|
|
735
|
+
let msg: Record<string, unknown>;
|
|
736
|
+
try {
|
|
737
|
+
msg = JSON.parse(String(data)) as Record<string, unknown>;
|
|
738
|
+
} catch {
|
|
739
|
+
return;
|
|
740
|
+
}
|
|
741
|
+
if (msg.t === "ack") {
|
|
742
|
+
const n = Number(msg.cursor);
|
|
743
|
+
if (Number.isFinite(n)) {
|
|
744
|
+
row.cursor = Math.max(row.cursor, n);
|
|
745
|
+
this.save();
|
|
746
|
+
}
|
|
747
|
+
return;
|
|
748
|
+
}
|
|
749
|
+
if (msg.t === "action") {
|
|
750
|
+
const refused = this.accept({ ...(msg as unknown as ActionCallback), deviceId: row.deviceId, ts: Date.now() }, row);
|
|
751
|
+
this.send(ws, { t: "bye", reason: refused ? `refused: ${refused}` : "action-ok" });
|
|
752
|
+
if (!refused) row.cursor = Math.max(row.cursor, this.state.seq);
|
|
753
|
+
this.save();
|
|
754
|
+
}
|
|
755
|
+
});
|
|
756
|
+
ws.on("close", () => {
|
|
757
|
+
if (this.sockets.get(row.deviceId) === ws) this.sockets.delete(row.deviceId);
|
|
758
|
+
});
|
|
759
|
+
ws.on("error", () => { /* close follows */ });
|
|
760
|
+
}
|
|
761
|
+
|
|
762
|
+
private send(ws: WebSocket, frame: NotifyFrame): void {
|
|
763
|
+
if (ws.readyState === WebSocket.OPEN) ws.send(JSON.stringify(frame));
|
|
764
|
+
}
|
|
765
|
+
|
|
766
|
+
private finish(ws: WebSocket, reason: string): void {
|
|
767
|
+
try {
|
|
768
|
+
this.send(ws, { t: "bye", reason });
|
|
769
|
+
ws.close();
|
|
770
|
+
} catch { /* already gone */ }
|
|
771
|
+
}
|
|
772
|
+
|
|
773
|
+
// ---- test scaffolding ----------------------------------------------------
|
|
774
|
+
|
|
775
|
+
/**
|
|
776
|
+
* The cockpit's "推到手机" button: one activity, right now, through the same publish() as
|
|
777
|
+
* everything else. Deliberately NOT filtered by the rules — pressing the button IS the
|
|
778
|
+
* intent, and a button that silently does nothing because a rule said so would be a lie.
|
|
779
|
+
*/
|
|
780
|
+
pushNow(opts: {
|
|
781
|
+
sessionId: string | null;
|
|
782
|
+
title: string;
|
|
783
|
+
body?: string;
|
|
784
|
+
kind?: "note" | "approval" | "island";
|
|
785
|
+
/** Promotion path for the device: "" = the app's own order (vendor first), "aosp" = Android 16's
|
|
786
|
+
* own Live Update only, "xiaomi" = the vendor island only. */
|
|
787
|
+
path?: string;
|
|
788
|
+
/** The sender's strength of preference for opening in an app (a third-party push can ask for a
|
|
789
|
+
* plain web link with "web"). Defaults to "app": the cockpit is where this is meant to land. */
|
|
790
|
+
openPrefer?: OpenTarget["prefer"];
|
|
791
|
+
}): number {
|
|
792
|
+
const approval = opts.kind === "approval";
|
|
793
|
+
// "island": an ongoing promotable card, i.e. the shape that CAN be promoted at all — the one the
|
|
794
|
+
// operator's island question is about. A finished notification is never an island on either path.
|
|
795
|
+
const island = opts.kind === "island";
|
|
796
|
+
/** The session name (or 「驾驶舱」 for a session-less push). It goes FIRST IN THE BODY so the
|
|
797
|
+
* collapsed row always says which conversation this is — see `nameAndDetail`. */
|
|
798
|
+
const name = opts.sessionId
|
|
799
|
+
? (this.sessionTitle?.(opts.sessionId) ?? opts.sessionId.slice(0, 8))
|
|
800
|
+
: "驾驶舱";
|
|
801
|
+
const deeplink = opts.sessionId ? `/?session=${encodeURIComponent(opts.sessionId)}` : "/";
|
|
802
|
+
const activity: ActivityObject = {
|
|
803
|
+
schema: 1, op: "upsert", activityId: `push:${Date.now()}`, revision: 1,
|
|
804
|
+
kind: approval ? "approval" : island ? "agent_running" : "agent_done",
|
|
805
|
+
priority: island ? "low" : "high",
|
|
806
|
+
ongoing: island, promotable: island,
|
|
807
|
+
title: opts.title.slice(0, 80),
|
|
808
|
+
body: nameAndDetail(name, opts.body),
|
|
809
|
+
channel: island
|
|
810
|
+
? { id: "agent_running", name: "任务运行中", importance: "low", sound: false }
|
|
811
|
+
: {
|
|
812
|
+
id: approval ? "agent_approval" : "agent_done",
|
|
813
|
+
name: approval ? "权限请求" : "任务完成",
|
|
814
|
+
importance: "high", sound: true, vibration: true,
|
|
815
|
+
},
|
|
816
|
+
...(island
|
|
817
|
+
? { progress: { value: 0.4, segments: [{ length: 4, color: "#2f6f4f" }, { length: 6, color: "#e2e2dd" }] } }
|
|
818
|
+
: {}),
|
|
819
|
+
actions: [{ id: "open", label: "打开", style: "primary" }],
|
|
820
|
+
smallIcon: "agentus", ...tapTarget(deeplink, opts.openPrefer),
|
|
821
|
+
visibility: island ? "public" : "private",
|
|
822
|
+
};
|
|
823
|
+
if (opts.path) activity.path = opts.path;
|
|
824
|
+
return this.publish(activity, {
|
|
825
|
+
actionRefs: { open: opts.sessionId ? { type: "open", sessionId: opts.sessionId } : { type: "probe" } },
|
|
826
|
+
});
|
|
827
|
+
}
|
|
828
|
+
|
|
829
|
+
/** A canned sequence that exercises every field the contract can carry:
|
|
830
|
+
* running (promoted) -> progress update -> approval with buttons + reply -> done. */
|
|
831
|
+
probe(): { steps: number } {
|
|
832
|
+
const link = "/";
|
|
833
|
+
const steps: ActivityObject[] = [
|
|
834
|
+
{
|
|
835
|
+
schema: 1, op: "upsert", activityId: "probe:running", revision: 1, kind: "agent_running",
|
|
836
|
+
priority: "low", ongoing: true, promotable: true,
|
|
837
|
+
title: "Agentus · 运行中", body: "探针会话 · 第 1 步:读取代码",
|
|
838
|
+
progress: { indeterminate: true },
|
|
839
|
+
channel: { id: "agent_running", name: "任务运行中", importance: "low", sound: false },
|
|
840
|
+
smallIcon: "agentus", ...tapTarget(link), visibility: "public",
|
|
841
|
+
},
|
|
842
|
+
{
|
|
843
|
+
schema: 1, op: "upsert", activityId: "probe:running", revision: 2, kind: "agent_running",
|
|
844
|
+
priority: "low", ongoing: true, promotable: true,
|
|
845
|
+
title: "Agentus · 运行中", body: "探针会话 · 第 2 步:运行测试(进度 60%)",
|
|
846
|
+
progress: { value: 0.6, segments: [{ length: 6, color: "#4f8" }, { length: 4, color: "#556" }] },
|
|
847
|
+
channel: { id: "agent_running", name: "任务运行中", importance: "low", sound: false },
|
|
848
|
+
smallIcon: "agentus", ...tapTarget(link), visibility: "public",
|
|
849
|
+
},
|
|
850
|
+
{
|
|
851
|
+
schema: 1, op: "upsert", activityId: "probe:approval", revision: 1, kind: "approval",
|
|
852
|
+
priority: "high", ongoing: true, promotable: true,
|
|
853
|
+
title: "待你确认:写文件", body: "探针会话 · edit /etc/hosts — 允许这次操作?",
|
|
854
|
+
channel: { id: "agent_approval", name: "权限请求", importance: "high", sound: true, vibration: true },
|
|
855
|
+
actions: [
|
|
856
|
+
{ id: "allow_once", label: "仅此次", style: "primary" },
|
|
857
|
+
{ id: "allow_always", label: "总是允许", style: "default" },
|
|
858
|
+
{ id: "reject_once", label: "拒绝", style: "danger" },
|
|
859
|
+
],
|
|
860
|
+
input: { enabled: true, placeholder: "或直接回一句" },
|
|
861
|
+
smallIcon: "agentus", ...tapTarget(link), visibility: "private",
|
|
862
|
+
},
|
|
863
|
+
// A canned frame cannot know how long a real turn took, so this one carries no 「用时」: the
|
|
864
|
+
// duration is measured in the real `turn-end` path (see durationText).
|
|
865
|
+
{
|
|
866
|
+
schema: 1, op: "upsert", activityId: "probe:done", revision: 1, kind: "agent_done",
|
|
867
|
+
priority: "high", ongoing: false, promotable: false,
|
|
868
|
+
title: "已完成", body: "探针会话 · 探针序列结束",
|
|
869
|
+
channel: { id: "agent_done", name: "任务完成", importance: "high", sound: true },
|
|
870
|
+
actions: [{ id: "open", label: "查看", style: "primary" }],
|
|
871
|
+
smallIcon: "agentus", ...tapTarget(link), visibility: "private",
|
|
872
|
+
},
|
|
873
|
+
];
|
|
874
|
+
const [running, progress, approval, done] = steps;
|
|
875
|
+
const actionRefs: Record<string, NotifyRef> = {
|
|
876
|
+
allow_once: { type: "probe" }, allow_always: { type: "probe" }, reject_once: { type: "probe" }, open: { type: "probe" },
|
|
877
|
+
};
|
|
878
|
+
this.publish(running);
|
|
879
|
+
this.timers.push(setTimeout(() => this.publish(progress), 3000));
|
|
880
|
+
this.timers.push(setTimeout(() => {
|
|
881
|
+
this.dismiss("probe:running");
|
|
882
|
+
this.publish(approval, { actionRefs });
|
|
883
|
+
this.publish(done, { actionRefs: { open: { type: "probe" } } });
|
|
884
|
+
}, 6000));
|
|
885
|
+
return { steps: steps.length };
|
|
886
|
+
}
|
|
887
|
+
|
|
888
|
+
close(): void {
|
|
889
|
+
for (const t of this.timers) clearTimeout(t);
|
|
890
|
+
this.timers = [];
|
|
891
|
+
for (const ws of this.sockets.values()) this.finish(ws, "server shutting down");
|
|
892
|
+
this.sockets.clear();
|
|
893
|
+
}
|
|
894
|
+
|
|
895
|
+
// ---- ACP event -> activity ----------------------------------------------
|
|
896
|
+
// The only place that knows what an Agentus event MEANS. Everything it emits
|
|
897
|
+
// goes back through publish(), so the contract stays the single wire format.
|
|
898
|
+
|
|
899
|
+
/** Called for every ServerEvent the session manager emits. */
|
|
900
|
+
observe(evt: ServerEvent): void {
|
|
901
|
+
const title = (sessionId: string): string => this.sessionTitle?.(sessionId) ?? sessionId.slice(0, 8);
|
|
902
|
+
const link = (sessionId: string): string => `/?session=${encodeURIComponent(sessionId)}`;
|
|
903
|
+
switch (evt.t) {
|
|
904
|
+
case "turn-start":
|
|
905
|
+
// The rules gate BEFORE anything else: an event that is switched off must not even be
|
|
906
|
+
// evaluated against presence (and a suppressed event must not be mistaken for a bug).
|
|
907
|
+
if (!this.rules().turnStart || this.watching(evt.sessionId)) return;
|
|
908
|
+
this.publish({
|
|
909
|
+
schema: 1, op: "upsert", activityId: `turn:${evt.sessionId}`, revision: this.nextRevision(`turn:${evt.sessionId}`),
|
|
910
|
+
kind: "agent_running", priority: "low", ongoing: true, promotable: true,
|
|
911
|
+
title: "Agentus · 运行中",
|
|
912
|
+
body: nameAndDetail(title(evt.sessionId), evt.trace?.model, evt.trace?.effort),
|
|
913
|
+
progress: { indeterminate: true },
|
|
914
|
+
channel: { id: "agent_running", name: "任务运行中", importance: "low", sound: false },
|
|
915
|
+
smallIcon: "agentus", ...tapTarget(link(evt.sessionId)), visibility: "public",
|
|
916
|
+
});
|
|
917
|
+
return;
|
|
918
|
+
case "permission":
|
|
919
|
+
// Never suppressed by presence: this is a decision waiting on a human, and the whole
|
|
920
|
+
// point of the phone being in the loop is answering it while away from the desk.
|
|
921
|
+
if (!this.rules().approval) return;
|
|
922
|
+
this.publishApproval(evt.request, title(evt.request.sessionId), link(evt.request.sessionId));
|
|
923
|
+
return;
|
|
924
|
+
case "permission-resolved":
|
|
925
|
+
// Always clear the card, even if the request itself was never published — a stale
|
|
926
|
+
// "waiting for you" on the phone is worse than no notification at all.
|
|
927
|
+
this.dismiss(`perm:${evt.requestId}`);
|
|
928
|
+
return;
|
|
929
|
+
case "turn-end": {
|
|
930
|
+
// The running card goes away unconditionally — a zombie "still running" on the phone is worse
|
|
931
|
+
// than no card at all, even when the frame that started it was suppressed.
|
|
932
|
+
this.dismiss(`turn:${evt.sessionId}`);
|
|
933
|
+
// Both gates belong here and both were missing: `completion` was stored but never read, and
|
|
934
|
+
// presence was only consulted for turn-start — so the completion card fired while the operator was watching
|
|
935
|
+
// the very session (and, mid-call, with a sound: the reported bug).
|
|
936
|
+
if (!this.rules().completion || this.watching(evt.sessionId)) return;
|
|
937
|
+
const failed = Boolean(evt.error);
|
|
938
|
+
this.publish({
|
|
939
|
+
schema: 1, op: "upsert", activityId: `done:${evt.sessionId}:${Date.now()}`, revision: 1,
|
|
940
|
+
kind: failed ? "error" : "agent_done", priority: "high", ongoing: false, promotable: false,
|
|
941
|
+
title: failed ? "执行失败" : "已完成",
|
|
942
|
+
body: nameAndDetail(title(evt.sessionId),
|
|
943
|
+
failed ? String(evt.error).slice(0, 140) : durationText(evt.durationMs)),
|
|
944
|
+
channel: {
|
|
945
|
+
id: failed ? "agent_error" : "agent_done",
|
|
946
|
+
name: failed ? "任务出错" : "任务完成",
|
|
947
|
+
importance: "high", sound: true,
|
|
948
|
+
},
|
|
949
|
+
actions: [{ id: "open", label: "查看", style: "primary" }],
|
|
950
|
+
smallIcon: "agentus", ...tapTarget(link(evt.sessionId)), visibility: "private",
|
|
951
|
+
}, { actionRefs: { open: { type: "open", sessionId: evt.sessionId } } });
|
|
952
|
+
return;
|
|
953
|
+
}
|
|
954
|
+
case "error":
|
|
955
|
+
this.publish({
|
|
956
|
+
schema: 1, op: "upsert", activityId: `error:${Date.now()}`, revision: 1, kind: "error",
|
|
957
|
+
priority: "high", ongoing: false, promotable: false, title: "Agentus 报错",
|
|
958
|
+
body: String(evt.error).slice(0, 160),
|
|
959
|
+
channel: { id: "agent_error", name: "任务出错", importance: "high", sound: true },
|
|
960
|
+
smallIcon: "agentus", visibility: "private",
|
|
961
|
+
});
|
|
962
|
+
return;
|
|
963
|
+
default:
|
|
964
|
+
return;
|
|
965
|
+
}
|
|
966
|
+
}
|
|
967
|
+
|
|
968
|
+
private publishApproval(request: PermissionRequestView, sessionTitle: string, link: string): void {
|
|
969
|
+
const actions = request.options.slice(0, 3).map((o) => ({
|
|
970
|
+
id: o.optionId,
|
|
971
|
+
label: o.name.slice(0, 24),
|
|
972
|
+
style: (/^(allow|accept|approve|yes)/i.test(o.kind) ? "primary" : /reject|deny|no/i.test(o.kind) ? "danger" : "default") as
|
|
973
|
+
"primary" | "danger" | "default",
|
|
974
|
+
}));
|
|
975
|
+
const actionRefs: Record<string, NotifyRef> = {};
|
|
976
|
+
for (const o of request.options) {
|
|
977
|
+
actionRefs[o.optionId] = {
|
|
978
|
+
type: "permission", sessionId: request.sessionId, requestId: request.requestId,
|
|
979
|
+
optionId: o.optionId, optionKind: o.kind, signature: `${o.kind}:${request.toolCallTitle}`,
|
|
980
|
+
};
|
|
981
|
+
}
|
|
982
|
+
const tool = splitToolTitle(request.toolCallTitle);
|
|
983
|
+
this.publish({
|
|
984
|
+
schema: 1, op: "upsert", activityId: `perm:${request.requestId}`, revision: 1, kind: "approval",
|
|
985
|
+
priority: "high", ongoing: true, promotable: true,
|
|
986
|
+
title: tool.head ? `待你确认:${tool.head.slice(0, 60)}` : "待你确认",
|
|
987
|
+
body: nameAndDetail(sessionTitle, tool.target || request.kind),
|
|
988
|
+
channel: { id: "agent_approval", name: "权限请求", importance: "high", sound: true, vibration: true },
|
|
989
|
+
actions: actions.length ? actions : [{ id: "open", label: "查看", style: "primary" }],
|
|
990
|
+
input: { enabled: true, placeholder: "或直接回一句" },
|
|
991
|
+
smallIcon: "agentus", ...tapTarget(link), visibility: "private",
|
|
992
|
+
}, { actionRefs });
|
|
993
|
+
}
|
|
994
|
+
}
|