workerdeck 2.2.1 → 2.4.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.
@@ -2,12 +2,370 @@ import { createReadStream, existsSync } from "node:fs";
2
2
  import { dirname, isAbsolute, join, resolve, sep } from "node:path";
3
3
  import { createFileSessionStore, createWorkerServer } from "@workerdeck/server";
4
4
  import { dashboardDir } from "@workerdeck/web";
5
- import { createHash, createHmac, createPrivateKey, randomBytes, sign, timingSafeEqual } from "node:crypto";
6
5
  import { chmod, mkdir, readFile, rename, stat, writeFile } from "node:fs/promises";
6
+ import { createHash, createHmac, createPrivateKey, randomBytes, sign, timingSafeEqual } from "node:crypto";
7
7
  import { connect, constants } from "node:http2";
8
+ import { parseUserQuestions } from "@workerdeck/protocol";
8
9
  import { homedir } from "node:os";
9
10
  import { pathToFileURL } from "node:url";
10
11
  import { spawn } from "node:child_process";
12
+ //#region src/lib/http.ts
13
+ function readBody(req, maxBytes) {
14
+ return new Promise((resolve) => {
15
+ const chunks = [];
16
+ let size = 0;
17
+ let settled = false;
18
+ const finish = (value) => {
19
+ if (!settled) {
20
+ settled = true;
21
+ resolve(value);
22
+ }
23
+ };
24
+ req.on("data", (chunk) => {
25
+ size += chunk.length;
26
+ if (size > maxBytes) {
27
+ finish(null);
28
+ return;
29
+ }
30
+ chunks.push(chunk);
31
+ });
32
+ req.on("end", () => finish(Buffer.concat(chunks).toString("utf8")));
33
+ req.on("error", () => finish(null));
34
+ });
35
+ }
36
+ function respondJson(res, status, body, headers) {
37
+ res.writeHead(status, {
38
+ "content-type": "application/json",
39
+ "cache-control": "no-store",
40
+ ...headers
41
+ }).end(JSON.stringify(body));
42
+ }
43
+ //#endregion
44
+ //#region src/apns/activities.ts
45
+ const FILENAME$1 = "apns-activities.json";
46
+ const TOKEN_PATTERN$1 = /^[0-9a-fA-F]{32,200}$/;
47
+ const MAX_BODY_BYTES$1 = 4096;
48
+ function isEnvironment$1(value) {
49
+ return value === "development" || value === "production";
50
+ }
51
+ function keyOf(deviceToken, sessionId) {
52
+ return `${deviceToken}:${sessionId}`;
53
+ }
54
+ function persistable(record) {
55
+ const { deviceToken, sessionId, hostId, environment, updateToken, phase, startedAt } = record;
56
+ return {
57
+ deviceToken,
58
+ sessionId,
59
+ hostId,
60
+ environment,
61
+ updateToken,
62
+ phase,
63
+ startedAt
64
+ };
65
+ }
66
+ async function createActivityRegistry(options) {
67
+ const path = options.dir === null ? null : join(options.dir, FILENAME$1);
68
+ const records = /* @__PURE__ */ new Map();
69
+ if (path !== null) try {
70
+ const parsed = JSON.parse(await readFile(path, "utf8"));
71
+ for (const record of parsed.activities ?? []) if (typeof record?.deviceToken === "string" && typeof record.sessionId === "string" && isEnvironment$1(record.environment)) records.set(keyOf(record.deviceToken, record.sessionId), record);
72
+ } catch {}
73
+ const persist = async () => {
74
+ if (path === null) return;
75
+ try {
76
+ await mkdir(options.dir, {
77
+ recursive: true,
78
+ mode: 448
79
+ });
80
+ await writeFile(path, `${JSON.stringify({ activities: [...records.values()].map(persistable) }, null, 2)}\n`, { mode: 384 });
81
+ await chmod(path, 384);
82
+ } catch (error) {
83
+ options.onError?.(error, {
84
+ op: "write",
85
+ path
86
+ });
87
+ }
88
+ };
89
+ return {
90
+ list: () => [...records.values()],
91
+ get: (deviceToken, sessionId) => records.get(keyOf(deviceToken, sessionId)),
92
+ forSession: (sessionId) => [...records.values()].filter((record) => record.sessionId === sessionId),
93
+ async start(record) {
94
+ const existing = records.get(keyOf(record.deviceToken, record.sessionId));
95
+ if (existing !== void 0) return existing;
96
+ const created = {
97
+ ...record,
98
+ phase: "starting",
99
+ startedAt: record.startedAt ?? Date.now()
100
+ };
101
+ records.set(keyOf(created.deviceToken, created.sessionId), created);
102
+ await persist();
103
+ return created;
104
+ },
105
+ async attach(match, updateToken) {
106
+ const direct = match.deviceToken === void 0 ? void 0 : records.get(keyOf(match.deviceToken, match.sessionId));
107
+ const candidates = direct !== void 0 ? [direct] : [...records.values()].filter((record) => record.sessionId === match.sessionId && record.environment === match.environment && record.updateToken === void 0);
108
+ const target = candidates.length === 1 ? candidates[0] : void 0;
109
+ if (target === void 0 || target.updateToken === updateToken) return target;
110
+ target.updateToken = updateToken;
111
+ target.phase = "live";
112
+ await persist();
113
+ return target;
114
+ },
115
+ async touch(deviceToken, sessionId, changes) {
116
+ const record = records.get(keyOf(deviceToken, sessionId));
117
+ if (record === void 0) return;
118
+ const structural = changes.phase !== void 0 && changes.phase !== record.phase;
119
+ Object.assign(record, changes);
120
+ if (structural) await persist();
121
+ },
122
+ async remove(deviceToken, sessionId) {
123
+ if (!records.delete(keyOf(deviceToken, sessionId))) return;
124
+ await persist();
125
+ },
126
+ async removeByUpdateToken(updateToken) {
127
+ let removed = false;
128
+ for (const [key, record] of records) if (record.updateToken === updateToken) {
129
+ records.delete(key);
130
+ removed = true;
131
+ }
132
+ if (removed) await persist();
133
+ }
134
+ };
135
+ }
136
+ function createActivityRoute(registry, authenticate) {
137
+ return async (req, res) => {
138
+ let pathname;
139
+ try {
140
+ pathname = new URL(req.url ?? "/", "http://internal").pathname;
141
+ } catch {
142
+ return false;
143
+ }
144
+ if (pathname !== "/apns/activities") return false;
145
+ if (registry === null) {
146
+ res.writeHead(404, { "content-type": "text/plain; charset=utf-8" });
147
+ res.end("this gateway runs without push\n");
148
+ return true;
149
+ }
150
+ if (req.method !== "POST" && req.method !== "DELETE") {
151
+ res.writeHead(405, { allow: "POST, DELETE" }).end();
152
+ return true;
153
+ }
154
+ if (authenticate(req) === null) {
155
+ respondJson(res, 401, { error: "unauthorized" });
156
+ return true;
157
+ }
158
+ const raw = await readBody(req, MAX_BODY_BYTES$1);
159
+ if (raw === null) {
160
+ respondJson(res, 413, { error: "body too large" });
161
+ res.once("finish", () => req.destroy());
162
+ return true;
163
+ }
164
+ let body;
165
+ try {
166
+ body = JSON.parse(raw);
167
+ } catch {
168
+ respondJson(res, 400, { error: "invalid JSON body" });
169
+ return true;
170
+ }
171
+ const token = body.token;
172
+ if (typeof token !== "string" || !TOKEN_PATTERN$1.test(token)) {
173
+ respondJson(res, 400, { error: "token must be a hex Live Activity update token" });
174
+ return true;
175
+ }
176
+ if (req.method === "DELETE") {
177
+ await registry.removeByUpdateToken(token);
178
+ res.writeHead(204).end();
179
+ return true;
180
+ }
181
+ const sessionId = body.sessionId;
182
+ if (typeof sessionId !== "string" || sessionId === "") {
183
+ respondJson(res, 400, { error: "sessionId is required" });
184
+ return true;
185
+ }
186
+ if (!isEnvironment$1(body.environment)) {
187
+ respondJson(res, 400, { error: "environment must be 'development' or 'production'" });
188
+ return true;
189
+ }
190
+ const deviceToken = typeof body.deviceToken === "string" && TOKEN_PATTERN$1.test(body.deviceToken) ? body.deviceToken : void 0;
191
+ if (await registry.attach({
192
+ sessionId,
193
+ deviceToken,
194
+ environment: body.environment
195
+ }, token) === void 0) {
196
+ respondJson(res, 404, { error: "no activity awaiting a token for this session" });
197
+ return true;
198
+ }
199
+ respondJson(res, 200, {
200
+ attached: true,
201
+ sessionId
202
+ });
203
+ return true;
204
+ };
205
+ }
206
+ //#endregion
207
+ //#region src/apns/devices.ts
208
+ const NOTIFY_EVENTS = [
209
+ "permission_requested",
210
+ "turn_completed",
211
+ "session_error",
212
+ "session_closed"
213
+ ];
214
+ const DEFAULT_NOTIFY = [
215
+ "permission_requested",
216
+ "turn_completed",
217
+ "session_error"
218
+ ];
219
+ const FILENAME = "apns-devices.json";
220
+ const TOKEN_PATTERN = /^[0-9a-fA-F]{32,200}$/;
221
+ const MAX_BODY_BYTES = 4096;
222
+ function isEnvironment(value) {
223
+ return value === "development" || value === "production";
224
+ }
225
+ function isNotifyList(value) {
226
+ return Array.isArray(value) && value.every((entry) => NOTIFY_EVENTS.includes(entry));
227
+ }
228
+ function sameNotify(a, b) {
229
+ if (a === void 0 || b === void 0) return a === b;
230
+ return a.length === b.length && a.every((entry, index) => entry === b[index]);
231
+ }
232
+ function wantsNotification(device, type) {
233
+ return (device.notify ?? DEFAULT_NOTIFY).includes(type);
234
+ }
235
+ async function createDeviceRegistry(options) {
236
+ const path = options.dir === null ? null : join(options.dir, FILENAME);
237
+ const devices = /* @__PURE__ */ new Map();
238
+ if (path !== null) try {
239
+ const parsed = JSON.parse(await readFile(path, "utf8"));
240
+ for (const record of parsed.devices ?? []) if (typeof record?.token === "string" && isEnvironment(record.environment)) devices.set(record.token, record);
241
+ } catch {}
242
+ const persist = async () => {
243
+ if (path === null) return;
244
+ try {
245
+ await mkdir(options.dir, {
246
+ recursive: true,
247
+ mode: 448
248
+ });
249
+ await writeFile(path, `${JSON.stringify({ devices: [...devices.values()] }, null, 2)}\n`, { mode: 384 });
250
+ await chmod(path, 384);
251
+ } catch (error) {
252
+ options.onError?.(error, {
253
+ op: "write",
254
+ path
255
+ });
256
+ }
257
+ };
258
+ return {
259
+ list: () => [...devices.values()],
260
+ async register(record) {
261
+ const existing = devices.get(record.token);
262
+ devices.set(record.token, {
263
+ ...record,
264
+ updatedAt: Date.now()
265
+ });
266
+ if (existing !== void 0 && existing.environment === record.environment && existing.hostId === record.hostId && existing.liveActivityStartToken === record.liveActivityStartToken && sameNotify(existing.notify, record.notify)) return;
267
+ await persist();
268
+ },
269
+ async clearStartToken(token) {
270
+ const existing = devices.get(token);
271
+ if (existing === void 0 || existing.liveActivityStartToken === void 0) return;
272
+ delete existing.liveActivityStartToken;
273
+ await persist();
274
+ },
275
+ async remove(token) {
276
+ if (!devices.delete(token)) return;
277
+ await persist();
278
+ }
279
+ };
280
+ }
281
+ function createDeviceRoute(registry, authenticate) {
282
+ return async (req, res) => {
283
+ let pathname;
284
+ try {
285
+ pathname = new URL(req.url ?? "/", "http://internal").pathname;
286
+ } catch {
287
+ return false;
288
+ }
289
+ if (pathname !== "/apns/devices") return false;
290
+ if (registry === null) {
291
+ res.writeHead(404, { "content-type": "text/plain; charset=utf-8" });
292
+ res.end("this gateway runs without push\n");
293
+ return true;
294
+ }
295
+ if (req.method !== "POST" && req.method !== "DELETE") {
296
+ res.writeHead(405, { allow: "POST, DELETE" }).end();
297
+ return true;
298
+ }
299
+ if (authenticate(req) === null) {
300
+ respondJson(res, 401, { error: "unauthorized" });
301
+ return true;
302
+ }
303
+ const raw = await readBody(req, MAX_BODY_BYTES);
304
+ if (raw === null) {
305
+ respondJson(res, 413, { error: "body too large" });
306
+ res.once("finish", () => req.destroy());
307
+ return true;
308
+ }
309
+ let body;
310
+ try {
311
+ body = JSON.parse(raw);
312
+ } catch {
313
+ respondJson(res, 400, { error: "invalid JSON body" });
314
+ return true;
315
+ }
316
+ const token = body.token;
317
+ if (typeof token !== "string" || !TOKEN_PATTERN.test(token)) {
318
+ respondJson(res, 400, { error: "token must be a hex APNs device token" });
319
+ return true;
320
+ }
321
+ if (req.method === "DELETE") {
322
+ await registry.remove(token);
323
+ res.writeHead(204).end();
324
+ return true;
325
+ }
326
+ if (!isEnvironment(body.environment)) {
327
+ respondJson(res, 400, { error: "environment must be 'development' or 'production'" });
328
+ return true;
329
+ }
330
+ const optionalString = (value) => typeof value === "string" && value.length > 0 && value.length <= 200 ? value : void 0;
331
+ const startTokenGiven = Object.hasOwn(body, "liveActivityStartToken");
332
+ const startToken = body.liveActivityStartToken;
333
+ if (startTokenGiven && startToken !== null && (typeof startToken !== "string" || !TOKEN_PATTERN.test(startToken))) {
334
+ respondJson(res, 400, { error: "liveActivityStartToken must be a hex token or null" });
335
+ return true;
336
+ }
337
+ const notifyGiven = Object.hasOwn(body, "notify");
338
+ if (notifyGiven && !isNotifyList(body.notify)) {
339
+ respondJson(res, 400, { error: `notify must be an array of ${NOTIFY_EVENTS.join(", ")}` });
340
+ return true;
341
+ }
342
+ const previous = registry.list().find((record) => record.token === token);
343
+ const liveActivityStartToken = startTokenGiven ? startToken ?? void 0 : previous?.liveActivityStartToken;
344
+ const notify = notifyGiven ? body.notify : previous?.notify;
345
+ await registry.register({
346
+ token,
347
+ environment: body.environment,
348
+ hostId: optionalString(body.hostId),
349
+ bundleId: optionalString(body.bundleId),
350
+ platform: optionalString(body.platform),
351
+ ...liveActivityStartToken === void 0 ? {} : { liveActivityStartToken },
352
+ ...notify === void 0 ? {} : { notify }
353
+ });
354
+ respondJson(res, 200, {
355
+ registered: true,
356
+ environment: body.environment
357
+ });
358
+ return true;
359
+ };
360
+ }
361
+ //#endregion
362
+ //#region src/apns/routes.ts
363
+ function createApnsRoute(registries, authenticate) {
364
+ const device = createDeviceRoute(registries?.devices ?? null, authenticate);
365
+ const activity = createActivityRoute(registries?.activities ?? null, authenticate);
366
+ return async (req, res) => await device(req, res) || activity(req, res);
367
+ }
368
+ //#endregion
11
369
  //#region src/apns/client.ts
