@lelouchhe/webagent 0.3.0 → 0.5.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (61) hide show
  1. package/README.md +59 -23
  2. package/bin/webagent.mjs +119 -8
  3. package/config.toml +105 -3
  4. package/dist/fonts/temml/Temml.woff2 +0 -0
  5. package/dist/index.html +64 -41
  6. package/dist/js/app.3OEVQXHK.js +4 -0
  7. package/dist/js/chunk.AJZBJBMO.js +1 -0
  8. package/dist/js/chunk.D4ZYHJAM.js +1 -0
  9. package/dist/js/chunk.IJM5DBCO.js +173 -0
  10. package/dist/js/chunk.VZXGXFNN.js +5 -0
  11. package/dist/js/login.PYIK52HN.js +1 -0
  12. package/dist/js/viewer.FCSSTUVY.js +1 -0
  13. package/dist/login.html +49 -0
  14. package/dist/share-viewer.00gubshk.css +114 -0
  15. package/dist/share-viewer.html +54 -0
  16. package/dist/styles.00xfh3e6.css +1848 -0
  17. package/dist/sw.js +79 -27
  18. package/dist/theme-init.js +6 -0
  19. package/lib/agent-detect.js +110 -0
  20. package/lib/atomic-write.js +50 -0
  21. package/lib/attachment-dispatch.js +86 -0
  22. package/lib/attachment-interceptor.js +130 -0
  23. package/lib/attachment-labels.js +139 -0
  24. package/lib/attachments.js +154 -0
  25. package/lib/auth-middleware.js +105 -0
  26. package/lib/auth-store.js +269 -0
  27. package/lib/auth.js +89 -0
  28. package/lib/bootstrap.js +70 -0
  29. package/lib/bridge-event-config.js +29 -0
  30. package/lib/bridge.js +244 -93
  31. package/lib/client-registry.js +149 -0
  32. package/lib/config.js +130 -9
  33. package/lib/daemon.js +175 -41
  34. package/lib/event-handler.js +209 -91
  35. package/lib/image-dimensions.js +64 -0
  36. package/lib/log-fmt.js +67 -0
  37. package/lib/log.js +83 -0
  38. package/lib/message-cleanup.js +48 -0
  39. package/lib/mode-bucket.js +62 -0
  40. package/lib/model-picker.js +17 -0
  41. package/lib/preflight.js +214 -0
  42. package/lib/push-service.js +297 -52
  43. package/lib/routes.js +1315 -145
  44. package/lib/server.js +149 -37
  45. package/lib/session-manager.js +164 -18
  46. package/lib/session-state.js +160 -0
  47. package/lib/sessions-anchor.js +28 -0
  48. package/lib/share/cleanup.js +45 -0
  49. package/lib/share/routes.js +972 -0
  50. package/lib/share/sanitize.js +179 -0
  51. package/lib/sse-manager.js +94 -8
  52. package/lib/sse-ticket.js +45 -0
  53. package/lib/startup-checks.js +95 -0
  54. package/lib/store.js +636 -30
  55. package/lib/title-service.js +26 -9
  56. package/lib/tokens.js +50 -0
  57. package/lib/types.js +23 -0
  58. package/package.json +39 -4
  59. package/dist/js/app.2562YGRO.js +0 -10
  60. package/dist/styles.008ve1hx.css +0 -669
  61. package/lib/shared/constants.js +0 -17
