workerdeck 2.2.1 → 2.3.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,342 @@ 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 FILENAME = "apns-devices.json";
209
+ const TOKEN_PATTERN = /^[0-9a-fA-F]{32,200}$/;
210
+ const MAX_BODY_BYTES = 4096;
211
+ function isEnvironment(value) {
212
+ return value === "development" || value === "production";
213
+ }
214
+ async function createDeviceRegistry(options) {
215
+ const path = options.dir === null ? null : join(options.dir, FILENAME);
216
+ const devices = /* @__PURE__ */ new Map();
217
+ if (path !== null) try {
218
+ const parsed = JSON.parse(await readFile(path, "utf8"));
219
+ for (const record of parsed.devices ?? []) if (typeof record?.token === "string" && isEnvironment(record.environment)) devices.set(record.token, record);
220
+ } catch {}
221
+ const persist = async () => {
222
+ if (path === null) return;
223
+ try {
224
+ await mkdir(options.dir, {
225
+ recursive: true,
226
+ mode: 448
227
+ });
228
+ await writeFile(path, `${JSON.stringify({ devices: [...devices.values()] }, null, 2)}\n`, { mode: 384 });
229
+ await chmod(path, 384);
230
+ } catch (error) {
231
+ options.onError?.(error, {
232
+ op: "write",
233
+ path
234
+ });
235
+ }
236
+ };
237
+ return {
238
+ list: () => [...devices.values()],
239
+ async register(record) {
240
+ const existing = devices.get(record.token);
241
+ devices.set(record.token, {
242
+ ...record,
243
+ updatedAt: Date.now()
244
+ });
245
+ if (existing !== void 0 && existing.environment === record.environment && existing.hostId === record.hostId && existing.liveActivityStartToken === record.liveActivityStartToken) return;
246
+ await persist();
247
+ },
248
+ async clearStartToken(token) {
249
+ const existing = devices.get(token);
250
+ if (existing === void 0 || existing.liveActivityStartToken === void 0) return;
251
+ delete existing.liveActivityStartToken;
252
+ await persist();
253
+ },
254
+ async remove(token) {
255
+ if (!devices.delete(token)) return;
256
+ await persist();
257
+ }
258
+ };
259
+ }
260
+ function createDeviceRoute(registry, authenticate) {
261
+ return async (req, res) => {
262
+ let pathname;
263
+ try {
264
+ pathname = new URL(req.url ?? "/", "http://internal").pathname;
265
+ } catch {
266
+ return false;
267
+ }
268
+ if (pathname !== "/apns/devices") return false;
269
+ if (registry === null) {
270
+ res.writeHead(404, { "content-type": "text/plain; charset=utf-8" });
271
+ res.end("this gateway runs without push\n");
272
+ return true;
273
+ }
274
+ if (req.method !== "POST" && req.method !== "DELETE") {
275
+ res.writeHead(405, { allow: "POST, DELETE" }).end();
276
+ return true;
277
+ }
278
+ if (authenticate(req) === null) {
279
+ respondJson(res, 401, { error: "unauthorized" });
280
+ return true;
281
+ }
282
+ const raw = await readBody(req, MAX_BODY_BYTES);
283
+ if (raw === null) {
284
+ respondJson(res, 413, { error: "body too large" });
285
+ res.once("finish", () => req.destroy());
286
+ return true;
287
+ }
288
+ let body;
289
+ try {
290
+ body = JSON.parse(raw);
291
+ } catch {
292
+ respondJson(res, 400, { error: "invalid JSON body" });
293
+ return true;
294
+ }
295
+ const token = body.token;
296
+ if (typeof token !== "string" || !TOKEN_PATTERN.test(token)) {
297
+ respondJson(res, 400, { error: "token must be a hex APNs device token" });
298
+ return true;
299
+ }
300
+ if (req.method === "DELETE") {
301
+ await registry.remove(token);
302
+ res.writeHead(204).end();
303
+ return true;
304
+ }
305
+ if (!isEnvironment(body.environment)) {
306
+ respondJson(res, 400, { error: "environment must be 'development' or 'production'" });
307
+ return true;
308
+ }
309
+ const optionalString = (value) => typeof value === "string" && value.length > 0 && value.length <= 200 ? value : void 0;
310
+ const startTokenGiven = Object.hasOwn(body, "liveActivityStartToken");
311
+ const startToken = body.liveActivityStartToken;
312
+ if (startTokenGiven && startToken !== null && (typeof startToken !== "string" || !TOKEN_PATTERN.test(startToken))) {
313
+ respondJson(res, 400, { error: "liveActivityStartToken must be a hex token or null" });
314
+ return true;
315
+ }
316
+ const previous = registry.list().find((record) => record.token === token);
317
+ const liveActivityStartToken = startTokenGiven ? startToken ?? void 0 : previous?.liveActivityStartToken;
318
+ await registry.register({
319
+ token,
320
+ environment: body.environment,
321
+ hostId: optionalString(body.hostId),
322
+ bundleId: optionalString(body.bundleId),
323
+ platform: optionalString(body.platform),
324
+ ...liveActivityStartToken === void 0 ? {} : { liveActivityStartToken }
325
+ });
326
+ respondJson(res, 200, {
327
+ registered: true,
328
+ environment: body.environment
329
+ });
330
+ return true;
331
+ };
332
+ }
333
+ //#endregion
334
+ //#region src/apns/routes.ts
335
+ function createApnsRoute(registries, authenticate) {
336
+ const device = createDeviceRoute(registries?.devices ?? null, authenticate);
337
+ const activity = createActivityRoute(registries?.activities ?? null, authenticate);
338
+ return async (req, res) => await device(req, res) || activity(req, res);
339
+ }
340
+ //#endregion
11
341
  //#region src/apns/client.ts