12
370
  const HOSTS = {
13
371
  development: "https://api.sandbox.push.apple.com",
@@ -16,6 +374,9 @@ const HOSTS = {
16
374
  const TOKEN_TTL_MS = 24e5;
17
375
  const REQUEST_TIMEOUT_MS = 1e4;
18
376
  const DIAL_ATTEMPT_TIMEOUT_MS = 2e3;
377
+ function topicFor(topic, pushType) {
378
+ return pushType === "liveactivity" ? `${topic}.push-type.liveactivity` : topic;
379
+ }
19
380
  function base64url(input) {
20
381
  return Buffer.from(input).toString("base64url");
21
382
  }
@@ -119,8 +480,8 @@ function createApnsClient(config, key, options = {}) {
119
480
  ":method": "POST",
120
481
  ":path": `/3/device/${request.deviceToken}`,
121
482
  authorization: `bearer ${jwt}`,
122
- "apns-topic": config.topic,
123
- "apns-push-type": "alert",
483
+ "apns-topic": topicFor(config.topic, request.pushType),
484
+ "apns-push-type": request.pushType ?? "alert",
124
485
  "apns-priority": String(request.priority ?? 10),
125
486
  "apns-expiration": String(request.expiration ?? 0),
126
487
  "content-length": String(body.byteLength),
@@ -237,142 +598,211 @@ function createApnsClient(config, key, options = {}) {
237
598
  };
238
599
  }
239
600
  //#endregion
240
- //#region src/lib/http.ts
241
- function readBody(req, maxBytes) {
242
- return new Promise((resolve) => {
243
- const chunks = [];
244
- let size = 0;
245
- let settled = false;
246
- const finish = (value) => {
247
- if (!settled) {
248
- settled = true;
249
- resolve(value);
250
- }
251
- };
252
- req.on("data", (chunk) => {
253
- size += chunk.length;
254
- if (size > maxBytes) {
255
- finish(null);
256
- return;
257
- }
258
- chunks.push(chunk);
259
- });
260
- req.on("end", () => finish(Buffer.concat(chunks).toString("utf8")));
261
- req.on("error", () => finish(null));
262
- });
601
+ //#region src/apns/live-activity.ts
602
+ const MAX_PAYLOAD_BYTES$1 = 3800;
603
+ const LIMIT = {
604
+ title: 60,
605
+ headline: 120,
606
+ detail: 200,
607
+ choiceLabel: 40,
608
+ choices: 4
609
+ };
610
+ const STALE_MS = 12e5;
611
+ const DISMISSAL_MS = 9e5;
612
+ const EXPIRATION_MS = 6e5;
613
+ const END_EXPIRATION_MS = 144e5;
614
+ const RELEVANCE = {
615
+ approval: 100,
616
+ question: 100,
617
+ plan: 100,
618
+ running: 50,
619
+ done: 10,
620
+ failed: 10,
621
+ parked: 10,
622
+ ended: 10
623
+ };
624
+ const PREVIEW_KEYS = [
625
+ "command",
626
+ "file_path",
627
+ "path",
628
+ "pattern",
629
+ "url",
630
+ "notebook_path",
631
+ "prompt",
632
+ "description"
633
+ ];
634
+ function clamp(text, limit) {
635
+ const flat = text.replace(/\s+/g, " ").trim();
636
+ return flat.length <= limit ? flat : `${flat.slice(0, limit - 1)}…`;
263
637
  }
264
- function respondJson(res, status, body, headers) {
265
- res.writeHead(status, {
266
- "content-type": "application/json",
267
- "cache-control": "no-store",
268
- ...headers
269
- }).end(JSON.stringify(body));
638
+ function sessionLabel(info) {
639
+ const title = info.title?.trim();
640
+ if (title !== void 0 && title !== "") return title;
641
+ return cwdLeaf(info.cwd) || info.id;
270
642
  }
271
- //#endregion
272
- //#region src/apns/devices.ts
273
- const FILENAME = "apns-devices.json";
274
- const TOKEN_PATTERN = /^[0-9a-fA-F]{32,200}$/;
275
- const MAX_BODY_BYTES = 4096;
276
- function isEnvironment(value) {
277
- return value === "development" || value === "production";
643
+ function cwdLeaf(cwd) {
644
+ return cwd.split("/").filter(Boolean).at(-1) ?? "";
278
645
  }
279
- async function createDeviceRegistry(options) {
280
- const path = options.dir === null ? null : join(options.dir, FILENAME);
281
- const devices = /* @__PURE__ */ new Map();
282
- if (path !== null) try {
283
- const parsed = JSON.parse(await readFile(path, "utf8"));
284
- for (const record of parsed.devices ?? []) if (typeof record?.token === "string" && isEnvironment(record.environment)) devices.set(record.token, record);
285
- } catch {}
286
- const persist = async () => {
287
- if (path === null) return;
288
- try {
289
- await mkdir(options.dir, {
290
- recursive: true,
291
- mode: 448
292
- });
293
- await writeFile(path, `${JSON.stringify({ devices: [...devices.values()] }, null, 2)}\n`, { mode: 384 });
294
- await chmod(path, 384);
295
- } catch (error) {
296
- options.onError?.(error, {
297
- op: "write",
298
- path
299
- });
646
+ function previewOf(input) {
647
+ for (const key of PREVIEW_KEYS) {
648
+ const value = input[key];
649
+ if (typeof value === "string" && value.trim() !== "") return value;
650
+ }
651
+ }
652
+ function requestKind(request) {
653
+ if (request.toolName === "ExitPlanMode") return "plan";
654
+ return request.toolName === "AskUserQuestion" && parseUserQuestions(request.input).length > 0 ? "question" : "permission";
655
+ }
656
+ function choicesFor(request, kind) {
657
+ if (kind !== "question") return [];
658
+ const questions = parseUserQuestions(request.input);
659
+ const [question] = questions;
660
+ if (questions.length !== 1 || question === void 0 || question.multiSelect === true || question.options.length > LIMIT.choices) return [];
661
+ return question.options.map((option, index) => ({
662
+ index,
663
+ label: clamp(option.label, LIMIT.choiceLabel)
664
+ }));
665
+ }
666
+ function phaseFor(info, request) {
667
+ switch (info.status) {
668
+ case "awaiting_approval": {
669
+ if (request === void 0) return "running";
670
+ const kind = requestKind(request);
671
+ return kind === "permission" ? "approval" : kind;
672
+ }
673
+ case "parked": return "parked";
674
+ case "failed": return "failed";
675
+ case "closed": return "ended";
676
+ case "idle": return "done";
677
+ default: return "running";
678
+ }
679
+ }
680
+ function headlineFor(info, phase, request) {
681
+ if (request !== void 0 && (phase === "approval" || phase === "question" || phase === "plan")) {
682
+ if (phase === "question") {
683
+ const [question] = parseUserQuestions(request.input);
684
+ return clamp(question?.header !== void 0 && question.header !== "" ? question.header : "A question", LIMIT.headline);
300
685
  }
686
+ if (phase === "plan") return "Plan ready for review";
687
+ return clamp(request.title ?? request.displayName ?? request.toolName, LIMIT.headline);
688
+ }
689
+ if (phase === "running") {
690
+ const active = info.checklist?.find((item) => item.status === "in_progress");
691
+ return clamp(active?.text ?? "Working…", LIMIT.headline);
692
+ }
693
+ return {
694
+ done: "Turn finished",
695
+ failed: "Session failed",
696
+ parked: "Parked",
697
+ ended: "Session ended"
698
+ }[phase] ?? "";
699
+ }
700
+ function detailFor(phase, request) {
701
+ if (request === void 0) return;
702
+ if (phase === "question") {
703
+ const [question] = parseUserQuestions(request.input);
704
+ return question === void 0 ? void 0 : clamp(question.question, LIMIT.detail);
705
+ }
706
+ if (phase === "approval" || phase === "plan") {
707
+ const preview = previewOf(request.input) ?? request.description;
708
+ return preview === void 0 ? void 0 : clamp(preview, LIMIT.detail);
709
+ }
710
+ }
711
+ function attributesFor(info, hostId) {
712
+ return {
713
+ sessionId: info.id,
714
+ ...hostId === void 0 ? {} : { hostId },
715
+ ...info.engine === void 0 ? {} : { engine: info.engine },
716
+ cwdLeaf: cwdLeaf(info.cwd)
301
717
  };
718
+ }
719
+ function projectContentState(options) {
720
+ const { info, request, startedAtMs } = options;
721
+ const phase = options.finalPhase ?? phaseFor(info, request);
722
+ const kind = request === void 0 ? void 0 : requestKind(request);
723
+ const choices = request === void 0 || kind === void 0 ? [] : choicesFor(request, kind);
724
+ const total = info.checklist?.length ?? 0;
302
725
  return {
303
- list: () => [...devices.values()],
304
- async register(record) {
305
- const existing = devices.get(record.token);
306
- devices.set(record.token, {
307
- ...record,
308
- updatedAt: Date.now()
309
- });
310
- if (existing !== void 0 && existing.environment === record.environment && existing.hostId === record.hostId) return;
311
- await persist();
312
- },
313
- async remove(token) {
314
- if (!devices.delete(token)) return;
315
- await persist();
316
- }
726
+ phase,
727
+ title: clamp(sessionLabel(info), LIMIT.title),
728
+ headline: options.finalHeadline ?? headlineFor(info, phase, request),
729
+ ...detailFor(phase, request) === void 0 ? {} : { detail: detailFor(phase, request) },
730
+ startedAtMs,
731
+ ...request?.expiresAt === void 0 ? {} : { expiresAtMs: request.expiresAt },
732
+ pendingCount: info.pendingPermissionCount,
733
+ ...total === 0 ? {} : { steps: {
734
+ done: info.checklist.filter((item) => item.status === "completed").length,
735
+ total
736
+ } },
737
+ ...request === void 0 || kind === void 0 ? {} : { request: {
738
+ id: request.id,
739
+ kind,
740
+ choices,
741
+ ...choices.length === 0 ? {} : { inputJSON: JSON.stringify(request.input) }
742
+ } },
743
+ ...info.epoch === void 0 ? {} : { epoch: info.epoch },
744
+ seq: info.lastSeq,
745
+ decision: null
317
746
  };
318
747
  }
319
- function createDeviceRoute(registry, authenticate) {
320
- return async (req, res) => {
321
- let pathname;
322
- try {
323
- pathname = new URL(req.url ?? "/", "http://internal").pathname;
324
- } catch {
325
- return false;
326
- }
327
- if (pathname !== "/apns/devices") return false;
328
- if (req.method !== "POST" && req.method !== "DELETE") {
329
- res.writeHead(405, { allow: "POST, DELETE" }).end();
330
- return true;
331
- }
332
- if (authenticate(req) === null) {
333
- respondJson(res, 401, { error: "unauthorized" });
334
- return true;
335
- }
336
- const raw = await readBody(req, MAX_BODY_BYTES);
337
- if (raw === null) {
338
- respondJson(res, 413, { error: "body too large" });
339
- res.once("finish", () => req.destroy());
340
- return true;
341
- }
342
- let body;
343
- try {
344
- body = JSON.parse(raw);
345
- } catch {
346
- respondJson(res, 400, { error: "invalid JSON body" });
347
- return true;
348
- }
349
- const token = body.token;
350
- if (typeof token !== "string" || !TOKEN_PATTERN.test(token)) {
351
- respondJson(res, 400, { error: "token must be a hex APNs device token" });
352
- return true;
353
- }
354
- if (req.method === "DELETE") {
355
- await registry.remove(token);
356
- res.writeHead(204).end();
357
- return true;
358
- }
359
- if (!isEnvironment(body.environment)) {
360
- respondJson(res, 400, { error: "environment must be 'development' or 'production'" });
361
- return true;
748
+ function contentHash(state) {
749
+ const { seq: _seq, epoch: _epoch, decision: _decision, ...drawn } = state;
750
+ return createHash("sha256").update(JSON.stringify(drawn)).digest("base64url").slice(0, 22);
751
+ }
752
+ function shrink(state) {
753
+ if (state.request?.inputJSON !== void 0) return {
754
+ ...state,
755
+ request: {
756
+ ...state.request,
757
+ choices: [],
758
+ inputJSON: void 0
362
759
  }
363
- const optionalString = (value) => typeof value === "string" && value.length > 0 && value.length <= 200 ? value : void 0;
364
- await registry.register({
365
- token,
366
- environment: body.environment,
367
- hostId: optionalString(body.hostId),
368
- bundleId: optionalString(body.bundleId),
369
- platform: optionalString(body.platform)
370
- });
371
- respondJson(res, 200, {
372
- registered: true,
373
- environment: body.environment
374
- });
375
- return true;
760
+ };
761
+ if (state.detail !== void 0 && state.detail.length > 24) return {
762
+ ...state,
763
+ detail: clamp(state.detail, Math.floor(state.detail.length / 2))
764
+ };
765
+ if (state.detail !== void 0) return {
766
+ ...state,
767
+ detail: void 0
768
+ };
769
+ if (state.headline.length > 24) return {
770
+ ...state,
771
+ headline: clamp(state.headline, Math.floor(state.headline.length / 2))
772
+ };
773
+ }
774
+ function buildLiveActivityPush(kind, options) {
775
+ const now = options.now ?? Date.now();
776
+ const seconds = Math.floor(now / 1e3);
777
+ let state = options.state;
778
+ const build = (content) => ({ aps: {
779
+ timestamp: seconds,
780
+ event: kind,
781
+ ...kind === "start" ? {
782
+ "attributes-type": "SessionActivityAttributes",
783
+ attributes: options.attributes,
784
+ alert: {
785
+ title: content.title,
786
+ body: content.phase === "running" ? "working" : "approval needed"
787
+ },
788
+ "input-push-token": 1
789
+ } : {},
790
+ "content-state": content,
791
+ ...kind === "end" ? { "dismissal-date": Math.floor((now + DISMISSAL_MS) / 1e3) } : { "stale-date": Math.floor((now + STALE_MS) / 1e3) },
792
+ "relevance-score": RELEVANCE[content.phase]
793
+ } });
794
+ let payload = build(state);
795
+ while (Buffer.byteLength(JSON.stringify(payload)) > MAX_PAYLOAD_BYTES$1) {
796
+ const smaller = shrink(state);
797
+ if (smaller === void 0) break;
798
+ state = smaller;
799
+ payload = build(state);
800
+ }
801
+ return {
802
+ payload,
803
+ pushType: "liveactivity",
804
+ priority: kind === "update" && options.urgent !== true ? 5 : 10,
805
+ expiration: Math.floor((now + (kind === "end" ? END_EXPIRATION_MS : EXPIRATION_MS)) / 1e3)
376
806
  };
377
807
  }
378
808
  //#endregion
@@ -396,6 +826,12 @@ function oneLine(text, limit = BODY_LIMIT) {
396
826
  function collapseKey(sessionId) {
397
827
  return createHash("sha256").update(sessionId).digest("base64url").slice(0, 32);
398
828
  }
829
+ const COLLAPSE_PREFIX = {
830
+ permission_requested: "p",
831
+ turn_completed: "t",
832
+ session_error: "e",
833
+ session_closed: "c"
834
+ };
399
835
  function titleFor(notification, name) {
400
836
  switch (notification.type) {
401
837
  case "permission_requested": return `Approval needed — ${name}`;
@@ -432,6 +868,7 @@ function buildPush(notification, hostId) {
432
868
  type: notification.type,
433
869
  sessionId: notification.sessionId,
434
870
  seq: notification.seq,
871
+ ...notification.session.epoch === void 0 ? {} : { epoch: notification.session.epoch },
435
872
  ...hostId === void 0 ? {} : { hostId },
436
873
  ...notification.request === void 0 ? {} : { requestId: notification.request.id }
437
874
  });
@@ -444,7 +881,7 @@ function buildPush(notification, hostId) {
444
881
  payload,
445
882
  priority: permission || notification.type === "session_error" ? 10 : 5,
446
883
  expiration: permission && notification.request?.expiresAt !== void 0 ? Math.floor(notification.request.expiresAt / 1e3) : Math.floor(Date.now() / 1e3) + 3600,
447
- ...notification.type === "turn_completed" ? { collapseId: `t:${collapseKey(notification.sessionId)}` } : {}
884
+ collapseId: `${COLLAPSE_PREFIX[notification.type]}:${collapseKey(notification.sessionId)}`
448
885
  };
449
886
  }
450
887
  async function createApnsForwarder(options) {
@@ -455,11 +892,18 @@ async function createApnsForwarder(options) {
455
892
  dir: options.stateDir,
456
893
  onError: (error, context) => warn(`device registry ${context.op} failed for ${context.path}: ${error instanceof Error ? error.message : String(error)}`)
457
894
  });
458
- const handleRequest = createDeviceRoute(registry, options.authenticate);
895
+ const activities = await createActivityRegistry({
896
+ dir: options.stateDir,
897
+ onError: (error, context) => warn(`activity registry ${context.op} failed for ${context.path}: ${error instanceof Error ? error.message : String(error)}`)
898
+ });
899
+ const handleRequest = createApnsRoute({
900
+ devices: registry,
901
+ activities
902
+ }, options.authenticate);
459
903
  const fallbackEnvironment = options.config.production === false ? "development" : "production";
460
904
  const chains = /* @__PURE__ */ new Map();
461
905
  const deliver = async (notification) => {
462
- const devices = registry.list();
906
+ const devices = registry.list().filter((device) => wantsNotification(device, notification.type));
463
907
  if (devices.length === 0) return;
464
908
  await Promise.all(devices.map(async (device) => {
465
909
  const push = buildPush(notification, device.hostId);
@@ -476,7 +920,110 @@ async function createApnsForwarder(options) {
476
920
  warn(`push to ${device.token.slice(0, 8)}… failed: ${result.reason} (${result.status})`);
477
921
  }));
478
922
  };
923
+ const sendActivity = async (kind, target, attributes, state, urgent) => {
924
+ const push = buildLiveActivityPush(kind, {
925
+ attributes,
926
+ state,
927
+ urgent
928
+ });
929
+ const result = await client.send({
930
+ ...push,
931
+ deviceToken: target.token,
932
+ environment: target.environment
933
+ });
934
+ if (result.ok) {
935
+ await activities.touch(target.deviceToken, target.sessionId, { lastPushAt: Date.now() });
936
+ return;
937
+ }
938
+ if (result.unregistered) {
939
+ if (kind === "start") await registry.clearStartToken(target.deviceToken);
940
+ await activities.remove(target.deviceToken, target.sessionId);
941
+ return;
942
+ }
943
+ warn(`live activity ${kind} for ${target.sessionId} failed: ${result.reason} (${result.status})`);
944
+ };
945
+ const startCards = async (attributes, state) => {
946
+ await Promise.all(registry.list().map(async (device) => {
947
+ if (device.liveActivityStartToken === void 0) return;
948
+ if (activities.get(device.token, attributes.sessionId) !== void 0) return;
949
+ const environment = device.environment ?? fallbackEnvironment;
950
+ await activities.start({
951
+ deviceToken: device.token,
952
+ sessionId: attributes.sessionId,
953
+ hostId: device.hostId,
954
+ environment
955
+ });
956
+ await sendActivity("start", {
957
+ deviceToken: device.token,
958
+ token: device.liveActivityStartToken,
959
+ environment,
960
+ sessionId: attributes.sessionId
961
+ }, {
962
+ ...attributes,
963
+ hostId: device.hostId
964
+ }, state, true);
965
+ }));
966
+ };
967
+ const pushCards = async (kind, sessionId, state, urgent) => {
968
+ await Promise.all(activities.forSession(sessionId).map(async (record) => {
969
+ if (record.updateToken === void 0) return;
970
+ await sendActivity(kind, {
971
+ deviceToken: record.deviceToken,
972
+ token: record.updateToken,
973
+ environment: record.environment,
974
+ sessionId
975
+ }, {
976
+ sessionId,
977
+ hostId: record.hostId,
978
+ cwdLeaf: ""
979
+ }, state, urgent);
980
+ if (kind === "end") await activities.remove(record.deviceToken, sessionId);
981
+ }));
982
+ };
983
+ const queueActivity = (sessionId, run) => {
984
+ const next = (chains.get(sessionId) ?? Promise.resolve()).then(() => run().catch((error) => {
985
+ warn(error instanceof Error ? error.message : String(error));
986
+ }));
987
+ chains.set(sessionId, next);
988
+ next.then(() => {
989
+ if (chains.get(sessionId) === next) chains.delete(sessionId);
990
+ });
991
+ };
479
992
  return {
993
+ activity: {
994
+ start(attributes, state) {
995
+ queueActivity(attributes.sessionId, () => startCards(attributes, state));
996
+ },
997
+ update(sessionId, state, { urgent }) {
998
+ queueActivity(sessionId, () => pushCards("update", sessionId, state, urgent));
999
+ },
1000
+ end(sessionId, state) {
1001
+ queueActivity(sessionId, () => pushCards("end", sessionId, state, true));
1002
+ },
1003
+ async endAll(headline) {
1004
+ await Promise.all(activities.list().map(async (record) => {
1005
+ if (record.updateToken !== void 0) await sendActivity("end", {
1006
+ deviceToken: record.deviceToken,
1007
+ token: record.updateToken,
1008
+ environment: record.environment,
1009
+ sessionId: record.sessionId
1010
+ }, {
1011
+ sessionId: record.sessionId,
1012
+ hostId: record.hostId,
1013
+ cwdLeaf: ""
1014
+ }, {
1015
+ phase: "ended",
1016
+ title: record.sessionId,
1017
+ headline,
1018
+ startedAtMs: record.startedAt,
1019
+ pendingCount: 0,
1020
+ decision: null
1021
+ }, true);
1022
+ await activities.remove(record.deviceToken, record.sessionId);
1023
+ }));
1024
+ },
1025
+ count: () => activities.list().length
1026
+ },
480
1027
  onNotification(notification) {
481
1028
  const next = (chains.get(notification.sessionId) ?? Promise.resolve()).then(() => deliver(notification).catch((error) => {
482
1029
  warn(error instanceof Error ? error.message : String(error));
@@ -492,6 +1039,181 @@ async function createApnsForwarder(options) {
492
1039
  };
493
1040
  }
494
1041
  //#endregion
1042
+ //#region src/apns/live-activities.ts
1043
+ const START_DEBOUNCE_MS = 2e3;
1044
+ const END_GRACE_MS = 2e4;
1045
+ const FLUSH_DEBOUNCE_MS = 5e3;
1046
+ const MIN_UPDATE_INTERVAL_MS = 3e4;
1047
+ const HEARTBEAT_MS = 6e5;
1048
+ const RENEW_MS = 279e5;
1049
+ const ENGAGED = /* @__PURE__ */ new Set(["running", "awaiting_approval"]);
1050
+ const RELEVANT = /* @__PURE__ */ new Set([
1051
+ "status_changed",
1052
+ "permission_requested",
1053
+ "permission_resolved",
1054
+ "checklist",
1055
+ "turn_result",
1056
+ "session_error",
1057
+ "session_closed"
1058
+ ]);
1059
+ function finalPhaseFor(status) {
1060
+ switch (status) {
1061
+ case "parked": return "parked";
1062
+ case "failed": return "failed";
1063
+ case "closed": return "ended";
1064
+ default: return "done";
1065
+ }
1066
+ }
1067
+ function isWaiting(phase) {
1068
+ return phase === "approval" || phase === "question" || phase === "plan";
1069
+ }
1070
+ function driveLiveActivities(options) {
1071
+ const sessions = /* @__PURE__ */ new Map();
1072
+ const unsubscribes = /* @__PURE__ */ new Set();
1073
+ const clear = (tracked, name) => {
1074
+ const handle = tracked.timers.get(name);
1075
+ if (handle !== void 0) {
1076
+ clearTimeout(handle);
1077
+ tracked.timers.delete(name);
1078
+ }
1079
+ };
1080
+ const arm = (tracked, name, delay, run) => {
1081
+ clear(tracked, name);
1082
+ tracked.timers.set(name, setTimeout(() => {
1083
+ tracked.timers.delete(name);
1084
+ run();
1085
+ }, delay));
1086
+ };
1087
+ const forget = (sessionId) => {
1088
+ const tracked = sessions.get(sessionId);
1089
+ if (tracked === void 0) return;
1090
+ for (const name of tracked.timers.keys()) clear(tracked, name);
1091
+ sessions.delete(sessionId);
1092
+ };
1093
+ const send = (runner, tracked, state, urgent) => {
1094
+ const hash = contentHash(state);
1095
+ if (hash === tracked.lastHash) return;
1096
+ tracked.lastHash = hash;
1097
+ tracked.lastSentAt = Date.now();
1098
+ tracked.pending = void 0;
1099
+ clear(tracked, "flush");
1100
+ tracked.attributes = attributesFor(runner.info(), options.hostIdOf?.());
1101
+ if (!tracked.started) {
1102
+ tracked.started = true;
1103
+ options.target.start(tracked.attributes, state);
1104
+ } else options.target.update(runner.id, state, { urgent });
1105
+ arm(tracked, "heartbeat", HEARTBEAT_MS, () => {
1106
+ const current = sessions.get(runner.id);
1107
+ if (current !== void 0 && current.started) {
1108
+ options.target.update(runner.id, {
1109
+ ...state,
1110
+ seq: runner.info().lastSeq
1111
+ }, { urgent: false });
1112
+ current.lastSentAt = Date.now();
1113
+ }
1114
+ });
1115
+ };
1116
+ const finish = (runner, tracked, phase) => {
1117
+ if (tracked.started) options.target.end(runner.id, projectContentState({
1118
+ info: runner.info(),
1119
+ startedAtMs: tracked.startedAtMs,
1120
+ finalPhase: phase
1121
+ }));
1122
+ forget(runner.id);
1123
+ };
1124
+ const evaluate = (runner, immediate = false) => {
1125
+ const info = runner.info();
1126
+ const request = runner.pendingApprovals[0];
1127
+ const now = Date.now();
1128
+ if (!ENGAGED.has(info.status)) {
1129
+ const tracked = sessions.get(info.id);
1130
+ if (tracked === void 0) return;
1131
+ clear(tracked, "start");
1132
+ if (info.status === "idle") {
1133
+ arm(tracked, "end", END_GRACE_MS, () => finish(runner, tracked, "done"));
1134
+ return;
1135
+ }
1136
+ finish(runner, tracked, finalPhaseFor(info.status));
1137
+ return;
1138
+ }
1139
+ let tracked = sessions.get(info.id);
1140
+ if (tracked === void 0) {
1141
+ tracked = {
1142
+ attributes: attributesFor(info, options.hostIdOf?.()),
1143
+ startedAtMs: now,
1144
+ started: false,
1145
+ lastSentAt: 0,
1146
+ lastWaiting: false,
1147
+ timers: /* @__PURE__ */ new Map()
1148
+ };
1149
+ sessions.set(info.id, tracked);
1150
+ }
1151
+ const current = tracked;
1152
+ clear(current, "end");
1153
+ const state = projectContentState({
1154
+ info,
1155
+ request,
1156
+ startedAtMs: current.startedAtMs
1157
+ });
1158
+ const waiting = isWaiting(state.phase);
1159
+ const urgent = waiting !== current.lastWaiting || state.request?.id !== current.lastRequestId;
1160
+ current.lastWaiting = waiting;
1161
+ current.lastRequestId = state.request?.id;
1162
+ if (!current.started && current.timers.get("start") === void 0) {
1163
+ current.pending = state;
1164
+ if (waiting || immediate) {
1165
+ send(runner, current, state, true);
1166
+ arm(current, "renew", RENEW_MS, () => renew(runner));
1167
+ } else arm(current, "start", START_DEBOUNCE_MS, () => {
1168
+ const pending = sessions.get(runner.id);
1169
+ if (pending === void 0 || !ENGAGED.has(runner.info().status)) return;
1170
+ send(runner, pending, pending.pending ?? state, true);
1171
+ arm(pending, "renew", RENEW_MS, () => renew(runner));
1172
+ });
1173
+ return;
1174
+ }
1175
+ if (!current.started) {
1176
+ current.pending = state;
1177
+ return;
1178
+ }
1179
+ if (urgent) {
1180
+ send(runner, current, state, true);
1181
+ return;
1182
+ }
1183
+ current.pending = state;
1184
+ const since = now - current.lastSentAt;
1185
+ const delay = Math.max(FLUSH_DEBOUNCE_MS, MIN_UPDATE_INTERVAL_MS - since);
1186
+ arm(current, "flush", delay, () => {
1187
+ const pending = sessions.get(runner.id);
1188
+ if (pending?.pending !== void 0) send(runner, pending, pending.pending, false);
1189
+ });
1190
+ };
1191
+ const renew = (runner) => {
1192
+ const tracked = sessions.get(runner.id);
1193
+ if (tracked === void 0 || !ENGAGED.has(runner.info().status)) return;
1194
+ finish(runner, tracked, "ended");
1195
+ evaluate(runner, true);
1196
+ };
1197
+ const stop = options.source.observe((runner) => {
1198
+ const unsubscribe = runner.subscribe((event) => {
1199
+ if (!RELEVANT.has(event.type)) return;
1200
+ evaluate(runner);
1201
+ if (event.type === "session_closed") {
1202
+ unsubscribe();
1203
+ unsubscribes.delete(unsubscribe);
1204
+ }
1205
+ }, runner.info().lastSeq);
1206
+ unsubscribes.add(unsubscribe);
1207
+ evaluate(runner);
1208
+ });
1209
+ return () => {
1210
+ stop();
1211
+ for (const unsubscribe of unsubscribes) unsubscribe();
1212
+ unsubscribes.clear();
1213
+ for (const sessionId of sessions.keys()) forget(sessionId);
1214
+ };
1215
+ }
1216
+ //#endregion
495
1217
  //#region src/auth/auth-key.ts
496
1218
  function generateKey() {
497
1219
  return randomBytes(24).toString("hex");
@@ -1486,13 +2208,6 @@ function createHostGuard(allowedHosts) {
1486
2208
  return isLoopbackHostname(hostname) || allowedHosts.has(hostname);
1487
2209
  };
1488
2210
  }
1489
- function pathnameOf(req) {
1490
- try {
1491
- return new URL(req.url ?? "/", "http://internal").pathname;
1492
- } catch {
1493
- return null;
1494
- }
1495
- }
1496
2211
  function createFallback(auth, webRoot, hostAllowed, apnsRoute) {
1497
2212
  return async (req, res) => {
1498
2213
  if (!hostAllowed(req)) {
@@ -1501,12 +2216,7 @@ function createFallback(auth, webRoot, hostAllowed, apnsRoute) {
1501
2216
  return;
1502
2217
  }
1503
2218
  if (await auth.handleAuthRequest(req, res)) return;
1504
- if (apnsRoute !== void 0 && await apnsRoute(req, res)) return;
1505
- if (apnsRoute === void 0 && pathnameOf(req) === "/apns/devices") {
1506
- res.writeHead(404, { "content-type": "text/plain; charset=utf-8" });
1507
- res.end("this gateway runs without push\n");
1508
- return;
1509
- }
2219
+ if (await apnsRoute(req, res)) return;
1510
2220
  if (webRoot === void 0) {
1511
2221
  res.writeHead(404, { "content-type": "text/plain; charset=utf-8" });
1512
2222
  res.end("the web dashboard is disabled on this gateway\n");
@@ -1564,12 +2274,13 @@ async function startInstance(config, options = {}) {
1564
2274
  if (config.allowedHosts === null && !config.hostAuthenticates && !auth.enabled) throw new Error("refusing to serve: the resolved config expects auth but no shared secret was materialized — this instance would be open while believing itself authenticated");
1565
2275
  if (config.corsOrigins.length > 0 && !config.hostAuthenticates && !auth.enabled) throw new Error("refusing to serve: corsOrigins is set but this instance has no auth — a cross-origin page could drive it with no credential. Set --auth-key.");
1566
2276
  const hostAllowed = createHostGuard(config.allowedHosts);
2277
+ const authenticateHost = (req) => hostAllowed(req) ? auth.authenticate(req) : null;
1567
2278
  const apns = config.apns === void 0 ? void 0 : await createApnsForwarder({
1568
2279
  config: config.apns,
1569
2280
  stateDir: config.stateDir,
1570
- authenticate: (req) => hostAllowed(req) ? auth.authenticate(req) : null
2281
+ authenticate: authenticateHost
1571
2282
  });
1572
- const fallback = createFallback(auth, webRoot, hostAllowed, apns?.handleRequest);
2283
+ const fallback = createFallback(auth, webRoot, hostAllowed, apns?.handleRequest ?? createApnsRoute(null, authenticateHost));
1573
2284
  const parking = { ...config.options.parking };
1574
2285
  if (config.stateDir && !parking.store) parking.store = createFileSessionStore({
1575
2286
  dir: join(config.stateDir, "parked"),
@@ -1601,6 +2312,14 @@ async function startInstance(config, options = {}) {
1601
2312
  sweep = setInterval(() => wake.set(sessionsNeedTheMachine(server.registry.list())), WAKE_SWEEP_MS);
1602
2313
  sweep.unref();
1603
2314
  }
2315
+ let stopActivities;
2316
+ if (apns) {
2317
+ await apns.activity.endAll("Gateway restarted");
2318
+ stopActivities = driveLiveActivities({
2319
+ source: server.registry,
2320
+ target: apns.activity
2321
+ });
2322
+ }
1604
2323
  const { port } = await server.listen(config.port, config.host);
1605
2324
  const displayHost = config.host === "0.0.0.0" || config.host === "::" ? "localhost" : config.host;
1606
2325
  const url = `http://${displayHost.includes(":") ? `[${displayHost}]` : displayHost}:${port}`;
@@ -1628,7 +2347,8 @@ async function startInstance(config, options = {}) {
1628
2347
  line(config.stateDir ? ` parked sessions persist in ${join(config.stateDir, "parked")}` : " parked sessions are in memory only — a restart drops them");
1629
2348
  if (apns) {
1630
2349
  const count = apns.deviceCount();
1631
- line(` push: APNs forwarder on ${config.apns?.topic} — ${count === 0 ? "no devices registered yet" : `${count} device(s)`}`);
2350
+ const cards = apns.activity.count();
2351
+ line(` push: APNs forwarder on ${config.apns?.topic} — ${count === 0 ? "no devices registered yet" : `${count} device(s)`}${cards === 0 ? "" : `, ${cards} live card(s)`}`);
1632
2352
  }
1633
2353
  if (config.configPath) line(` config ${config.configPath}`);
1634
2354
  line("");
@@ -1642,14 +2362,16 @@ async function startInstance(config, options = {}) {
1642
2362
  close: async () => {
1643
2363
  if (sweep) clearInterval(sweep);
1644
2364
  wake?.release();
2365
+ stopActivities?.();
1645
2366
  await server.close();
1646
2367
  await sessions?.flush?.();
2368
+ if (apns) await Promise.race([apns.activity.endAll("Gateway stopped"), new Promise((resolve) => setTimeout(resolve, 3e3))]);
1647
2369
  apns?.close();
1648
2370
  resolveClosed();
1649
2371
  }
1650
2372
  };
1651
2373
  }
1652
2374
  //#endregion
1653
- export { createApnsForwarder as _, ConfigError as a, createApnsClient as b, isLoopback as c, parseArgs as d, resolveInstanceConfig as f, buildPush as g, materializeAuthKey as h, renderLoginPage as i, isLoopbackHostname as l, createAuthSessionStore as m, resolveWebRoot as n, defaultStateDir as o, createCliAuth as p, startInstance as r, hostnameOf as s, createHostGuard as t, loadConfigFile as u, createDeviceRegistry as v, loadApnsKey as x, createDeviceRoute as y };
2375
+ export { createActivityRegistry as C, createDeviceRoute as S, createApnsForwarder as _, ConfigError as a, createApnsRoute as b, isLoopback as c, parseArgs as d, resolveInstanceConfig as f, buildPush as g, materializeAuthKey as h, renderLoginPage as i, isLoopbackHostname as l, createAuthSessionStore as m, resolveWebRoot as n, defaultStateDir as o, createCliAuth as p, startInstance as r, hostnameOf as s, createHostGuard as t, loadConfigFile as u, createApnsClient as v, createActivityRoute as w, createDeviceRegistry as x, loadApnsKey as y };
1654
2376
 
1655
- //# sourceMappingURL=instance-DeDxfiBR.mjs.map
2377
+ //# sourceMappingURL=instance-DqbdaKrO.mjs.map