@@ -0,0 +1,214 @@
1
+ // Startup preflight checks. Each check prints a uniform `[check] <name> ✓`
2
+ // line on success, or `✗` with an actionable hint and exits 78 (sysexits
3
+ // EX_CONFIG) on failure. Runs synchronously before any heavy init so
4
+ // failures land on the operator's terminal first thing, not buried under
5
+ // noise.
6
+ //
7
+ // Scope is intentionally narrow: things we can answer before binding the
8
+ // port. Network reachability, agent login state, etc. are runtime
9
+ // concerns and surface as warnings via the bridge / UI later.
10
+ import { accessSync, constants, mkdirSync, statSync } from "node:fs";
11
+ import { resolve } from "node:path";
12
+ import { spawnSync } from "node:child_process";
13
+ import { createServer } from "node:net";
14
+ import { detectAgent, formatDetectionFailure } from "./agent-detect.js";
15
+ const PAD = 36;
16
+ function pad(s) {
17
+ return s.length >= PAD ? s + " " : s + " ".repeat(PAD - s.length);
18
+ }
19
+ function printOk(c) {
20
+ console.log(`[check] ${pad(`${c.name}: ${c.detail}`)} ✓`);
21
+ }
22
+ function printFail(c) {
23
+ console.error(`[check] ${pad(`${c.name}: ${c.detail}`)} ✗`);
24
+ for (const line of c.hint.split("\n"))
25
+ console.error(` ${line}`);
26
+ }
27
+ function checkNodeVersion() {
28
+ const v = process.versions.node;
29
+ const [maj, min] = v.split(".").map((n) => parseInt(n, 10));
30
+ // package.json declares engines.node >= 22.6.0; mirror that here so the
31
+ // diagnostic is friendly rather than a stack trace from a missing API.
32
+ const ok = maj > 22 || (maj === 22 && min >= 6);
33
+ if (ok)
34
+ return { ok: true, name: "node", detail: `v${v}` };
35
+ return {
36
+ ok: false,
37
+ name: "node",
38
+ detail: `v${v}`,
39
+ hint: "webagent requires Node.js v22.6.0 or newer (for --experimental-strip-types).\nInstall via: nvm install 22 && nvm use 22",
40
+ };
41
+ }
42
+ function checkDataDir(dir) {
43
+ const abs = resolve(dir);
44
+ // Create the directory if it doesn't exist (Store does this lazily but
45
+ // we want the failure surface here, before SQLite tries to open).
46
+ try {
47
+ if (!safeStat(abs))
48
+ mkdirSync(abs, { recursive: true });
49
+ accessSync(abs, constants.W_OK);
50
+ return { ok: true, name: "data_dir", detail: abs };
51
+ }
52
+ catch (err) {
53
+ const code = err.code ?? "unknown";
54
+ return {
55
+ ok: false,
56
+ name: "data_dir",
57
+ detail: abs,
58
+ hint: `cannot create or write to ${abs}: ${code}\nfix permissions or set data_dir in config.toml to a writable path.`,
59
+ };
60
+ }
61
+ function safeStat(p) {
62
+ try {
63
+ return statSync(p);
64
+ }
65
+ catch {
66
+ return null;
67
+ }
68
+ }
69
+ }
70
+ /**
71
+ * Resolve `agent_cmd`. If it's the "auto" sentinel, run PATH detection;
72
+ * otherwise verify the first token (the binary) exists in PATH so we
73
+ * fail at preflight instead of after `server.listen` with a cryptic
74
+ * ENOENT in the bridge stderr.
75
+ */
76
+ function checkAgent(agentCmd) {
77
+ if (agentCmd === "auto") {
78
+ const r = detectAgent();
79
+ if (r.ok) {
80
+ return {
81
+ ok: true,
82
+ name: "acp agent",
83
+ detail: `${r.label} (${r.cmd})`,
84
+ resolved: r.cmd,
85
+ };
86
+ }
87
+ return {
88
+ ok: false,
89
+ name: "acp agent",
90
+ detail: "no ACP-ready binary in PATH",
91
+ hint: formatDetectionFailure(r).replace(/^\[bridge\] [^\n]*\n\n?/, ""),
92
+ };
93
+ }
94
+ // Explicit agent_cmd. Best-effort sanity: check first token's binary.
95
+ const bin = agentCmd.trim().split(/\s+/)[0];
96
+ if (!bin) {
97
+ return {
98
+ ok: false,
99
+ name: "acp agent",
100
+ detail: agentCmd,
101
+ hint: "agent_cmd is empty.",
102
+ };
103
+ }
104
+ // Use the same detection helper logic via a one-off PATH probe.
105
+ const which = process.platform === "win32" ? "where" : "which";
106
+ const r = spawnSync(which, [bin], { stdio: "ignore" });
107
+ if (r.status === 0) {
108
+ return {
109
+ ok: true,
110
+ name: "acp agent",
111
+ detail: agentCmd,
112
+ resolved: agentCmd,
113
+ };
114
+ }
115
+ return {
116
+ ok: false,
117
+ name: "acp agent",
118
+ detail: agentCmd,
119
+ hint: `'${bin}' not found in PATH.\nverify the binary is installed, or set agent_cmd to "auto" for automatic detection.`,
120
+ };
121
+ }
122
+ /**
123
+ * Probe whether `host:port` can be bound (matching what `server.listen`
124
+ * actually uses). Listens, then closes immediately. There's a tiny
125
+ * race window between close and the real server.listen() — that's
126
+ * fine for diagnostics: the goal is a friendly hint, not a hard
127
+ * guarantee.
128
+ *
129
+ * Port 0 means "let the OS pick"; we treat it as always-free.
130
+ *
131
+ * Probing the same host as the real server matters: a foreign
132
+ * listener on 0.0.0.0:PORT occupies 127.0.0.1:PORT too (more-specific
133
+ * bind fails when wildcard already bound), so binding the configured
134
+ * host catches conflicts and surfaces EADDRNOTAVAIL when the user
135
+ * typo'd an IP that isn't on any local interface.
136
+ */
137
+ async function checkPort(port, host) {
138
+ const label = `${host}:${port}`;
139
+ // port=0 still gets probed: the OS picks any free port, but bind can
140
+ // still fail with EADDRNOTAVAIL if `host` isn't on any local
141
+ // interface — that's exactly the typo we want to catch.
142
+ const result = await new Promise((settle) => {
143
+ const probe = createServer();
144
+ probe.once("error", (err) => {
145
+ settle({ code: err.code ?? "unknown" });
146
+ });
147
+ probe.listen(port, host, () => {
148
+ const addr = probe.address();
149
+ const assigned = typeof addr === "object" && addr ? addr.port : port;
150
+ probe.close(() => {
151
+ settle({ assigned });
152
+ });
153
+ });
154
+ });
155
+ if (!result.code) {
156
+ if (port === 0) {
157
+ return {
158
+ ok: true,
159
+ name: "port",
160
+ detail: `${host}:0 (OS-assigned → ${result.assigned})`,
161
+ };
162
+ }
163
+ return { ok: true, name: "port", detail: label };
164
+ }
165
+ if (result.code === "EADDRINUSE") {
166
+ return {
167
+ ok: false,
168
+ name: "port",
169
+ detail: `${label} (in use)`,
170
+ hint: `${label} is already in use (EADDRINUSE).\nfind the owner: ${process.platform === "win32"
171
+ ? `netstat -ano | findstr :${port}`
172
+ : `lsof -nP -iTCP:${port} -sTCP:LISTEN`}\nor change \`port\` in config.toml to a free port.`,
173
+ };
174
+ }
175
+ if (result.code === "EADDRNOTAVAIL") {
176
+ return {
177
+ ok: false,
178
+ name: "port",
179
+ detail: `${label} (${result.code})`,
180
+ hint: `cannot bind ${label}: ${result.code}\nthe host '${host}' is not assigned to any local interface.\ncheck \`host\` in config.toml — typical values are "127.0.0.1" (loopback) or "0.0.0.0" (all interfaces).`,
181
+ };
182
+ }
183
+ return {
184
+ ok: false,
185
+ name: "port",
186
+ detail: `${label} (${result.code})`,
187
+ hint: `cannot bind ${label}: ${result.code}\ncheck firewall / permissions, or change \`host\`/\`port\` in config.toml.`,
188
+ };
189
+ }
190
+ /**
191
+ * Run all preflight checks in order. Prints each result; exits process
192
+ * (78) on first failure. On success, returns the resolved agent command
193
+ * so the caller doesn't need to re-detect.
194
+ */
195
+ export async function runPreflight(opts) {
196
+ const checks = [];
197
+ checks.push(checkNodeVersion());
198
+ checks.push(checkDataDir(opts.data_dir));
199
+ const agent = checkAgent(opts.agent_cmd);
200
+ checks.push(agent);
201
+ checks.push(await checkPort(opts.port, opts.host));
202
+ for (const c of checks) {
203
+ if (c.ok)
204
+ printOk(c);
205
+ else {
206
+ printFail(c);
207
+ process.exit(78);
208
+ }
209
+ }
210
+ // After the loop above all checks are ok — narrow the agent result.
211
+ return {
212
+ agentCmd: agent.resolved,
213
+ };
214
+ }
@@ -1,21 +1,76 @@
1
1
  import webpush from "web-push";