12
342
  const HOSTS = {
13
343
  development: "https://api.sandbox.push.apple.com",
@@ -16,6 +346,9 @@ const HOSTS = {
16
346
  const TOKEN_TTL_MS = 24e5;
17
347
  const REQUEST_TIMEOUT_MS = 1e4;
18
348
  const DIAL_ATTEMPT_TIMEOUT_MS = 2e3;
349
+ function topicFor(topic, pushType) {
350
+ return pushType === "liveactivity" ? `${topic}.push-type.liveactivity` : topic;
351
+ }
19
352
  function base64url(input) {
20
353
  return Buffer.from(input).toString("base64url");
21
354
  }
@@ -119,8 +452,8 @@ function createApnsClient(config, key, options = {}) {
119
452
  ":method": "POST",
120
453
  ":path": `/3/device/${request.deviceToken}`,
121
454
  authorization: `bearer ${jwt}`,
122
- "apns-topic": config.topic,
123
- "apns-push-type": "alert",
455
+ "apns-topic": topicFor(config.topic, request.pushType),
456
+ "apns-push-type": request.pushType ?? "alert",
124
457
  "apns-priority": String(request.priority ?? 10),
125
458
  "apns-expiration": String(request.expiration ?? 0),
126
459
  "content-length": String(body.byteLength),
@@ -237,142 +570,211 @@ function createApnsClient(config, key, options = {}) {
237
570
  };
238
571
  }
239
572
  //#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
- });
573
+ //#region src/apns/live-activity.ts
574
+ const MAX_PAYLOAD_BYTES$1 = 3800;
575
+ const LIMIT = {
576
+ title: 60,
577
+ headline: 120,
578
+ detail: 200,
579
+ choiceLabel: 40,
580
+ choices: 4
581
+ };
582
+ const STALE_MS = 12e5;
583
+ const DISMISSAL_MS = 9e5;
584
+ const EXPIRATION_MS = 6e5;
585
+ const END_EXPIRATION_MS = 144e5;
586
+ const RELEVANCE = {
587
+ approval: 100,
588
+ question: 100,
589
+ plan: 100,
590
+ running: 50,
591
+ done: 10,
592
+ failed: 10,
593
+ parked: 10,
594
+ ended: 10
595
+ };
596
+ const PREVIEW_KEYS = [
597
+ "command",
598
+ "file_path",
599
+ "path",
600
+ "pattern",
601
+ "url",
602
+ "notebook_path",
603
+ "prompt",
604
+ "description"
605
+ ];
606
+ function clamp(text, limit) {
607
+ const flat = text.replace(/\s+/g, " ").trim();
608
+ return flat.length <= limit ? flat : `${flat.slice(0, limit - 1)}…`;
263
609
  }
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));
610
+ function sessionLabel(info) {
611
+ const title = info.title?.trim();
612
+ if (title !== void 0 && title !== "") return title;
613
+ return cwdLeaf(info.cwd) || info.id;
270
614
  }
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";
615
+ function cwdLeaf(cwd) {
616
+ return cwd.split("/").filter(Boolean).at(-1) ?? "";
278
617
  }
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
- });
618
+ function previewOf(input) {
619
+ for (const key of PREVIEW_KEYS) {
620
+ const value = input[key];
621
+ if (typeof value === "string" && value.trim() !== "") return value;
622
+ }
623
+ }
624
+ function requestKind(request) {
625
+ if (request.toolName === "ExitPlanMode") return "plan";
626
+ return request.toolName === "AskUserQuestion" && parseUserQuestions(request.input).length > 0 ? "question" : "permission";
627
+ }
628
+ function choicesFor(request, kind) {
629
+ if (kind !== "question") return [];
630
+ const questions = parseUserQuestions(request.input);
631
+ const [question] = questions;
632
+ if (questions.length !== 1 || question === void 0 || question.multiSelect === true || question.options.length > LIMIT.choices) return [];
633
+ return question.options.map((option, index) => ({
634
+ index,
635
+ label: clamp(option.label, LIMIT.choiceLabel)
636
+ }));
637
+ }
638
+ function phaseFor(info, request) {
639
+ switch (info.status) {
640
+ case "awaiting_approval": {
641
+ if (request === void 0) return "running";
642
+ const kind = requestKind(request);
643
+ return kind === "permission" ? "approval" : kind;
644
+ }
645
+ case "parked": return "parked";
646
+ case "failed": return "failed";
647
+ case "closed": return "ended";
648
+ case "idle": return "done";
649
+ default: return "running";
650
+ }
651
+ }
652
+ function headlineFor(info, phase, request) {
653
+ if (request !== void 0 && (phase === "approval" || phase === "question" || phase === "plan")) {
654
+ if (phase === "question") {
655
+ const [question] = parseUserQuestions(request.input);
656
+ return clamp(question?.header !== void 0 && question.header !== "" ? question.header : "A question", LIMIT.headline);
300
657
  }
658
+ if (phase === "plan") return "Plan ready for review";
659
+ return clamp(request.title ?? request.displayName ?? request.toolName, LIMIT.headline);
660
+ }
661
+ if (phase === "running") {
662
+ const active = info.checklist?.find((item) => item.status === "in_progress");
663
+ return clamp(active?.text ?? "Working…", LIMIT.headline);
664
+ }
665
+ return {
666
+ done: "Turn finished",
667
+ failed: "Session failed",
668
+ parked: "Parked",
669
+ ended: "Session ended"
670
+ }[phase] ?? "";
671
+ }
672
+ function detailFor(phase, request) {
673
+ if (request === void 0) return;
674
+ if (phase === "question") {
675
+ const [question] = parseUserQuestions(request.input);
676
+ return question === void 0 ? void 0 : clamp(question.question, LIMIT.detail);
677
+ }
678
+ if (phase === "approval" || phase === "plan") {
679
+ const preview = previewOf(request.input) ?? request.description;
680
+ return preview === void 0 ? void 0 : clamp(preview, LIMIT.detail);
681
+ }
682
+ }
683
+ function attributesFor(info, hostId) {
684
+ return {
685
+ sessionId: info.id,
686
+ ...hostId === void 0 ? {} : { hostId },
687
+ ...info.engine === void 0 ? {} : { engine: info.engine },
688
+ cwdLeaf: cwdLeaf(info.cwd)
301
689
  };
690
+ }
691
+ function projectContentState(options) {
692
+ const { info, request, startedAtMs } = options;
693
+ const phase = options.finalPhase ?? phaseFor(info, request);
694
+ const kind = request === void 0 ? void 0 : requestKind(request);
695
+ const choices = request === void 0 || kind === void 0 ? [] : choicesFor(request, kind);
696
+ const total = info.checklist?.length ?? 0;
302
697
  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
- }
698
+ phase,
699
+ title: clamp(sessionLabel(info), LIMIT.title),
700
+ headline: options.finalHeadline ?? headlineFor(info, phase, request),
701
+ ...detailFor(phase, request) === void 0 ? {} : { detail: detailFor(phase, request) },
702
+ startedAtMs,
703
+ ...request?.expiresAt === void 0 ? {} : { expiresAtMs: request.expiresAt },
704
+ pendingCount: info.pendingPermissionCount,
705
+ ...total === 0 ? {} : { steps: {
706
+ done: info.checklist.filter((item) => item.status === "completed").length,
707
+ total
708
+ } },
709
+ ...request === void 0 || kind === void 0 ? {} : { request: {
710
+ id: request.id,
711
+ kind,
712
+ choices,
713
+ ...choices.length === 0 ? {} : { inputJSON: JSON.stringify(request.input) }
714
+ } },
715
+ ...info.epoch === void 0 ? {} : { epoch: info.epoch },
716
+ seq: info.lastSeq,
717
+ decision: null
317
718
  };
318
719
  }
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;
720
+ function contentHash(state) {
721
+ const { seq: _seq, epoch: _epoch, decision: _decision, ...drawn } = state;
722
+ return createHash("sha256").update(JSON.stringify(drawn)).digest("base64url").slice(0, 22);
723
+ }
724
+ function shrink(state) {
725
+ if (state.request?.inputJSON !== void 0) return {
726
+ ...state,
727
+ request: {
728
+ ...state.request,
729
+ choices: [],
730
+ inputJSON: void 0
362
731
  }
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;
732
+ };
733
+ if (state.detail !== void 0 && state.detail.length > 24) return {
734
+ ...state,
735
+ detail: clamp(state.detail, Math.floor(state.detail.length / 2))
736
+ };
737
+ if (state.detail !== void 0) return {
738
+ ...state,
739
+ detail: void 0
740
+ };
741
+ if (state.headline.length > 24) return {
742
+ ...state,
743
+ headline: clamp(state.headline, Math.floor(state.headline.length / 2))
744
+ };
745
+ }
746
+ function buildLiveActivityPush(kind, options) {
747
+ const now = options.now ?? Date.now();
748
+ const seconds = Math.floor(now / 1e3);
749
+ let state = options.state;
750
+ const build = (content) => ({ aps: {
751
+ timestamp: seconds,
752
+ event: kind,
753
+ ...kind === "start" ? {
754
+ "attributes-type": "SessionActivityAttributes",
755
+ attributes: options.attributes,
756
+ alert: {
757
+ title: content.title,
758
+ body: content.phase === "running" ? "working" : "approval needed"
759
+ },
760
+ "input-push-token": 1
761
+ } : {},
762
+ "content-state": content,
763
+ ...kind === "end" ? { "dismissal-date": Math.floor((now + DISMISSAL_MS) / 1e3) } : { "stale-date": Math.floor((now + STALE_MS) / 1e3) },
764
+ "relevance-score": RELEVANCE[content.phase]
765
+ } });
766
+ let payload = build(state);
767
+ while (Buffer.byteLength(JSON.stringify(payload)) > MAX_PAYLOAD_BYTES$1) {
768
+ const smaller = shrink(state);
769
+ if (smaller === void 0) break;
770
+ state = smaller;
771
+ payload = build(state);
772
+ }
773
+ return {
774
+ payload,
775
+ pushType: "liveactivity",
776
+ priority: kind === "update" && options.urgent !== true ? 5 : 10,
777
+ expiration: Math.floor((now + (kind === "end" ? END_EXPIRATION_MS : EXPIRATION_MS)) / 1e3)
376
778
  };
377
779
  }