2
2
  import { chmodSync, existsSync, readFileSync, writeFileSync } from "node:fs";
3
+ import { createHash } from "node:crypto";
3
4
  import { join } from "node:path";
5
+ import { log } from "./log.js";
6
+ const slog = log.scope("push");
7
+ const elog = log.scope("egress");
4
8
  const VAPID_FILE = "vapid.json";
5
9
  /** Remove a subscription after this many consecutive send failures. */
6
10
  const MAX_CONSECUTIVE_FAILURES = 5;
11
+ /** Derive the push tag for an ACP event. Kept module-level so it can be
12
+ * reused by the close-on-handle path without instantiating the service. */
13
+ export function pushTagForEvent(sessionId, event) {
14
+ switch (event.type) {
15
+ case "prompt_done":
16
+ return `sess-${sessionId}-done`;
17
+ case "permission_request":
18
+ return `sess-${sessionId}-perm-${event.eventId ?? "0"}`;
19
+ case "bash_done":
20
+ return `sess-${sessionId}-bash-${event.eventId ?? "0"}`;
21
+ default:
22
+ return `sess-${sessionId}-${event.type}`;
23
+ }
24
+ }
25
+ /**
26
+ * True iff the push endpoint is an Apple (APNs / Web Push on Apple) host.
27
+ *
28
+ * We deliberately filter silent `kind:"close"` pushes to these endpoints in
29
+ * `sendToAll` — iOS Safari PWA enforces an undocumented silent-push budget
30
+ * that, once exhausted, causes WebKit to drop ALL subsequent pushes for the
31
+ * subscription (including user-visible `kind:"notify"`). APNs returns 201
32
+ * throughout, so the server has zero visibility.
33
+ *
34
+ * macOS Safari shares the same host but is not subject to that budget; the
35
+ * conflation here is accepted collateral damage until we add a `platform`
36
+ * column to `push_subscriptions`.
37
+ */
38
+ export function isAppleEndpoint(endpoint) {
39
+ let host;
40
+ try {
41
+ host = new URL(endpoint).hostname.toLowerCase();
42
+ }
43
+ catch {
44
+ return false;
45
+ }
46
+ return host === "web.push.apple.com" || host.endsWith(".push.apple.com");
47
+ }
48
+ function emptyClientState() {
49
+ return { endpoint: null };
50
+ }
7
51
  export class PushService {
8
52
  store;
9
53
  vapidKeys;
10
- clientVisibility = new Map(); // clientId → visible
11
- clientEndpoints = new Map(); // clientId push endpoint
12
- clientSessions = new Map(); // clientId currently viewed sessionId
54
+ /**
55
+ * Per-client transport state only the push endpoint. Visibility lives
56
+ * in ClientRegistry as of Plan C Step 4.
57
+ */
58
+ clients = new Map();
13
59
  /** endpoint → consecutive failure count (absent or 0 = healthy) */
14
60
  failureCounts = new Map();
15
- constructor(store, dataDir, vapidSubject) {
61
+ globalVisibilitySuppression;
62
+ visibilityTtlMs;
63
+ now;
64
+ clientRegistry;
65
+ constructor(store, dataDir, vapidSubject, options) {
16
66
  this.store = store;
17
67
  this.vapidKeys = this.loadOrGenerateKeys(dataDir);
18
68
  webpush.setVapidDetails(vapidSubject, this.vapidKeys.publicKey, this.vapidKeys.privateKey);
69
+ this.globalVisibilitySuppression =
70
+ options.globalVisibilitySuppression ?? true;
71
+ this.visibilityTtlMs = options.visibilityTtlMs ?? 60_000;
72
+ this.now = options.now ?? (() => Date.now());
73
+ this.clientRegistry = options.clientRegistry;
19
74
  }
20
75
  // ---------------------------------------------------------------------------
21
76
  // VAPID keys
@@ -25,12 +80,14 @@ export class PushService {
25
80
  if (existsSync(filePath)) {
26
81
  chmodSync(filePath, 0o600);
27
82
  const keys = JSON.parse(readFileSync(filePath, "utf8"));
28
- console.log("[push] loaded VAPID keys");
83
+ slog.info("loaded VAPID keys");
29
84
  return keys;
30
85
  }
31
86
  const keys = webpush.generateVAPIDKeys();
32
- writeFileSync(filePath, JSON.stringify(keys, null, 2) + "\n", { mode: 0o600 });
33
- console.log("[push] generated new VAPID keys");
87
+ writeFileSync(filePath, JSON.stringify(keys, null, 2) + "\n", {
88
+ mode: 0o600,
89
+ });
90
+ slog.info("generated new VAPID keys");
34
91
  return keys;
35
92
  }
36
93
  getPublicKey() {
@@ -39,81 +96,107 @@ export class PushService {
39
96
  // ---------------------------------------------------------------------------
40
97
  // Notification formatting
41
98
  // ---------------------------------------------------------------------------
42
- formatNotification(sessionId, sessionTitle, eventType, eventData) {
43
- const title = sessionTitle || "WebAgent";
99
+ formatNotification(sessionId, sessionTitle, eventType, eventData, tag) {
100
+ const title = sessionTitle ?? "WebAgent";
44
101
  let body;
45
102
  switch (eventType) {
46
103
  case "permission_request":
47
- body = `⚿ ${eventData.description ?? "Permission requested"}`;
104
+ body = `⚿ ${typeof eventData.description === "string" ? eventData.description : "Permission requested"}`;
48
105
  break;
49
106
  case "prompt_done":
50
107
  body = "✓ Task complete";
51
108
  break;
52
109
  case "bash_done": {
53
- const cmd = eventData.command ?? "command";
54
- const code = eventData.exitCode ?? "?";
110
+ const cmd = typeof eventData.command === "string" ? eventData.command : "command";
111
+ const code = typeof eventData.exitCode === "number" ||
112
+ typeof eventData.exitCode === "string"
113
+ ? String(eventData.exitCode)
114
+ : "?";
55
115
  body = `$ ${cmd} — exit ${code}`;
56
116
  break;
57
117
  }
58
118
  default:
59
119
  body = eventType;
60
120
  }
61
- return { title, body, data: { sessionId } };
121
+ return { kind: "notify", title, body, tag, data: { sessionId } };
62
122
  }
63
123
  // ---------------------------------------------------------------------------
64
- // Client visibility tracking
124
+ // Endpoint mapping (transport-only as of Plan C Step 4)
65
125
  // ---------------------------------------------------------------------------
66
- setClientVisibility(clientId, visible) {
67
- this.clientVisibility.set(clientId, visible);
126
+ /**
127
+ * Set the push endpoint for a client. Identity-layer state (visibility,
128
+ * active session) goes through ClientRegistry.setVisibility, not here.
129
+ */
130
+ updateClient(clientId, patch) {
131
+ const prev = this.clients.get(clientId) ?? emptyClientState();
132
+ const next = { ...prev };
133
+ if (patch.endpoint !== undefined)
134
+ next.endpoint = patch.endpoint;
135
+ this.clients.set(clientId, next);
68
136
  }
69
- setClientSession(clientId, sessionId) {
70
- this.clientSessions.set(clientId, sessionId);
137
+ /**
138
+ * Read-only snapshot for tests and diagnostics. Do NOT mutate the
139
+ * returned object.
140
+ */
141
+ getClientState(clientId) {
142
+ return this.clients.get(clientId) ?? null;
71
143
  }
72
144
  registerClient(clientId, endpoint) {
73
- this.clientEndpoints.set(clientId, endpoint);
145
+ this.updateClient(clientId, { endpoint });
74
146
  }
75
147
  removeClient(clientId) {
76
- this.clientVisibility.delete(clientId);
77
- this.clientEndpoints.delete(clientId);
78
- this.clientSessions.delete(clientId);
148
+ this.clients.delete(clientId);
149
+ // Disconnect also wipes identity-layer state so visibility queries don't
150
+ // leak past the SSE lifetime. Production calls removeClient on SSE close
151
+ // (see sse-manager); tests expect the same.
152
+ this.clientRegistry.remove(clientId);
79
153
  }
80
154
  hasVisibleClient() {
81
- for (const visible of this.clientVisibility.values()) {
82
- if (visible)
83
- return true;
84
- }
85
- return false;
155
+ return this.clientRegistry.hasAnyVisibleClient();
86
156
  }
87
- /** Check if a specific endpoint has at least one visible client. */
157
+ /** Check if a specific endpoint has at least one visible (non-stale) client. */
88
158
  isEndpointVisible(endpoint) {
89
- for (const [clientId, ep] of this.clientEndpoints) {
90
- if (ep === endpoint && this.clientVisibility.get(clientId))
159
+ // Identity (visible) comes from registry, transport (endpoint↔clientId)
160
+ // stays with pushService.clients.
161
+ const reg = this.clientRegistry;
162
+ for (const [clientId, s] of this.clients) {
163
+ if (s.endpoint !== endpoint)
164
+ continue;
165
+ if (reg.isClientVisible(clientId))
91
166
  return true;
92
167
  }
93
168
  return false;
94
169
  }
95
170
  /**
96
- * Check if any client (across all endpoints) is visible and viewing the given session.
97
- * A client with no session set does not suppress any session's push.
98
- * Used for global suppression: if any client sees this session, all endpoints are skipped.
171
+ * Check if any client (across all endpoints) is visible and viewing the
172
+ * given session. A client with no session set does not suppress any
173
+ * session's push. Stale records (older than `visibilityTtlMs` since the
174
+ * last heartbeat refresh) are ignored — this is the server-side safety
175
+ * net for iOS PWA suspension, where the client's `visible:false` POST may
176
+ * never leave the device.
177
+ *
178
+ * Returns false unconditionally when global suppression is disabled via
179
+ * the `globalVisibilitySuppression` option (kill switch).
99
180
  */
100
181
  isSessionVisibleToAnyClient(sessionId) {
101
- for (const [clientId, visible] of this.clientVisibility) {
102
- if (visible && this.clientSessions.get(clientId) === sessionId)
103
- return true;
104
- }
105
- return false;
182
+ if (!this.globalVisibilitySuppression)
183
+ return false;
184
+ return this.clientRegistry.isSessionVisibleToAnyClient(sessionId);
106
185
  }
107
186
  // ---------------------------------------------------------------------------
108
187
  // High-level: decide whether to push, and if so, send
109
188
  // ---------------------------------------------------------------------------
110
- static NOTIFIABLE = new Set(["permission_request", "prompt_done", "bash_done"]);
189
+ static NOTIFIABLE = new Set([
190
+ "permission_request",
191
+ "prompt_done",
192
+ "bash_done",
193
+ ]);
111
194
  /**
112
195
  * Check if this event should trigger a push notification.
113
196
  * Returns true if a notification should be sent (caller should then call sendToAll).
114
197
  * Global session visibility suppression happens inside sendToAll.
115
198
  */
116
- maybeNotify(sessionId, sessionTitle, eventType, eventData) {
199
+ maybeNotify(sessionId, sessionTitle, eventType, _eventData) {
117
200
  if (!PushService.NOTIFIABLE.has(eventType))
118
201
  return false;
119
202
  return true;
@@ -122,45 +205,207 @@ export class PushService {
122
205
  // Send push to all subscriptions
123
206
  // ---------------------------------------------------------------------------
124
207
  async sendToAll(notification) {
125
- const subs = this.store.getAllSubscriptions();
126
- if (subs.length === 0)
208
+ const allSubs = this.store.getAllSubscriptions();
209
+ if (allSubs.length === 0)
127
210
  return;
211
+ // Global visibility: if any client is viewing this session, suppress notify pushes.
212
+ // Close pushes are never suppressed — they're silent and are the mechanism
213
+ // by which cross-device recall actually closes banners on the "losing" devices.
214
+ if (notification.kind === "notify") {
215
+ const targetSession = notification.data.sessionId;
216
+ if (targetSession && this.isSessionVisibleToAnyClient(targetSession))
217
+ return;
218
+ }
219
+ // Skip Apple endpoints for silent close pushes to preserve iOS PWA's
220
+ // silent-push budget. See isAppleEndpoint() for rationale.
221
+ let subs = allSubs;
222
+ let filteredApple = 0;
223
+ if (notification.kind === "close") {
224
+ subs = allSubs.filter((s) => {
225
+ if (isAppleEndpoint(s.endpoint)) {
226
+ filteredApple++;
227
+ return false;
228
+ }
229
+ return true;
230
+ });
231
+ if (subs.length === 0) {
232
+ elog.info("sendClose", {
233
+ tag: notification.tag,
234
+ endpoints: 0,
235
+ ok: 0,
236
+ fail: 0,
237
+ fail_410: 0,
238
+ filtered_apple: filteredApple,
239
+ });
240
+ return;
241
+ }
242
+ }
128
243
  const payload = JSON.stringify(notification);
129
- // Global visibility: if any client is viewing this session, suppress all push
130
- if (this.isSessionVisibleToAnyClient(notification.data.sessionId))
131
- return;
132
- const results = await Promise.allSettled(subs.map((sub) => this.sendOne({ endpoint: sub.endpoint, keys: { auth: sub.auth, p256dh: sub.p256dh } }, payload)));
244
+ // Derive an RFC 8030 Topic so push services can collapse undelivered
245
+ // pushes on the wire. FCM (Chrome/Firefox desktop + Android) honors
246
+ // this and collapses correctly. APNs (iOS Safari PWA) does NOT — we
247
+ // verified in dogfood that two same-Topic pushes still surface as
248
+ // two stacked banners on iOS 17, even combined with the SW-side
249
+ // close-before-show workaround (see public/sw.js). Keep the header
250
+ // anyway: it's spec-compliant, cheap, and benefits every non-Apple
251
+ // client. iOS banner stacking is a platform limitation we accept.
252
+ const topic = tagToTopic(notification.tag);
253
+ const results = await Promise.allSettled(subs.map((sub) => this.sendOne({
254
+ endpoint: sub.endpoint,
255
+ keys: { auth: sub.auth, p256dh: sub.p256dh },
256
+ }, payload, { topic })));
257
+ let ok = 0;
258
+ let fail = 0;
259
+ let fail410 = 0;
133
260
  for (let i = 0; i < results.length; i++) {
134
261
  const result = results[i];
135
262
  const endpoint = subs[i].endpoint;
136
263
  if (result.status === "fulfilled") {
137
264
  this.failureCounts.delete(endpoint);
265
+ ok++;
138
266
  }
139
267
  else {
268
+ fail++;
140
269
  const err = result.reason;
141
270
  if (err.statusCode === 410) {
142
- // Subscription expired — clean up immediately
271
+ fail410++;
143
272
  this.store.removeSubscription(endpoint);
144
273
  this.failureCounts.delete(endpoint);
145
- console.log(`[push] removed expired subscription (410): ${endpoint.slice(0, 60)}…`);
274
+ slog.info("removed expired subscription (410)", {
275
+ endpoint: endpoint.slice(0, 60) + "…",
276
+ });
146
277
  }
147
278
  else {
148
279
  const count = (this.failureCounts.get(endpoint) ?? 0) + 1;
149
280
  if (count >= MAX_CONSECUTIVE_FAILURES) {
150
281
  this.store.removeSubscription(endpoint);
151
282
  this.failureCounts.delete(endpoint);
152
- console.log(`[push] removed subscription after ${count} consecutive failures: ${endpoint.slice(0, 60)}…`);
283
+ slog.info("removed subscription after consecutive failures", {
284
+ count,
285
+ endpoint: endpoint.slice(0, 60) + "…",
286
+ });
153
287
  }
154
288
  else {
155
289
  this.failureCounts.set(endpoint, count);
156
- console.error(`[push] send failed (${count}/${MAX_CONSECUTIVE_FAILURES}) for ${endpoint.slice(0, 60)}…:`, result.reason);
290
+ slog.error("send failed", {
291
+ count,
292
+ max: MAX_CONSECUTIVE_FAILURES,
293
+ endpoint: endpoint.slice(0, 60) + "…",
294
+ error: result.reason,
295
+ });
157
296
  }
158
297
  }
159
298
  }
160
299
  }
300
+ if (notification.kind === "close") {
301
+ // Observability signal #6 — aggregated per-call close outcome.
302
+ elog.info("sendClose", {
303
+ tag: notification.tag,
304
+ endpoints: subs.length,
305
+ ok,
306
+ fail,
307
+ fail_410: fail410,
308
+ filtered_apple: filteredApple,
309
+ });
310
+ }
311
+ }
312
+ /** Send a silent close push for the given tag. Never visibility-suppressed. */
313
+ async sendClose(tag) {
314
+ await this.sendToAll({ kind: "close", tag });
315
+ }
316
+ /**
317
+ * Send a push for an external message. Respects the message's `deliver`
318
+ * intent: `silent` sends nothing, `inapp` and `push` both send through
319
+ * web-push (the `inapp` vs `push` distinction is enforced by the SW /
320
+ * frontend rendering, not by the server).
321
+ *
322
+ * Tag = `msg-<id>` for unbound messages; bound messages get the
323
+ * `sess-<sid>-msg-<eid>` tag by taking a different code path in the
324
+ * consume handler.
325
+ */
326
+ async sendForMessage(msg) {
327
+ const deliver = msg.deliver ?? "push";
328
+ if (deliver === "silent")
329
+ return false;
330
+ const subs = this.store.getAllSubscriptions();
331
+ const title = msg.from_label ?? msg.from_ref ?? "Message";
332
+ const body = msg.body.length > 140 ? msg.body.slice(0, 137) + "…" : msg.body;
333
+ const tag = msg.dedup_key ? `dedup-${msg.to}-${msg.dedup_key}` : msg.id;
334
+ // If this message targets a specific session, surface the sid in the
335
+ // push data so SW notificationclick can route the user there. Without
336
+ // this, clicks fall back to "/" and land on whatever session was last
337
+ // open — a confusing UX when multiple sessions get background pushes.
338
+ const sessionId = msg.to.startsWith("session:")
339
+ ? msg.to.slice("session:".length)
340
+ : undefined;
341
+ // Observability signal #7 — sendForMessage entry.
342
+ elog.info("sendForMessage", {
343
+ msg_id: msg.id,
344
+ tag,
345
+ deliver,
346
+ endpoints: subs.length,
347
+ suppressed_by_visibility: false,
348
+ });
349
+ await this.sendToAll({
350
+ kind: "notify",
351
+ title,
352
+ body,
353
+ tag,
354
+ data: sessionId
355
+ ? { messageId: msg.id, sessionId }
356
+ : { messageId: msg.id },
357
+ });
358
+ return true;
359
+ }
360
+ /**
361
+ * Send a push for an ACP session event (permission_request / prompt_done /
362
+ * bash_done). Handles tag derivation, visibility suppression, and session-
363
+ * title lookup. Returns `true` if a push attempt was made.
364
+ */
365
+ async sendForEvent(sessionId, event) {
366
+ if (!PushService.NOTIFIABLE.has(event.type))
367
+ return false;
368
+ const session = this.store.getSession(sessionId);
369
+ const sessionTitle = session?.title ?? null;
370
+ const tag = pushTagForEvent(sessionId, event);
371
+ const eventData = {};
372
+ if (event.type === "permission_request" && event.title !== undefined) {
373
+ eventData.description = event.title;
374
+ }
375
+ if (event.type === "bash_done") {
376
+ if (event.command !== undefined)
377
+ eventData.command = event.command;
378
+ if (event.exitCode !== undefined)
379
+ eventData.exitCode = event.exitCode;
380
+ }
381
+ const suppressed = this.isSessionVisibleToAnyClient(sessionId);
382
+ const subs = this.store.getAllSubscriptions();
383
+ elog.info("sendForEvent", {
384
+ sess_id: sessionId.slice(0, 8),
385
+ type: event.type,
386
+ tag,
387
+ endpoints: subs.length,
388
+ suppressed_by_visibility: suppressed,
389
+ });
390
+ const notification = this.formatNotification(sessionId, sessionTitle, event.type, eventData, tag);
391
+ await this.sendToAll(notification);
392
+ return true;
161
393
  }
162
394
  /** Send a single push notification. Extracted for testability. */
163
- sendOne(sub, payload) {
164
- return webpush.sendNotification(sub, payload);
395
+ sendOne(sub, payload, options) {
396
+ return webpush.sendNotification(sub, payload, options);
165
397
  }
166
398
  }
399
+ /**
400
+ * Derive an RFC 8030 `Topic` header value from a notification tag. The
401
+ * spec limits topic to ≤32 chars of URL-safe Base64, so we hash and
402
+ * truncate. FCM maps this to its on-wire collapse key and works
403
+ * correctly. APNs is documented to map Topic → `apns-collapse-id`,
404
+ * but in practice iOS Safari PWA (≤17 at least) still surfaces
405
+ * stacked banners for same-Topic pushes — confirmed in dogfood.
406
+ * We keep the header for the platforms where it does work and for
407
+ * spec compliance; don't expect it to fix iOS.
408
+ */
409
+ function tagToTopic(tag) {
410
+ return createHash("sha256").update(tag).digest("base64url").slice(0, 22);
411
+ }