378
780
  //#endregion
@@ -432,6 +834,7 @@ function buildPush(notification, hostId) {
432
834
  type: notification.type,
433
835
  sessionId: notification.sessionId,
434
836
  seq: notification.seq,
837
+ ...notification.session.epoch === void 0 ? {} : { epoch: notification.session.epoch },
435
838
  ...hostId === void 0 ? {} : { hostId },
436
839
  ...notification.request === void 0 ? {} : { requestId: notification.request.id }
437
840
  });
@@ -455,7 +858,14 @@ async function createApnsForwarder(options) {
455
858
  dir: options.stateDir,
456
859
  onError: (error, context) => warn(`device registry ${context.op} failed for ${context.path}: ${error instanceof Error ? error.message : String(error)}`)
457
860
  });
458
- const handleRequest = createDeviceRoute(registry, options.authenticate);
861
+ const activities = await createActivityRegistry({
862
+ dir: options.stateDir,
863
+ onError: (error, context) => warn(`activity registry ${context.op} failed for ${context.path}: ${error instanceof Error ? error.message : String(error)}`)
864
+ });
865
+ const handleRequest = createApnsRoute({
866
+ devices: registry,
867
+ activities
868
+ }, options.authenticate);
459
869
  const fallbackEnvironment = options.config.production === false ? "development" : "production";
460
870
  const chains = /* @__PURE__ */ new Map();
461
871
  const deliver = async (notification) => {
@@ -476,7 +886,110 @@ async function createApnsForwarder(options) {
476
886
  warn(`push to ${device.token.slice(0, 8)}… failed: ${result.reason} (${result.status})`);
477
887
  }));
478
888
  };
889
+ const sendActivity = async (kind, target, attributes, state, urgent) => {
890
+ const push = buildLiveActivityPush(kind, {
891
+ attributes,
892
+ state,
893
+ urgent
894
+ });
895
+ const result = await client.send({
896
+ ...push,
897
+ deviceToken: target.token,
898
+ environment: target.environment
899
+ });
900
+ if (result.ok) {
901
+ await activities.touch(target.deviceToken, target.sessionId, { lastPushAt: Date.now() });
902
+ return;
903
+ }
904
+ if (result.unregistered) {
905
+ if (kind === "start") await registry.clearStartToken(target.deviceToken);
906
+ await activities.remove(target.deviceToken, target.sessionId);
907
+ return;
908
+ }
909
+ warn(`live activity ${kind} for ${target.sessionId} failed: ${result.reason} (${result.status})`);
910
+ };
911
+ const startCards = async (attributes, state) => {
912
+ await Promise.all(registry.list().map(async (device) => {
913
+ if (device.liveActivityStartToken === void 0) return;
914
+ if (activities.get(device.token, attributes.sessionId) !== void 0) return;
915
+ const environment = device.environment ?? fallbackEnvironment;
916
+ await activities.start({
917
+ deviceToken: device.token,
918
+ sessionId: attributes.sessionId,
919
+ hostId: device.hostId,
920
+ environment
921
+ });
922
+ await sendActivity("start", {
923
+ deviceToken: device.token,
924
+ token: device.liveActivityStartToken,
925
+ environment,
926
+ sessionId: attributes.sessionId
927
+ }, {
928
+ ...attributes,
929
+ hostId: device.hostId
930
+ }, state, true);
931
+ }));
932
+ };
933
+ const pushCards = async (kind, sessionId, state, urgent) => {
934
+ await Promise.all(activities.forSession(sessionId).map(async (record) => {
935
+ if (record.updateToken === void 0) return;
936
+ await sendActivity(kind, {
937
+ deviceToken: record.deviceToken,
938
+ token: record.updateToken,
939
+ environment: record.environment,
940
+ sessionId
941
+ }, {
942
+ sessionId,
943
+ hostId: record.hostId,
944
+ cwdLeaf: ""
945
+ }, state, urgent);
946
+ if (kind === "end") await activities.remove(record.deviceToken, sessionId);
947
+ }));
948
+ };
949
+ const queueActivity = (sessionId, run) => {
950
+ const next = (chains.get(sessionId) ?? Promise.resolve()).then(() => run().catch((error) => {
951
+ warn(error instanceof Error ? error.message : String(error));
952
+ }));
953
+ chains.set(sessionId, next);
954
+ next.then(() => {
955
+ if (chains.get(sessionId) === next) chains.delete(sessionId);
956
+ });
957
+ };
479
958
  return {
959
+ activity: {
960
+ start(attributes, state) {
961
+ queueActivity(attributes.sessionId, () => startCards(attributes, state));
962
+ },
963
+ update(sessionId, state, { urgent }) {
964
+ queueActivity(sessionId, () => pushCards("update", sessionId, state, urgent));
965
+ },
966
+ end(sessionId, state) {
967
+ queueActivity(sessionId, () => pushCards("end", sessionId, state, true));
968
+ },
969
+ async endAll(headline) {
970
+ await Promise.all(activities.list().map(async (record) => {
971
+ if (record.updateToken !== void 0) await sendActivity("end", {
972
+ deviceToken: record.deviceToken,
973
+ token: record.updateToken,
974
+ environment: record.environment,
975
+ sessionId: record.sessionId
976
+ }, {
977
+ sessionId: record.sessionId,
978
+ hostId: record.hostId,
979
+ cwdLeaf: ""
980
+ }, {
981
+ phase: "ended",
982
+ title: record.sessionId,
983
+ headline,
984
+ startedAtMs: record.startedAt,
985
+ pendingCount: 0,
986
+ decision: null
987
+ }, true);
988
+ await activities.remove(record.deviceToken, record.sessionId);
989
+ }));
990
+ },
991
+ count: () => activities.list().length
992
+ },
480
993
  onNotification(notification) {
481
994
  const next = (chains.get(notification.sessionId) ?? Promise.resolve()).then(() => deliver(notification).catch((error) => {
482
995
  warn(error instanceof Error ? error.message : String(error));
@@ -492,6 +1005,181 @@ async function createApnsForwarder(options) {
492
1005
  };
493
1006
  }
494
1007
  //#endregion
1008
+ //#region src/apns/live-activities.ts
1009
+ const START_DEBOUNCE_MS = 2e3;
1010
+ const END_GRACE_MS = 2e4;
1011
+ const FLUSH_DEBOUNCE_MS = 5e3;
1012
+ const MIN_UPDATE_INTERVAL_MS = 3e4;
1013
+ const HEARTBEAT_MS = 6e5;
1014
+ const RENEW_MS = 279e5;
1015
+ const ENGAGED = /* @__PURE__ */ new Set(["running", "awaiting_approval"]);
1016
+ const RELEVANT = /* @__PURE__ */ new Set([
1017
+ "status_changed",
1018
+ "permission_requested",
1019
+ "permission_resolved",
1020
+ "checklist",
1021
+ "turn_result",
1022
+ "session_error",
1023
+ "session_closed"
1024
+ ]);
1025
+ function finalPhaseFor(status) {
1026
+ switch (status) {
1027
+ case "parked": return "parked";
1028
+ case "failed": return "failed";
1029
+ case "closed": return "ended";
1030
+ default: return "done";
1031
+ }
1032
+ }
1033
+ function isWaiting(phase) {
1034
+ return phase === "approval" || phase === "question" || phase === "plan";
1035
+ }
1036
+ function driveLiveActivities(options) {
1037
+ const sessions = /* @__PURE__ */ new Map();
1038
+ const unsubscribes = /* @__PURE__ */ new Set();
1039
+ const clear = (tracked, name) => {
1040
+ const handle = tracked.timers.get(name);
1041
+ if (handle !== void 0) {
1042
+ clearTimeout(handle);
1043
+ tracked.timers.delete(name);
1044
+ }
1045
+ };
1046
+ const arm = (tracked, name, delay, run) => {
1047
+ clear(tracked, name);
1048
+ tracked.timers.set(name, setTimeout(() => {
1049
+ tracked.timers.delete(name);
1050
+ run();
1051
+ }, delay));
1052
+ };
1053
+ const forget = (sessionId) => {
1054
+ const tracked = sessions.get(sessionId);
1055
+ if (tracked === void 0) return;
1056
+ for (const name of tracked.timers.keys()) clear(tracked, name);
1057
+ sessions.delete(sessionId);
1058
+ };
1059
+ const send = (runner, tracked, state, urgent) => {
1060
+ const hash = contentHash(state);
1061
+ if (hash === tracked.lastHash) return;
1062
+ tracked.lastHash = hash;
1063
+ tracked.lastSentAt = Date.now();
1064
+ tracked.pending = void 0;
1065
+ clear(tracked, "flush");
1066
+ tracked.attributes = attributesFor(runner.info(), options.hostIdOf?.());
1067
+ if (!tracked.started) {
1068
+ tracked.started = true;
1069
+ options.target.start(tracked.attributes, state);
1070
+ } else options.target.update(runner.id, state, { urgent });
1071
+ arm(tracked, "heartbeat", HEARTBEAT_MS, () => {
1072
+ const current = sessions.get(runner.id);
1073
+ if (current !== void 0 && current.started) {
1074
+ options.target.update(runner.id, {
1075
+ ...state,
1076
+ seq: runner.info().lastSeq
1077
+ }, { urgent: false });
1078
+ current.lastSentAt = Date.now();
1079
+ }
1080
+ });
1081
+ };
1082
+ const finish = (runner, tracked, phase) => {
1083
+ if (tracked.started) options.target.end(runner.id, projectContentState({
1084
+ info: runner.info(),
1085
+ startedAtMs: tracked.startedAtMs,
1086
+ finalPhase: phase
1087
+ }));
1088
+ forget(runner.id);
1089
+ };
1090
+ const evaluate = (runner, immediate = false) => {
1091
+ const info = runner.info();
1092
+ const request = runner.pendingApprovals[0];
1093
+ const now = Date.now();
1094
+ if (!ENGAGED.has(info.status)) {
1095
+ const tracked = sessions.get(info.id);
1096
+ if (tracked === void 0) return;
1097
+ clear(tracked, "start");
1098
+ if (info.status === "idle") {
1099
+ arm(tracked, "end", END_GRACE_MS, () => finish(runner, tracked, "done"));
1100
+ return;
1101
+ }
1102
+ finish(runner, tracked, finalPhaseFor(info.status));
1103
+ return;
1104
+ }
1105
+ let tracked = sessions.get(info.id);
1106
+ if (tracked === void 0) {
1107
+ tracked = {
1108
+ attributes: attributesFor(info, options.hostIdOf?.()),
1109
+ startedAtMs: now,
1110
+ started: false,
1111
+ lastSentAt: 0,
1112
+ lastWaiting: false,
1113
+ timers: /* @__PURE__ */ new Map()
1114
+ };
1115
+ sessions.set(info.id, tracked);
1116
+ }
1117
+ const current = tracked;
1118
+ clear(current, "end");
1119
+ const state = projectContentState({
1120
+ info,
1121
+ request,
1122
+ startedAtMs: current.startedAtMs
1123
+ });
1124
+ const waiting = isWaiting(state.phase);
1125
+ const urgent = waiting !== current.lastWaiting || state.request?.id !== current.lastRequestId;
1126
+ current.lastWaiting = waiting;
1127
+ current.lastRequestId = state.request?.id;
1128
+ if (!current.started && current.timers.get("start") === void 0) {
1129
+ current.pending = state;
1130
+ if (waiting || immediate) {
1131
+ send(runner, current, state, true);
1132
+ arm(current, "renew", RENEW_MS, () => renew(runner));
1133
+ } else arm(current, "start", START_DEBOUNCE_MS, () => {
1134
+ const pending = sessions.get(runner.id);
1135
+ if (pending === void 0 || !ENGAGED.has(runner.info().status)) return;
1136
+ send(runner, pending, pending.pending ?? state, true);
1137
+ arm(pending, "renew", RENEW_MS, () => renew(runner));
1138
+ });
1139
+ return;
1140
+ }
1141
+ if (!current.started) {
1142
+ current.pending = state;
1143
+ return;
1144
+ }
1145
+ if (urgent) {
1146
+ send(runner, current, state, true);
1147
+ return;
1148
+ }
1149
+ current.pending = state;
1150
+ const since = now - current.lastSentAt;
1151
+ const delay = Math.max(FLUSH_DEBOUNCE_MS, MIN_UPDATE_INTERVAL_MS - since);
1152
+ arm(current, "flush", delay, () => {
1153
+ const pending = sessions.get(runner.id);
1154
+ if (pending?.pending !== void 0) send(runner, pending, pending.pending, false);
1155
+ });
1156
+ };
1157
+ const renew = (runner) => {
1158
+ const tracked = sessions.get(runner.id);
1159
+ if (tracked === void 0 || !ENGAGED.has(runner.info().status)) return;
1160
+ finish(runner, tracked, "ended");
1161
+ evaluate(runner, true);
1162
+ };
1163
+ const stop = options.source.observe((runner) => {
1164
+ const unsubscribe = runner.subscribe((event) => {
1165
+ if (!RELEVANT.has(event.type)) return;
1166
+ evaluate(runner);
1167
+ if (event.type === "session_closed") {
1168
+ unsubscribe();
1169
+ unsubscribes.delete(unsubscribe);
1170
+ }
1171
+ }, runner.info().lastSeq);
1172
+ unsubscribes.add(unsubscribe);
1173
+ evaluate(runner);
1174
+ });
1175
+ return () => {
1176
+ stop();
1177
+ for (const unsubscribe of unsubscribes) unsubscribe();
1178
+ unsubscribes.clear();
1179
+ for (const sessionId of sessions.keys()) forget(sessionId);
1180
+ };
1181
+ }
1182
+ //#endregion
495
1183
  //#region src/auth/auth-key.ts
496
1184
  function generateKey() {
497
1185
  return randomBytes(24).toString("hex");
@@ -1486,13 +2174,6 @@ function createHostGuard(allowedHosts) {
1486
2174
  return isLoopbackHostname(hostname) || allowedHosts.has(hostname);
1487
2175
  };
1488
2176
  }
1489
- function pathnameOf(req) {
1490
- try {
1491
- return new URL(req.url ?? "/", "http://internal").pathname;
1492
- } catch {
1493
- return null;
1494
- }
1495
- }
1496
2177
  function createFallback(auth, webRoot, hostAllowed, apnsRoute) {
1497
2178
  return async (req, res) => {
1498
2179
  if (!hostAllowed(req)) {
@@ -1501,12 +2182,7 @@ function createFallback(auth, webRoot, hostAllowed, apnsRoute) {
1501
2182
  return;
1502
2183
  }
1503
2184
  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
- }
2185
+ if (await apnsRoute(req, res)) return;
1510
2186
  if (webRoot === void 0) {
1511
2187
  res.writeHead(404, { "content-type": "text/plain; charset=utf-8" });
1512
2188
  res.end("the web dashboard is disabled on this gateway\n");
@@ -1564,12 +2240,13 @@ async function startInstance(config, options = {}) {
1564
2240
  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
2241
  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
2242
  const hostAllowed = createHostGuard(config.allowedHosts);
2243
+ const authenticateHost = (req) => hostAllowed(req) ? auth.authenticate(req) : null;
1567
2244
  const apns = config.apns === void 0 ? void 0 : await createApnsForwarder({
1568
2245
  config: config.apns,
1569
2246
  stateDir: config.stateDir,
1570
- authenticate: (req) => hostAllowed(req) ? auth.authenticate(req) : null
2247
+ authenticate: authenticateHost
1571
2248
  });
1572
- const fallback = createFallback(auth, webRoot, hostAllowed, apns?.handleRequest);
2249
+ const fallback = createFallback(auth, webRoot, hostAllowed, apns?.handleRequest ?? createApnsRoute(null, authenticateHost));
1573
2250
  const parking = { ...config.options.parking };
1574
2251
  if (config.stateDir && !parking.store) parking.store = createFileSessionStore({
1575
2252
  dir: join(config.stateDir, "parked"),
@@ -1601,6 +2278,14 @@ async function startInstance(config, options = {}) {
1601
2278
  sweep = setInterval(() => wake.set(sessionsNeedTheMachine(server.registry.list())), WAKE_SWEEP_MS);
1602
2279
  sweep.unref();
1603
2280
  }
2281
+ let stopActivities;
2282
+ if (apns) {
2283
+ await apns.activity.endAll("Gateway restarted");
2284
+ stopActivities = driveLiveActivities({
2285
+ source: server.registry,
2286
+ target: apns.activity
2287
+ });
2288
+ }
1604
2289
  const { port } = await server.listen(config.port, config.host);
1605
2290
  const displayHost = config.host === "0.0.0.0" || config.host === "::" ? "localhost" : config.host;
1606
2291
  const url = `http://${displayHost.includes(":") ? `[${displayHost}]` : displayHost}:${port}`;
@@ -1628,7 +2313,8 @@ async function startInstance(config, options = {}) {
1628
2313
  line(config.stateDir ? ` parked sessions persist in ${join(config.stateDir, "parked")}` : " parked sessions are in memory only — a restart drops them");
1629
2314
  if (apns) {
1630
2315
  const count = apns.deviceCount();
1631
- line(` push: APNs forwarder on ${config.apns?.topic} — ${count === 0 ? "no devices registered yet" : `${count} device(s)`}`);
2316
+ const cards = apns.activity.count();
2317
+ line(` push: APNs forwarder on ${config.apns?.topic} — ${count === 0 ? "no devices registered yet" : `${count} device(s)`}${cards === 0 ? "" : `, ${cards} live card(s)`}`);
1632
2318
  }
1633
2319
  if (config.configPath) line(` config ${config.configPath}`);
1634
2320
  line("");
@@ -1642,14 +2328,16 @@ async function startInstance(config, options = {}) {
1642
2328
  close: async () => {
1643
2329
  if (sweep) clearInterval(sweep);
1644
2330
  wake?.release();
2331
+ stopActivities?.();
1645
2332
  await server.close();
1646
2333
  await sessions?.flush?.();
2334
+ if (apns) await Promise.race([apns.activity.endAll("Gateway stopped"), new Promise((resolve) => setTimeout(resolve, 3e3))]);
1647
2335
  apns?.close();
1648
2336
  resolveClosed();
1649
2337
  }
1650
2338
  };
1651
2339
  }
1652
2340
  //#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 };
2341
+ 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
2342
 
1655
- //# sourceMappingURL=instance-DeDxfiBR.mjs.map
2343
+ //# sourceMappingURL=instance-Bdf5e-lQ.mjs.map