dsh-deeppilot 0.6.0 → 0.6.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/index.js CHANGED
@@ -3,18 +3,257 @@ import { createHash, createPrivateKey, createPublicKey, randomBytes, randomUUID,
3
3
  import { access, chmod, mkdir, readFile, readdir, rename, rm, unlink, writeFile } from "node:fs/promises";
4
4
  import { dirname, join, resolve } from "node:path";
5
5
  import { WebSocketServer } from "ws";
6
+ import { closeSync, constants, existsSync, fsyncSync, mkdirSync, openSync, readFileSync, renameSync, statSync, writeFileSync } from "node:fs";
6
7
  import { homedir, networkInterfaces } from "node:os";
7
8
  import * as Cordis from "@deepseek-ai/cordis";
8
9
  import { Context } from "@deepseek-ai/cordis";
9
10
  import { TypertRemoteService } from "@deepseek-ai/dsh-typert-protocol";
10
11
  import { connect } from "node:http2";
11
12
  import { spawn } from "node:child_process";
12
- import { constants } from "node:fs";
13
13
  import { fileURLToPath } from "node:url";
14
14
  import { request } from "node:https";
15
15
  import z from "@deepseek-ai/schemastery";
16
16
  import { isIP } from "node:net";
17
17
  import { createServer } from "node:http";
18
+ function validSendId(id) {
19
+ return typeof id === "string" && /^\d{13}-[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(id);
20
+ }
21
+ /** Durable at-most-once dispatch. Unknown outcomes are never automatically retried. */
22
+ var PromptDeliveryJournal = class {
23
+ path;
24
+ capacity;
25
+ entries = Object.create(null);
26
+ inFlight = /* @__PURE__ */ new Map();
27
+ healthy = true;
28
+ constructor(path, capacity = 1e4) {
29
+ this.path = path;
30
+ this.capacity = capacity;
31
+ if (!path || !existsSync(path)) return;
32
+ try {
33
+ if (statSync(path).size > 8388608) throw new Error("oversized journal");
34
+ const parsed = JSON.parse(readFileSync(path, "utf8"));
35
+ if (parsed.version !== 1 || !Array.isArray(parsed.entries) || parsed.entries.length > capacity) throw new Error("invalid journal");
36
+ for (const [key, value] of parsed.entries) {
37
+ if (typeof key !== "string" || !/^[a-f0-9]{64}$/.test(key) || !value || typeof value.sessionId !== "string" || !Number.isFinite(value.createdAt) || typeof value.fingerprint !== "string" || !validSendId(value.receipt?.clientSendId) || ![
38
+ "accepted",
39
+ "rejected",
40
+ "unknown"
41
+ ].includes(value.receipt.status)) throw new Error("invalid entry");
42
+ if (!/^[a-f0-9]{64}$/.test(value.fingerprint) || value.createdAt !== Number(value.receipt.clientSendId.slice(0, 13)) || value.receipt.status === "accepted" && !Number.isSafeInteger(value.receipt.userSeq) || value.receipt.status === "rejected" && ![
43
+ "E_BUSY",
44
+ "E_NOT_FOUND",
45
+ "E_PROTOCOL",
46
+ "E_UNSUPPORTED"
47
+ ].includes(value.receipt.code)) throw new Error("invalid receipt");
48
+ this.entries[key] = value;
49
+ }
50
+ } catch {
51
+ this.healthy = false;
52
+ }
53
+ }
54
+ key(deviceId, id) {
55
+ return createHash("sha256").update(JSON.stringify([deviceId, id])).digest("hex");
56
+ }
57
+ expired(id, now = Date.now()) {
58
+ const age = now - Number(id.slice(0, 13));
59
+ return age > 6048e5 || age < -3e5;
60
+ }
61
+ save() {
62
+ if (!this.path) return;
63
+ mkdirSync(dirname(this.path), {
64
+ recursive: true,
65
+ mode: 448
66
+ });
67
+ const temp = this.path + "." + randomUUID() + ".tmp";
68
+ const fd = openSync(temp, "wx", 384);
69
+ try {
70
+ writeFileSync(fd, JSON.stringify({
71
+ version: 1,
72
+ entries: Object.entries(this.entries)
73
+ }));
74
+ fsyncSync(fd);
75
+ } finally {
76
+ closeSync(fd);
77
+ }
78
+ renameSync(temp, this.path);
79
+ const dir = openSync(dirname(this.path), "r");
80
+ try {
81
+ fsyncSync(dir);
82
+ } finally {
83
+ closeSync(dir);
84
+ }
85
+ }
86
+ lookup(deviceId, sessionId, id) {
87
+ if (!this.healthy) return {
88
+ clientSendId: id,
89
+ status: "unknown"
90
+ };
91
+ const entry = this.entries[this.key(deviceId, id)];
92
+ if (entry && entry.sessionId === sessionId) return entry.receipt;
93
+ return {
94
+ clientSendId: id,
95
+ status: this.expired(id) ? "expired" : "notFound"
96
+ };
97
+ }
98
+ async dispatch(deviceId, sessionId, id, content, operation) {
99
+ const key = this.key(deviceId, id);
100
+ const fingerprint = createHash("sha256").update(JSON.stringify([sessionId, content])).digest("hex");
101
+ const existing = this.entries[key];
102
+ if (existing) {
103
+ if (existing.fingerprint !== fingerprint) return {
104
+ clientSendId: id,
105
+ status: "rejected",
106
+ code: "E_PROTOCOL"
107
+ };
108
+ return this.inFlight.get(key) ?? existing.receipt;
109
+ }
110
+ if (!this.healthy) return {
111
+ clientSendId: id,
112
+ status: "unknown"
113
+ };
114
+ if (this.expired(id)) return {
115
+ clientSendId: id,
116
+ status: "expired"
117
+ };
118
+ for (const [key, value] of Object.entries(this.entries)) if (Date.now() - value.createdAt > 6051e5 && !this.inFlight.has(key)) delete this.entries[key];
119
+ if (Object.keys(this.entries).length >= this.capacity) return {
120
+ clientSendId: id,
121
+ status: "rejected",
122
+ code: "E_BUSY"
123
+ };
124
+ const entry = {
125
+ fingerprint,
126
+ sessionId,
127
+ createdAt: Number(id.slice(0, 13)),
128
+ receipt: {
129
+ clientSendId: id,
130
+ status: "unknown"
131
+ }
132
+ };
133
+ this.entries[key] = entry;
134
+ try {
135
+ this.save();
136
+ } catch {
137
+ this.healthy = false;
138
+ return entry.receipt;
139
+ }
140
+ const run = (async () => {
141
+ try {
142
+ const result = await operation();
143
+ if (result.ok) entry.receipt = {
144
+ clientSendId: id,
145
+ status: "accepted",
146
+ userSeq: result.value
147
+ };
148
+ else if ([
149
+ "busy",
150
+ "not-found",
151
+ "invalid",
152
+ "unsupported"
153
+ ].includes(result.kind)) {
154
+ const code = {
155
+ busy: "E_BUSY",
156
+ "not-found": "E_NOT_FOUND",
157
+ invalid: "E_PROTOCOL",
158
+ unsupported: "E_UNSUPPORTED"
159
+ }[result.kind];
160
+ entry.receipt = {
161
+ clientSendId: id,
162
+ status: "rejected",
163
+ code
164
+ };
165
+ }
166
+ } catch {}
167
+ try {
168
+ this.save();
169
+ } catch {
170
+ this.healthy = false;
171
+ }
172
+ return entry.receipt;
173
+ })();
174
+ this.inFlight.set(key, run);
175
+ try {
176
+ return await run;
177
+ } finally {
178
+ this.inFlight.delete(key);
179
+ }
180
+ }
181
+ };
182
+ const journals = /* @__PURE__ */ new Map();
183
+ function openDeliveryJournal(path) {
184
+ if (!path) return new PromptDeliveryJournal();
185
+ let journal = journals.get(path);
186
+ if (!journal) {
187
+ journal = new PromptDeliveryJournal(path);
188
+ journals.set(path, journal);
189
+ }
190
+ return journal;
191
+ }
192
+ //#endregion
193
+ //#region src/request-validation.ts
194
+ /** Shape and resource limits for authenticated client requests. Unknown fields
195
+ * remain accepted for additive protocol-v2 compatibility. Business validation
196
+ * (supported models, pending choices, permissions) stays with its owner. */
197
+ function validateRequest(type, value) {
198
+ const empty = /* @__PURE__ */ new Set([
199
+ "c2s.ping",
200
+ "c2s.sessions.list",
201
+ "c2s.pending.list",
202
+ "c2s.workspaces.list",
203
+ "c2s.directory.pick"
204
+ ]);
205
+ if (value === void 0 && (type === "c2s.session.create" || type === "c2s.directory.list")) value = {};
206
+ if (value == null && empty.has(type)) return;
207
+ if (value === null || typeof value !== "object" || Array.isArray(value)) return "payload must be an object";
208
+ const p = value;
209
+ const text = (v, max = 4096, nonempty = true) => typeof v === "string" && v.length <= max && (!nonempty || v.trim().length > 0);
210
+ const optional = (key, check) => p[key] === void 0 || check(p[key]);
211
+ const integer = (v, min, max) => typeof v === "number" && Number.isSafeInteger(v) && v >= min && v <= max;
212
+ if (type.startsWith("c2s.session.") && type !== "c2s.session.create" && !text(p.sessionId)) return "invalid sessionId";
213
+ if (type === "c2s.session.create" && (!optional("workspaceId", (v) => text(v)) || !optional("cwd", (v) => text(v, 32768)) || p.workspaceId !== void 0 && p.cwd !== void 0)) return "invalid workspace selection";
214
+ if (type === "c2s.directory.list" && !optional("path", (v) => text(v, 32768, false))) return "invalid path";
215
+ if (type === "c2s.workspace.create" && !text(p.path, 32768)) return "invalid path";
216
+ if (type === "c2s.session.open" && !optional("tailCount", (v) => integer(v, 1, 1e4))) return "invalid tailCount";
217
+ if (type === "c2s.session.history" && (!integer(p.beforeSeq, 0, Number.MAX_SAFE_INTEGER) || !optional("limit", (v) => integer(v, 1, 500)))) return "invalid history range";
218
+ if (type === "c2s.session.rename" && !text(p.title, 4096)) return "invalid title";
219
+ if (type === "c2s.session.attachment" && !text(p.attachmentId)) return "invalid attachmentId";
220
+ if (type === "c2s.session.selectModel" && (!text(p.provider, 256) || !text(p.model, 1024) || !optional("reasoningEffort", (v) => text(v, 128, false)))) return "invalid model selection";
221
+ if (type === "c2s.session.delivery" && !validSendId(p.clientSendId)) return "invalid clientSendId";
222
+ if (type === "c2s.session.sendPrompt") {
223
+ if (p.clientSendId !== void 0 && !validSendId(p.clientSendId)) return "invalid clientSendId";
224
+ if (!optional("text", (v) => text(v, 262144, false)) || !optional("images", Array.isArray) || !optional("documents", Array.isArray)) return "invalid prompt fields";
225
+ for (const key of ["images", "documents"]) {
226
+ const items = p[key];
227
+ if (items && items.some((v) => v === null || typeof v !== "object" || Array.isArray(v))) return "invalid attachment";
228
+ for (const item of items ?? []) {
229
+ if (item.name !== void 0 && !text(item.name, 4096, false)) return "invalid attachment name";
230
+ if (item.truncated !== void 0 && typeof item.truncated !== "boolean") return "invalid truncated flag";
231
+ }
232
+ }
233
+ }
234
+ if (type === "c2s.approval.respond" || type === "c2s.question.respond") {
235
+ if (!text(p.requestId)) return "invalid requestId";
236
+ }
237
+ if (type === "c2s.approval.respond" && (!["allow", "deny"].includes(p.decision) || !optional("reason", (v) => text(v, 65536, false)))) return "invalid approval response";
238
+ if (type === "c2s.question.respond") {
239
+ if (!Array.isArray(p.answers) || p.answers.length > 100) return "invalid answers";
240
+ const ids = /* @__PURE__ */ new Set();
241
+ for (const answer of p.answers) {
242
+ if (!answer || typeof answer !== "object" || Array.isArray(answer) || !text(answer.id) || ids.has(answer.id) || !Array.isArray(answer.selected) || answer.selected.length > 100 || !answer.selected.every((v) => text(v, 4096)) || answer.custom !== void 0 && !text(answer.custom, 65536)) return "invalid answer";
243
+ ids.add(answer.id);
244
+ }
245
+ }
246
+ if (type === "c2s.liveActivity.register" || type === "c2s.liveActivity.unregister") {
247
+ if (!text(p.activityId, 128)) return "invalid activityId";
248
+ if (type === "c2s.liveActivity.register" && (!text(p.sessionId) || typeof p.deviceToken !== "string" || !/^[0-9a-fA-F]{32,512}$/.test(p.deviceToken) || !["development", "production"].includes(p.environment) || !optional("enrollKey", (v) => text(v, 128)))) return "invalid live activity registration";
249
+ }
250
+ if (type === "c2s.push.register" || type === "c2s.widget.push.register") {
251
+ if (p.environment !== void 0 && p.environment !== "production" && p.environment !== "development") return "invalid APNs environment";
252
+ if (!optional("enrollKey", (v) => text(v, 128))) return "invalid enrollKey";
253
+ if (p.categories !== void 0 && (!p.categories || typeof p.categories !== "object" || Array.isArray(p.categories) || Object.values(p.categories).some((v) => typeof v !== "boolean"))) return "invalid categories";
254
+ }
255
+ }
256
+ //#endregion
18
257
  //#region src/device-auth.ts
19
258
  function getPairingCodeTtlMs() {
20
259
  return Number(process.env.DEEPPILOT_PAIRING_TTL_MS) || 3e5;
@@ -226,7 +465,9 @@ var DeviceStore = class DeviceStore {
226
465
  scopes: normalizeDeviceScopes(record.scopes),
227
466
  firstSeenTs: existing?.firstSeenTs ?? now,
228
467
  lastSeenTs: now,
229
- ...existing?.apns ? { apns: existing.apns } : {}
468
+ ...existing?.apns ? { apns: existing.apns } : {},
469
+ ...existing?.widgetApns ? { widgetApns: existing.widgetApns } : {},
470
+ ...existing?.liveActivity ? { liveActivity: existing.liveActivity } : {}
230
471
  };
231
472
  this.devices.set(deviceId, next);
232
473
  this.flush();
@@ -251,6 +492,8 @@ var DeviceStore = class DeviceStore {
251
492
  if (!record || record.revokedAt !== void 0) return false;
252
493
  record.revokedAt = now;
253
494
  delete record.apns;
495
+ delete record.widgetApns;
496
+ delete record.liveActivity;
254
497
  this.flush();
255
498
  return true;
256
499
  }
@@ -296,6 +539,48 @@ var DeviceStore = class DeviceStore {
296
539
  delete record.apns;
297
540
  this.flush();
298
541
  }
542
+ setLiveActivity(deviceId, registration) {
543
+ const record = this.authorized(deviceId);
544
+ if (!record || !isValidApnsToken(registration.token)) return;
545
+ const old = record.liveActivity;
546
+ record.liveActivity = old?.activityId === registration.activityId ? {
547
+ ...registration,
548
+ expiresAt: old.expiresAt,
549
+ endedState: old.endedState
550
+ } : registration;
551
+ this.flush();
552
+ }
553
+ endLiveActivity(deviceId, token, state) {
554
+ const registration = this.devices.get(deviceId)?.liveActivity;
555
+ if (!registration || registration.token !== token || registration.endedState) return;
556
+ registration.endedState = state;
557
+ this.flush();
558
+ }
559
+ clearLiveActivity(deviceId, activityId, token) {
560
+ const record = this.devices.get(deviceId);
561
+ if (record?.liveActivity?.activityId !== activityId || token !== void 0 && record.liveActivity.token !== token) return;
562
+ delete record.liveActivity;
563
+ this.flush();
564
+ }
565
+ setWidgetPushToken(deviceId, token, environment, now) {
566
+ const record = this.authorized(deviceId);
567
+ if (!record || !isValidApnsToken(token)) return;
568
+ const previous = record.widgetApns;
569
+ if (previous?.token === token && previous.environment === environment && now - previous.updatedAt < 36e5) return;
570
+ record.widgetApns = {
571
+ token,
572
+ environment,
573
+ updatedAt: now
574
+ };
575
+ this.flush();
576
+ }
577
+ /** Compare-and-clear protects a rotated token from a delayed APNs rejection. */
578
+ clearWidgetPushToken(deviceId, token) {
579
+ const record = this.devices.get(deviceId);
580
+ if (record?.widgetApns?.token !== token) return;
581
+ delete record.widgetApns;
582
+ this.flush();
583
+ }
299
584
  /** Serialized so concurrent touches can never interleave half-written JSON. */
300
585
  flush() {
301
586
  const next = this.flushTail.then(() => this.writeFile());
@@ -332,14 +617,55 @@ const IMAGE_MEDIA_TYPES = /* @__PURE__ */ new Set([
332
617
  function sanitizeDeviceField(value, maxChars) {
333
618
  return (typeof value === "string" ? value : String(value ?? "")).replace(/[\u0000-\u001f\u007f]/g, " ").trim().slice(0, maxChars);
334
619
  }
620
+ /**
621
+ * Runtime shape guard for a frame after JSON.parse. The old `as Envelope`
622
+ * cast alone let JSON `null` reach `env.v` and let a missing or mistyped
623
+ * `type` reach `requiredScope()`'s string operations — one anonymous frame
624
+ * could crash the host process. Reject anything that is not a plain object
625
+ * with a numeric version and a non-empty string type, so field access below
626
+ * is always safe. The payload is intentionally opaque here; each handler
627
+ * validates its own payload shape.
628
+ */
629
+ function isEnvelope(value) {
630
+ if (typeof value !== "object" || value === null || Array.isArray(value)) return false;
631
+ const frame = value;
632
+ if (typeof frame.v !== "number") return false;
633
+ if (typeof frame.type !== "string" || frame.type.length === 0) return false;
634
+ if (frame.id !== void 0 && typeof frame.id !== "string") return false;
635
+ if (frame.ts !== void 0 && typeof frame.ts !== "number") return false;
636
+ if (frame.seq !== void 0 && typeof frame.seq !== "number") return false;
637
+ return true;
638
+ }
335
639
  function requiredScope(type) {
640
+ if (typeof type !== "string") return void 0;
336
641
  if (type === "c2s.ping" || type === "c2s.resume") return void 0;
337
- if (type === "c2s.session.sendPrompt") return "prompt.send";
642
+ if (type === "c2s.session.sendPrompt" || type === "c2s.session.delivery") return "prompt.send";
643
+ if (type === "c2s.pending.list") return "interactions.respond";
338
644
  if (type === "c2s.approval.respond" || type === "c2s.question.respond") return "interactions.respond";
339
- if (type === "c2s.push.register") return "notifications.register";
645
+ if (type === "c2s.liveActivity.register" || type === "c2s.liveActivity.unregister") return "notifications.register";
646
+ if (type === "c2s.push.register" || type === "c2s.widget.push.register") return "notifications.register";
340
647
  if (type === "c2s.workspace.create" || type === "c2s.session.create" || type === "c2s.session.rename" || type === "c2s.session.archive" || type === "c2s.session.cancel" || type === "c2s.session.selectModel") return "sessions.manage";
341
648
  if (type.startsWith("c2s.")) return "sessions.read";
342
649
  }
650
+ /** Broadcast and replay authorization. Unknown frame types fail closed. */
651
+ const PUSH_SCOPE_BY_TYPE = {
652
+ "s2c.session.event": "sessions.read",
653
+ "s2c.sessions.delta": "sessions.read",
654
+ "s2c.session.tail": "sessions.read",
655
+ "s2c.history.page": "sessions.read",
656
+ "s2c.pending.approval": "interactions.respond",
657
+ "s2c.pending.question": "interactions.respond",
658
+ "s2c.pending.cleared": "interactions.respond"
659
+ };
660
+ function pushScopeFor(type, payload) {
661
+ const fixed = PUSH_SCOPE_BY_TYPE[type];
662
+ if (fixed !== void 0) return fixed;
663
+ if (type === "s2c.notify") {
664
+ const category = payload?.category;
665
+ if (category === "approval.required" || category === "question.asked") return "interactions.respond";
666
+ if (category === "turn.completed" || category === "session.error") return "sessions.read";
667
+ }
668
+ }
343
669
  function sanitizeImageName(value) {
344
670
  return value.replace(/[\u0000-\u001F\u007F]/g, "").trim().slice(0, 120);
345
671
  }
@@ -396,13 +722,20 @@ var BridgeConnection = class {
396
722
  /** Sanitized device identity from hello; needed for push registration. */
397
723
  deviceId;
398
724
  scopes = /* @__PURE__ */ new Set();
725
+ widgetClient = false;
726
+ liveActivityRegistrationGeneration = 0;
727
+ pendingLiveActivityId;
399
728
  authChallenge;
400
729
  constructor(ws, deps) {
401
730
  this.ws = ws;
402
731
  this.deps = deps;
403
732
  this.authChallenge = createAuthChallenge(deps.audience);
404
733
  ws.on("message", (data) => {
405
- this.onMessage(String(data));
734
+ this.onMessage(String(data)).catch((error) => {
735
+ if (this.deps.debug === true) this.deps.log("frame handler failed: " + String(error));
736
+ if (this.closed) return;
737
+ this.terminate();
738
+ });
406
739
  });
407
740
  ws.on("close", () => {
408
741
  this.onClose();
@@ -439,7 +772,19 @@ var BridgeConnection = class {
439
772
  get connectedDeviceId() {
440
773
  return this.authenticated ? this.deviceId : void 0;
441
774
  }
775
+ get suppressesAlertPush() {
776
+ return !this.widgetClient;
777
+ }
778
+ /** S→C permission gate consulted by the bridge for every broadcast/replay
779
+ * frame: a device only receives what its scopes grant (R1/P2). */
780
+ canReceive(scope) {
781
+ return this.scopes.has(scope);
782
+ }
442
783
  push(type, payload, seq) {
784
+ if (type === "s2c.notify") payload = {
785
+ ...payload,
786
+ hostAudience: this.deps.audience
787
+ };
443
788
  if (type === "s2c.session.event") {
444
789
  const sessionId = payload?.sessionId;
445
790
  if (typeof sessionId === "string") {
@@ -528,7 +873,12 @@ var BridgeConnection = class {
528
873
  }
529
874
  let env;
530
875
  try {
531
- env = JSON.parse(raw);
876
+ const parsed = JSON.parse(raw);
877
+ if (!isEnvelope(parsed)) {
878
+ this.fail(void 0, "E_PROTOCOL", "malformed frame");
879
+ return;
880
+ }
881
+ env = parsed;
532
882
  } catch {
533
883
  this.fail(void 0, "E_PROTOCOL", "frame is not valid JSON");
534
884
  return;
@@ -550,11 +900,19 @@ var BridgeConnection = class {
550
900
  this.fail(env.id, "E_PROTOCOL", "authenticate first");
551
901
  return;
552
902
  }
903
+ if (this.widgetClient && ![
904
+ "c2s.ping",
905
+ "c2s.sessions.list",
906
+ "c2s.pending.list",
907
+ "c2s.widget.push.register"
908
+ ].includes(env.type)) return this.fail(env.id, "E_FORBIDDEN", "widget connection is read-only");
553
909
  const required = requiredScope(env.type);
554
910
  if (required !== void 0 && !this.scopes.has(required)) {
555
911
  this.fail(env.id, "E_FORBIDDEN", `scope ${required} required`);
556
912
  return;
557
913
  }
914
+ const invalid = validateRequest(env.type, env.payload);
915
+ if (invalid) return this.fail(env.id, "E_PROTOCOL", invalid);
558
916
  switch (env.type) {
559
917
  case "c2s.ping":
560
918
  this.send("s2c.pong", { serverTime: Date.now() }, env.id);
@@ -727,6 +1085,11 @@ var BridgeConnection = class {
727
1085
  }, env.id);
728
1086
  return;
729
1087
  }
1088
+ case "c2s.session.delivery": {
1089
+ const p = env.payload;
1090
+ this.send("s2c.ack", this.deps.bridge.promptDeliveries.lookup(this.deviceId, p.sessionId, p.clientSendId), env.id);
1091
+ return;
1092
+ }
730
1093
  case "c2s.session.sendPrompt": {
731
1094
  const p = env.payload;
732
1095
  const text = typeof p?.text === "string" ? p.text : "";
@@ -758,6 +1121,15 @@ var BridgeConnection = class {
758
1121
  ...document.truncated === true ? { truncated: true } : {}
759
1122
  });
760
1123
  }
1124
+ if (p.clientSendId) {
1125
+ const receipt = await this.deps.bridge.promptDeliveries.dispatch(this.deviceId, p.sessionId, p.clientSendId, {
1126
+ text,
1127
+ images,
1128
+ documents
1129
+ }, () => this.deps.bridge.sendPrompt(p.sessionId, text, images, documents));
1130
+ this.send("s2c.ack", receipt, env.id);
1131
+ return;
1132
+ }
761
1133
  const userSeq = await this.deps.bridge.sendPrompt(p.sessionId, text, images, documents);
762
1134
  if (!userSeq.ok) return this.fail(env.id, managementErrorCode(userSeq.kind), userSeq.message);
763
1135
  this.send("s2c.ack", { userSeq: userSeq.value }, env.id);
@@ -779,7 +1151,52 @@ var BridgeConnection = class {
779
1151
  this.send("s2c.ack", {}, env.id);
780
1152
  return;
781
1153
  }
1154
+ case "c2s.liveActivity.unregister": {
1155
+ const p = env.payload;
1156
+ if (this.pendingLiveActivityId === p.activityId) {
1157
+ this.liveActivityRegistrationGeneration += 1;
1158
+ this.pendingLiveActivityId = void 0;
1159
+ }
1160
+ this.deps.devices.clearLiveActivity(this.deviceId, p.activityId);
1161
+ this.send("s2c.ack", {}, env.id);
1162
+ return;
1163
+ }
1164
+ case "c2s.liveActivity.register": {
1165
+ const p = env.payload;
1166
+ const initial = this.deviceId && this.deps.devices.authorized(this.deviceId);
1167
+ if (!initial || ![
1168
+ "notifications.register",
1169
+ "sessions.read",
1170
+ "interactions.respond"
1171
+ ].every((scope) => initial.scopes?.includes(scope))) return this.fail(env.id, "E_FORBIDDEN", "live activity permissions required");
1172
+ const generation = ++this.liveActivityRegistrationGeneration;
1173
+ this.pendingLiveActivityId = p.activityId;
1174
+ if (p.enrollKey) await this.deps.onPushEnrollKey?.(p.enrollKey);
1175
+ if (generation !== this.liveActivityRegistrationGeneration) return this.fail(env.id, "E_BUSY", "live activity registration superseded");
1176
+ const record = this.deviceId && this.deps.devices.authorized(this.deviceId);
1177
+ if (this.closed || !record || ![
1178
+ "notifications.register",
1179
+ "sessions.read",
1180
+ "interactions.respond"
1181
+ ].every((scope) => record.scopes?.includes(scope))) return this.fail(env.id, "E_FORBIDDEN", "live activity permissions required");
1182
+ if (!this.deps.bridge.listSessions().some((row) => row.id === p.sessionId)) return this.fail(env.id, "E_NOT_FOUND", "session not found");
1183
+ const previous = record.liveActivity;
1184
+ if (previous?.activityId === p.activityId && previous.sessionId !== p.sessionId) return this.fail(env.id, "E_PROTOCOL", "activity is bound to another session");
1185
+ this.deps.devices.setLiveActivity(record.deviceId, {
1186
+ activityId: p.activityId,
1187
+ sessionId: p.sessionId,
1188
+ token: p.deviceToken.toLowerCase(),
1189
+ environment: p.environment,
1190
+ updatedAt: Date.now(),
1191
+ expiresAt: Date.now() + 288e5
1192
+ });
1193
+ this.deps.bridge.refreshLiveActivities();
1194
+ this.send("s2c.ack", { enabled: this.deps.bridge.capabilities.push }, env.id);
1195
+ return;
1196
+ }
1197
+ case "c2s.widget.push.register":
782
1198
  case "c2s.push.register": {
1199
+ const widget = env.type === "c2s.widget.push.register";
783
1200
  const p = env.payload;
784
1201
  const token = typeof p?.deviceToken === "string" ? p.deviceToken.trim() : "";
785
1202
  if (!isValidApnsToken(token)) return this.fail(env.id, "E_PROTOCOL", "hex deviceToken (32-512 chars) required");
@@ -790,7 +1207,12 @@ var BridgeConnection = class {
790
1207
  const enrollKey = p.enrollKey.trim().replace(/[^\x20-\x7e]/g, "").slice(0, 128);
791
1208
  if (enrollKey.length >= 8 && enrollKey.length <= 128) await this.deps.onPushEnrollKey?.(enrollKey);
792
1209
  }
793
- this.deps.devices.setPushToken(this.deviceId, token, environment, categories, Date.now());
1210
+ const record = this.deps.devices.authorized(this.deviceId);
1211
+ if (this.closed || !record?.scopes?.includes("notifications.register")) return this.fail(env.id, "E_FORBIDDEN", "device authorization changed");
1212
+ if (widget) {
1213
+ if (!record.scopes.includes("sessions.read") || !record.scopes.includes("interactions.respond")) return this.fail(env.id, "E_FORBIDDEN", "widget overview permissions required");
1214
+ this.deps.devices.setWidgetPushToken(this.deviceId, token, environment, Date.now());
1215
+ } else this.deps.devices.setPushToken(this.deviceId, token, environment, categories, Date.now());
794
1216
  if (!this.deps.bridge.capabilities.push) {
795
1217
  if (this.deps.debug === true) this.deps.log("push register held: bridge not ready");
796
1218
  return this.fail(env.id, "E_UNSUPPORTED", "push is not configured on this bridge");
@@ -836,11 +1258,12 @@ var BridgeConnection = class {
836
1258
  this.settleAuthentication(true, "success");
837
1259
  this.authenticated = true;
838
1260
  this.deviceId = deviceId;
1261
+ this.widgetClient = p.clientRole === "widget";
839
1262
  this.scopes = new Set(record.scopes ?? []);
840
1263
  if (this.helloTimer !== void 0) clearTimeout(this.helloTimer);
841
- this.deps.devices.markAuthenticated(deviceId, deviceName, appVersion, Date.now());
1264
+ if (!this.widgetClient) this.deps.devices.markAuthenticated(deviceId, deviceName, appVersion, Date.now());
842
1265
  this.deps.onDeviceAuthenticated?.(deviceId);
843
- const cursor = resumeCursor;
1266
+ const cursor = this.widgetClient ? void 0 : resumeCursor;
844
1267
  const canResume = cursor !== void 0 && this.deps.bridge.canResumeFrom(cursor);
845
1268
  this.send("s2c.welcome", {
846
1269
  protocolVersion: 2,
@@ -851,7 +1274,7 @@ var BridgeConnection = class {
851
1274
  cursor: this.deps.bridge.currentCursor(),
852
1275
  resumed: canResume
853
1276
  }, env.id);
854
- this.deps.bridge.addSink(this);
1277
+ if (!this.widgetClient) this.deps.bridge.addSink(this);
855
1278
  if (cursor !== void 0) {
856
1279
  if (canResume) this.deps.bridge.resumeFrom(cursor, this);
857
1280
  else this.resync();
@@ -950,6 +1373,7 @@ function projectEvent(sessionId, event) {
950
1373
  kind: "turn.end",
951
1374
  data: { ok: event.data?.reason?.kind === "completed" }
952
1375
  };
1376
+ case "system/message": return null;
953
1377
  case "user/message": return {
954
1378
  kind: "message.final",
955
1379
  data: { ...limitMessageProjection({
@@ -1212,6 +1636,7 @@ function projectHistory(events) {
1212
1636
  ts: tsOf(event)
1213
1637
  };
1214
1638
  switch (event.type) {
1639
+ case "system/message": break;
1215
1640
  case "user/message":
1216
1641
  messages.push({
1217
1642
  ...base,
@@ -1401,11 +1826,14 @@ var HostBridge = class {
1401
1826
  abort = new AbortController();
1402
1827
  started = false;
1403
1828
  disposed = false;
1404
- constructor(apiProxy, historyBufferMax = MAX_RING_DEFAULT) {
1829
+ promptDeliveries;
1830
+ constructor(apiProxy, historyBufferMax = MAX_RING_DEFAULT, deliveryJournalPath) {
1405
1831
  this.apiProxy = apiProxy;
1406
1832
  this.historyBufferMax = historyBufferMax;
1833
+ this.promptDeliveries = openDeliveryJournal(deliveryJournalPath);
1407
1834
  }
1408
1835
  pushOutlet;
1836
+ widgetFingerprint = "";
1409
1837
  /**
1410
1838
  * Wire the offline-push fan-out. Present ⇒ welcome advertises the `push`
1411
1839
  * capability and notify-worthy events are mirrored to APNs.
@@ -1420,11 +1848,14 @@ var HostBridge = class {
1420
1848
  approvals: true,
1421
1849
  questions: true,
1422
1850
  pendingSnapshot: true,
1851
+ promptDelivery: true,
1423
1852
  notifyAllCategories: true,
1424
1853
  models: typeof this.apiProxy.sessions.models === "function" && typeof this.apiProxy.sessions.selectModel === "function",
1425
1854
  sessionManagement: typeof this.apiProxy.sessions.rename === "function" && typeof this.apiProxy.workspace?.archiveSession === "function",
1426
1855
  projectSelection: typeof this.apiProxy.workspace?.list === "function" && typeof this.apiProxy.workspace?.create === "function",
1427
- push: this.pushOutlet?.isAvailable() === true
1856
+ push: this.pushOutlet?.isAvailable() === true,
1857
+ widgetPush: true,
1858
+ liveActivityPush: true
1428
1859
  };
1429
1860
  }
1430
1861
  diagnostic(message) {
@@ -1521,17 +1952,52 @@ var HostBridge = class {
1521
1952
  * Replay buffered pushes after the given cursor; false when the gap is
1522
1953
  * unrecoverable. Frames go to `target` only — replaying into every sink
1523
1954
  * duplicated the whole window onto devices that never asked for it.
1955
+ * Each frame is filtered by the S→C permission policy per sink, so a
1956
+ * reader without interactions.respond never gets the missed approval
1957
+ * frames back (R1/P2).
1524
1958
  */
1525
1959
  resumeFrom(cursor, target) {
1526
1960
  const oldest = this.ring.length > 0 ? this.ring[0].seq : this.cursor + 1;
1527
1961
  if (cursor + 1 < oldest) return false;
1528
1962
  const receivers = target !== void 0 ? [target] : [...this.sinks];
1529
- for (const entry of this.ring) if (entry.seq > cursor) for (const sink of receivers) sink.replay([entry]);
1963
+ for (const entry of this.ring) if (entry.seq > cursor) for (const sink of receivers) {
1964
+ const need = pushScopeFor(entry.type, entry.payload);
1965
+ if (need === void 0 || !sink.canReceive(need)) continue;
1966
+ sink.replay([entry]);
1967
+ }
1530
1968
  for (const sink of receivers) sink.replayDone();
1531
1969
  return true;
1532
1970
  }
1971
+ refreshLiveActivities() {
1972
+ try {
1973
+ this.pushOutlet?.liveActivityChanged?.(this.listSessions());
1974
+ } catch {}
1975
+ }
1533
1976
  record(type, payload, except) {
1534
1977
  if (this.disposed) return;
1978
+ if (type === "s2c.sessions.delta") this.refreshLiveActivities();
1979
+ if (type === "s2c.sessions.delta" || type.startsWith("s2c.pending.")) {
1980
+ const fingerprint = JSON.stringify([
1981
+ this.listSessions().map(({ id, title, status, lastActivityTs, todos, todoItems, pendingApproval, pendingQuestion }) => ({
1982
+ id,
1983
+ title,
1984
+ status,
1985
+ lastActivityTs,
1986
+ todos,
1987
+ todoItems,
1988
+ pendingApproval,
1989
+ pendingQuestion
1990
+ })),
1991
+ this.approvals.size,
1992
+ this.questions.size
1993
+ ]);
1994
+ if (fingerprint !== this.widgetFingerprint) {
1995
+ this.widgetFingerprint = fingerprint;
1996
+ try {
1997
+ this.pushOutlet?.widgetChanged?.();
1998
+ } catch {}
1999
+ }
2000
+ }
1535
2001
  this.cursor += 1;
1536
2002
  const entry = {
1537
2003
  seq: this.cursor,
@@ -1540,7 +2006,9 @@ var HostBridge = class {
1540
2006
  };
1541
2007
  this.ring.push(entry);
1542
2008
  if (this.ring.length > this.historyBufferMax) this.ring.splice(0, this.ring.length - this.historyBufferMax);
2009
+ const need = pushScopeFor(type, payload);
1543
2010
  for (const sink of this.sinks) {
2011
+ if (need === void 0 || !sink.canReceive(need)) continue;
1544
2012
  if (except && except(sink)) continue;
1545
2013
  sink.push(type, payload, entry.seq);
1546
2014
  }
@@ -1782,6 +2250,14 @@ var HostBridge = class {
1782
2250
  } else if (key === "sessionListMetadata") {
1783
2251
  const meta = value;
1784
2252
  if (meta?.lastPromptAt) row.lastActivityTs = Math.max(row.lastActivityTs, meta.lastPromptAt);
2253
+ } else if (key === "sessionStats" || key === "tokenUsage") {
2254
+ const patch = sanitizeUsageStatsPatch(key, value);
2255
+ if (!patch) return;
2256
+ row.stats = {
2257
+ ...emptyUsageStats,
2258
+ ...row.stats ?? {},
2259
+ ...patch
2260
+ };
1785
2261
  } else return;
1786
2262
  this.pushSummary(row);
1787
2263
  }
@@ -2415,6 +2891,65 @@ function sanitizeTodoItems(items) {
2415
2891
  status: i.status
2416
2892
  }));
2417
2893
  }
2894
+ /** Zero-value stats snapshot used as the merge base for partial patches. */
2895
+ const emptyUsageStats = {
2896
+ turns: 0,
2897
+ steps: 0,
2898
+ llmMs: 0,
2899
+ toolMs: 0,
2900
+ ttftMs: 0,
2901
+ ttftSteps: 0,
2902
+ decodeMs: 0,
2903
+ decodeTokens: 0,
2904
+ inputTokens: 0,
2905
+ outputTokens: 0,
2906
+ cacheReadTokens: 0,
2907
+ cacheWriteTokens: 0
2908
+ };
2909
+ /** Coerce one host counter to a non-negative integer; junk becomes 0. */
2910
+ function usageCounter(value) {
2911
+ const n = typeof value === "number" ? value : Number(value);
2912
+ if (!Number.isFinite(n) || n < 0) return 0;
2913
+ return Math.floor(n);
2914
+ }
2915
+ const SESSION_STATS_COUNTERS = [
2916
+ "turns",
2917
+ "steps",
2918
+ "llmMs",
2919
+ "toolMs",
2920
+ "ttftMs",
2921
+ "ttftSteps",
2922
+ "decodeMs",
2923
+ "decodeTokens"
2924
+ ];
2925
+ const TOKEN_USAGE_COUNTERS = [
2926
+ "outputTokens",
2927
+ "cacheReadTokens",
2928
+ "cacheWriteTokens"
2929
+ ];
2930
+ /** Sanitize one `sessionStats` / `tokenUsage` projection value into the wire
2931
+ * stats fields; undefined when the payload carries nothing readable. Host
2932
+ * field names differ between the two projections (tokenUsage reports
2933
+ * `uncachedInputTokens`, the wire mirrors it as `inputTokens`). */
2934
+ function sanitizeUsageStatsPatch(key, value) {
2935
+ if (!value || typeof value !== "object") return void 0;
2936
+ const raw = value;
2937
+ const patch = {};
2938
+ if (key === "sessionStats") {
2939
+ for (const field of SESSION_STATS_COUNTERS) if (field in raw) patch[field] = usageCounter(raw[field]);
2940
+ } else {
2941
+ if ("uncachedInputTokens" in raw) patch.inputTokens = usageCounter(raw.uncachedInputTokens);
2942
+ for (const field of TOKEN_USAGE_COUNTERS) if (field in raw) patch[field] = usageCounter(raw[field]);
2943
+ }
2944
+ return Object.keys(patch).length > 0 ? patch : void 0;
2945
+ }
2946
+ /** Combine one session's `sessionStats` + `tokenUsage` projection values into
2947
+ * a complete wire stats object; undefined when neither carries anything. */
2948
+ function usageStatsOf(sessionStats, tokenUsage) {
2949
+ const patches = [sanitizeUsageStatsPatch("sessionStats", sessionStats), sanitizeUsageStatsPatch("tokenUsage", tokenUsage)].filter((patch) => patch !== void 0);
2950
+ if (patches.length === 0) return void 0;
2951
+ return Object.assign({}, emptyUsageStats, ...patches);
2952
+ }
2418
2953
  function toSummary(row, approvals, questions, workspace) {
2419
2954
  const values = row.projections?.values ?? {};
2420
2955
  const todos = Array.isArray(values.todos) ? values.todos : null;
@@ -2425,6 +2960,7 @@ function toSummary(row, approvals, questions, workspace) {
2425
2960
  const cwd = typeof row.cwd === "string" ? row.cwd : "";
2426
2961
  const label = workspace?.title ?? (cwd ? cwd.split("/").filter(Boolean).pop() : void 0);
2427
2962
  const todoItems = sanitizeTodoItems(todos);
2963
+ const stats = usageStatsOf(values.sessionStats, values.tokenUsage);
2428
2964
  return {
2429
2965
  id: row.sessionId,
2430
2966
  title: typeof values.title === "string" ? values.title : "",
@@ -2437,6 +2973,7 @@ function toSummary(row, approvals, questions, workspace) {
2437
2973
  todoItems: todoItems.length > 0 ? todoItems : null,
2438
2974
  pendingApproval,
2439
2975
  pendingQuestion,
2976
+ ...stats ? { stats } : {},
2440
2977
  workspaceLabel: label ?? null,
2441
2978
  workspaceId: workspace?.workspaceId ?? null,
2442
2979
  workspacePath: workspace?.path ?? (cwd || null)
@@ -3642,6 +4179,16 @@ function p8ToDer(pem) {
3642
4179
  }
3643
4180
  /** Pure payload builder so tests can assert the wire format without sockets. */
3644
4181
  function apnsPayload(notification) {
4182
+ if ("kind" in notification && notification.kind === "liveactivity") return { aps: {
4183
+ timestamp: notification.timestamp,
4184
+ event: notification.event,
4185
+ "content-state": notification.contentState,
4186
+ ...notification.event === "end" ? { "dismissal-date": notification.timestamp + 300 } : { "stale-date": notification.timestamp + 180 }
4187
+ } };
4188
+ if ("kind" in notification && notification.kind === "widget") return { aps: { "content-changed": true } };
4189
+ return alertPayload(notification);
4190
+ }
4191
+ function alertPayload(notification) {
3645
4192
  const timeSensitive = notification.category === "approval.required" || notification.category === "question.asked";
3646
4193
  return {
3647
4194
  aps: {
@@ -3651,17 +4198,38 @@ function apnsPayload(notification) {
3651
4198
  },
3652
4199
  sound: "default",
3653
4200
  category: notification.category,
3654
- "thread-id": notification.sessionId.slice(0, 64),
4201
+ "thread-id": notification.hostAudience ? createHash("sha256").update(`${notification.hostAudience}:${notification.sessionId}`).digest("hex") : notification.sessionId.slice(0, 64),
3655
4202
  ...timeSensitive ? { "interruption-level": "time-sensitive" } : {}
3656
4203
  },
4204
+ ...notification.hostAudience ? { hostAudience: notification.hostAudience } : {},
3657
4205
  sessionId: notification.sessionId,
3658
4206
  notificationId: notification.notificationId,
3659
4207
  kind: notification.category
3660
4208
  };
3661
4209
  }
4210
+ function pushHeaders(bundleId, notification) {
4211
+ if ("kind" in notification && notification.kind === "liveactivity") return {
4212
+ "apns-topic": bundleId + ".push-type.liveactivity",
4213
+ "apns-push-type": "liveactivity",
4214
+ "apns-priority": notification.event === "end" ? "10" : "5",
4215
+ "apns-expiration": String(notification.timestamp + 180)
4216
+ };
4217
+ if ("kind" in notification && notification.kind === "widget") return {
4218
+ "apns-topic": bundleId + ".push-type.widgets",
4219
+ "apns-push-type": "widgets",
4220
+ "apns-priority": "5",
4221
+ "apns-collapse-id": "widget-overview"
4222
+ };
4223
+ return {
4224
+ "apns-topic": bundleId,
4225
+ "apns-push-type": "alert",
4226
+ "apns-priority": "10",
4227
+ "apns-collapse-id": collapseIdFor(notification)
4228
+ };
4229
+ }
3662
4230
  /** collapse-id accepts ≤64 bytes of ASCII; keep it stable per session+event. */
3663
4231
  function collapseIdFor(notification) {
3664
- const raw = `${notification.category}:${notification.sessionId}`;
4232
+ const raw = `${notification.hostAudience ? notification.hostAudience + ":" : ""}${notification.category}:${notification.sessionId}`;
3665
4233
  const readable = raw.replace(/[^a-zA-Z0-9.:-]/g, "");
3666
4234
  const digest = createHash("sha256").update(raw, "utf8").digest("hex").slice(0, 12);
3667
4235
  return `${readable.slice(0, 51)}:${digest}`;
@@ -3738,11 +4306,8 @@ var ApnsClient = class {
3738
4306
  [":method"]: "POST",
3739
4307
  [":path"]: "/3/device/" + deviceToken,
3740
4308
  authorization: "bearer " + token,
3741
- "apns-topic": this.opts.bundleId,
3742
- "apns-push-type": "alert",
3743
- "apns-priority": "10",
4309
+ ...pushHeaders(this.opts.bundleId, notification),
3744
4310
  "apns-expiration": String(Math.floor(Date.now() / 1e3) + 3600),
3745
- "apns-collapse-id": collapseIdFor(notification),
3746
4311
  "content-type": "application/json",
3747
4312
  "content-length": String(Buffer.byteLength(body))
3748
4313
  });
@@ -4608,6 +5173,7 @@ const Config = z.object({
4608
5173
  "apns",
4609
5174
  "relay"
4610
5175
  ]).default("none"),
5176
+ contentMode: z.union(["preview", "generic"]).default("preview"),
4611
5177
  teamId: z.string().default(""),
4612
5178
  keyId: z.string().default(""),
4613
5179
  keyPath: z.string().default(join(bridgeDataDir(), "apns", "AuthKey.p8")),
@@ -4616,6 +5182,7 @@ const Config = z.object({
4616
5182
  relayToken: z.string().default("")
4617
5183
  }).default({
4618
5184
  provider: "none",
5185
+ contentMode: "preview",
4619
5186
  teamId: "",
4620
5187
  keyId: "",
4621
5188
  keyPath: join(bridgeDataDir(), "apns", "AuthKey.p8"),
@@ -4793,6 +5360,11 @@ var AuthRateLimiter = class {
4793
5360
  function shouldPrunePushToken(outcome, reason) {
4794
5361
  return outcome === "invalid-token" && (reason === "Unregistered" || reason === "ExpiredToken");
4795
5362
  }
5363
+ /** APNs requires both registration and the same content permission as WS. */
5364
+ function mayReceivePush(device, notification) {
5365
+ const scope = pushScopeFor("s2c.notify", notification);
5366
+ return scope !== void 0 && device.scopes?.includes("notifications.register") === true && device.scopes.includes(scope);
5367
+ }
4796
5368
  /**
4797
5369
  * Zero-touch relay self-heal: HTTP 401 means the relay no longer honors the
4798
5370
  * cached credential. Only auto-enrolled cells with a still-current token may
@@ -4802,6 +5374,132 @@ function shouldPrunePushToken(outcome, reason) {
4802
5374
  function shouldReEnrollRelayToken(transport, outcome, reason, opts) {
4803
5375
  return transport === "relay" && outcome === "failed" && reason === "HTTP 401" && opts.hasEnrollKey && opts.usedCellToken && opts.tokenStillCurrent;
4804
5376
  }
5377
+ /** Apply before either transport receives the payload, including the Relay. */
5378
+ function pushContent(notification, mode) {
5379
+ if (mode !== "generic") return notification;
5380
+ const bodies = {
5381
+ "turn.completed": "A task has completed.",
5382
+ "approval.required": "An approval needs your attention.",
5383
+ "question.asked": "A question needs your answer.",
5384
+ "session.error": "A task needs your attention."
5385
+ };
5386
+ return {
5387
+ ...notification,
5388
+ title: "DeepPilot",
5389
+ body: bodies[notification.category] ?? "Open DeepPilot for an update."
5390
+ };
5391
+ }
5392
+ //#endregion
5393
+ //#region src/widget-push.ts
5394
+ /** Coalesce bursts and bound continuous updates, without starving the trailing
5395
+ * state. One scheduler per host, not per token or per streaming text frame. */
5396
+ var WidgetPushScheduler = class {
5397
+ send;
5398
+ intervalMs;
5399
+ timer;
5400
+ lastSent = 0;
5401
+ disposed = false;
5402
+ sending = false;
5403
+ dirty = false;
5404
+ constructor(send, intervalMs = 3e4) {
5405
+ this.send = send;
5406
+ this.intervalMs = intervalMs;
5407
+ }
5408
+ changed() {
5409
+ if (this.disposed) return;
5410
+ this.dirty = true;
5411
+ if (this.timer || this.sending) return;
5412
+ this.timer = setTimeout(() => {
5413
+ this.timer = void 0;
5414
+ this.dirty = false;
5415
+ this.sending = true;
5416
+ this.lastSent = Date.now();
5417
+ this.send().catch(() => {}).finally(() => {
5418
+ this.sending = false;
5419
+ if (this.dirty) this.changed();
5420
+ });
5421
+ }, Math.max(Math.min(1e3, this.intervalMs), this.intervalMs - (Date.now() - this.lastSent)));
5422
+ this.timer.unref();
5423
+ }
5424
+ dispose() {
5425
+ this.disposed = true;
5426
+ if (this.timer) clearTimeout(this.timer);
5427
+ this.timer = void 0;
5428
+ }
5429
+ };
5430
+ //#endregion
5431
+ //#region src/live-activity.ts
5432
+ function liveActivityState(session) {
5433
+ const total = Math.max(0, session?.todos?.total ?? 0);
5434
+ const done = Math.min(total, Math.max(0, session?.todos?.done ?? 0));
5435
+ const task = session?.todoItems?.find((item) => item.status === "in_progress") ?? session?.todoItems?.find((item) => item.status === "pending");
5436
+ return {
5437
+ title: Array.from(session?.title ?? "").slice(0, 100).join(""),
5438
+ task: Array.from(task?.content ?? "").slice(0, 160).join(""),
5439
+ done,
5440
+ total,
5441
+ phase: !session ? "unavailable" : session.pendingApproval ? "approval" : session.pendingQuestion ? "question" : session.status === "running" ? "running" : session.status === "idle" ? "ended" : "unavailable"
5442
+ };
5443
+ }
5444
+ /** Persist terminal state before coalescing so a rapid next round cannot revive an activity. */
5445
+ var LiveActivityPushManager = class {
5446
+ devices;
5447
+ send;
5448
+ latest = /* @__PURE__ */ new Map();
5449
+ sent = /* @__PURE__ */ new Map();
5450
+ scheduler;
5451
+ constructor(devices, send, intervalMs = 15e3) {
5452
+ this.devices = devices;
5453
+ this.send = send;
5454
+ this.scheduler = new WidgetPushScheduler(() => this.flush(), intervalMs);
5455
+ }
5456
+ changed(sessions) {
5457
+ this.latest = new Map(sessions.map((session) => [session.id, session]));
5458
+ for (const device of this.devices()?.list() ?? []) {
5459
+ const r = device.liveActivity;
5460
+ if (!r) continue;
5461
+ const state = liveActivityState(this.latest.get(r.sessionId));
5462
+ if (state.phase === "ended" || state.phase === "unavailable") this.devices()?.endLiveActivity(device.deviceId, r.token, state);
5463
+ }
5464
+ this.scheduler.changed();
5465
+ }
5466
+ async flush() {
5467
+ const devices = this.devices();
5468
+ if (!devices) return;
5469
+ const valid = /* @__PURE__ */ new Set();
5470
+ for (const device of devices.list()) {
5471
+ const r = device.liveActivity;
5472
+ if (!r) continue;
5473
+ const key = device.deviceId + ":" + r.token;
5474
+ valid.add(key);
5475
+ if (device.revokedAt !== void 0 || ![
5476
+ "notifications.register",
5477
+ "sessions.read",
5478
+ "interactions.respond"
5479
+ ].every((scope) => device.scopes?.some((value) => value === scope)) || r.expiresAt <= Date.now()) {
5480
+ devices.clearLiveActivity(device.deviceId, r.activityId, r.token);
5481
+ continue;
5482
+ }
5483
+ const state = r.endedState ?? liveActivityState(this.latest.get(r.sessionId));
5484
+ const fingerprint = JSON.stringify(state);
5485
+ if (this.sent.get(key) === fingerprint) continue;
5486
+ if (devices.authorized(device.deviceId)?.liveActivity?.token !== r.token) continue;
5487
+ const result = await this.send(r.token, r.environment, {
5488
+ kind: "liveactivity",
5489
+ event: r.endedState ? "end" : "update",
5490
+ timestamp: Math.floor(Date.now() / 1e3),
5491
+ contentState: state
5492
+ }).catch(() => ({ outcome: "failed" }));
5493
+ if (result.outcome === "sent") this.sent.set(key, fingerprint);
5494
+ else if (result.outcome === "invalid-token") devices.clearLiveActivity(device.deviceId, r.activityId, r.token);
5495
+ else this.scheduler.changed();
5496
+ }
5497
+ for (const key of this.sent.keys()) if (!valid.has(key)) this.sent.delete(key);
5498
+ }
5499
+ dispose() {
5500
+ this.scheduler.dispose();
5501
+ }
5502
+ };
4805
5503
  //#endregion
4806
5504
  //#region src/phone-server.ts
4807
5505
  /**
@@ -5250,20 +5948,78 @@ function apply(ctx, options) {
5250
5948
  * token. Rules:
5251
5949
  * - devices with a live WebSocket are skipped (they already got the WS
5252
5950
  * frame and will raise the local notification themselves);
5951
+ * - only devices granted `notifications.register` are candidates — a
5952
+ * device whose scope was revoked must not receive offline pushes
5953
+ * (R1/P2 S→C permission policy);
5253
5954
  * - each device is delivered on ITS registered environment (the build
5254
5955
  * kind it self-reported), so sandbox and production devices coexist;
5255
5956
  * - the device's per-category switches suppress muted categories;
5256
5957
  * - only APNs' terminal Unregistered/ExpiredToken verdicts prune storage;
5257
5958
  * BadDeviceToken may be an environment mismatch and stays diagnosable.
5258
5959
  */
5960
+ const widgetPush = new WidgetPushScheduler(async () => {
5961
+ if (!enabledNow()) return;
5962
+ const resolved = resolvePushConfig(currentConfig());
5963
+ if (!resolved.ok) return;
5964
+ const devices = auth.devices;
5965
+ const send = await senderFor(resolved.value);
5966
+ if (!devices || !send) return;
5967
+ const sent = /* @__PURE__ */ new Set();
5968
+ for (const device of devices.list()) {
5969
+ const registration = device.widgetApns;
5970
+ if (!registration || device.revokedAt !== void 0 || ![
5971
+ "notifications.register",
5972
+ "sessions.read",
5973
+ "interactions.respond"
5974
+ ].every((scope) => device.scopes?.includes(scope)) || Date.now() - registration.updatedAt > 6048e5) continue;
5975
+ const key = registration.environment + ":" + registration.token;
5976
+ if (sent.has(key)) continue;
5977
+ sent.add(key);
5978
+ const { outcome, reason } = await send({
5979
+ deviceToken: registration.token,
5980
+ environment: registration.environment,
5981
+ notification: { kind: "widget" }
5982
+ });
5983
+ if (shouldPrunePushToken(outcome, reason)) devices.clearWidgetPushToken(device.deviceId, registration.token);
5984
+ if (resolved.value.kind === "relay" && reason === "HTTP 401" && enrollmentCell.token === resolved.value.token && enrollmentCell.enrollKey) {
5985
+ enrollmentCell.token = void 0;
5986
+ persistEnrollment();
5987
+ await ensureRelayEnrolled(resolved.value.url);
5988
+ }
5989
+ }
5990
+ });
5991
+ const liveActivityPush = new LiveActivityPushManager(() => auth.devices ?? void 0, async (deviceToken, environment, notification) => {
5992
+ if (!enabledNow()) return { outcome: "failed" };
5993
+ const resolved = resolvePushConfig(currentConfig());
5994
+ if (!resolved.ok) return { outcome: "failed" };
5995
+ const send = await senderFor(resolved.value);
5996
+ if (!send) return { outcome: "failed" };
5997
+ const result = await send({
5998
+ deviceToken,
5999
+ environment,
6000
+ notification
6001
+ });
6002
+ if (resolved.value.kind === "relay" && result.reason === "HTTP 401" && enrollmentCell.token === resolved.value.token && enrollmentCell.enrollKey) {
6003
+ enrollmentCell.token = void 0;
6004
+ persistEnrollment();
6005
+ await ensureRelayEnrolled(resolved.value.url);
6006
+ }
6007
+ return result;
6008
+ });
5259
6009
  const makePushOutlet = () => ({
6010
+ widgetChanged: () => widgetPush.changed(),
6011
+ liveActivityChanged: (sessions) => liveActivityPush.changed(sessions),
5260
6012
  isAvailable: () => {
5261
6013
  const resolved = resolvePushConfig(currentConfig());
5262
6014
  if (!resolved.ok) return false;
5263
6015
  if (resolved.value.kind === "relay" && !resolved.value.token) return false;
5264
6016
  return true;
5265
6017
  },
5266
- fanOut: (notification) => {
6018
+ fanOut: (sourceNotification) => {
6019
+ const notification = pushContent({
6020
+ ...sourceNotification,
6021
+ hostAudience: auth.audience ?? void 0
6022
+ }, currentConfig().push?.contentMode);
5267
6023
  (async () => {
5268
6024
  let resolved = resolvePushConfig(currentConfig());
5269
6025
  if (!resolved.ok && resolved.reason === "relay token not enrolled yet") {
@@ -5280,12 +6036,16 @@ function apply(ctx, options) {
5280
6036
  const connectedIds = /* @__PURE__ */ new Set();
5281
6037
  for (const connection of connections) {
5282
6038
  const id = connection.connectedDeviceId;
5283
- if (id) connectedIds.add(id);
6039
+ if (id && connection.suppressesAlertPush) connectedIds.add(id);
5284
6040
  }
5285
6041
  const candidates = devices.list().filter((device) => {
5286
6042
  const registration = device.apns;
5287
6043
  if (!registration) return false;
5288
6044
  if (connectedIds.has(device.deviceId)) return false;
6045
+ if (!mayReceivePush(device, notification)) {
6046
+ if (currentConfig().debug === true) log(`push skip "${device.deviceName}": notification permission not granted`);
6047
+ return false;
6048
+ }
5289
6049
  if (registration.categories?.[notification.category] === false) {
5290
6050
  if (currentConfig().debug === true) log(`push skip "${device.deviceName}": category ${notification.category} muted`);
5291
6051
  return false;
@@ -5840,7 +6600,7 @@ function apply(ctx, options) {
5840
6600
  log("dsh 0.1.2 session bridge unavailable: " + String(error));
5841
6601
  return;
5842
6602
  }
5843
- const bridge = new HostBridge(proxy, cfg.historyBufferMax);
6603
+ const bridge = new HostBridge(proxy, cfg.historyBufferMax, join(dataDir, "prompt-deliveries-v1.json"));
5844
6604
  bridge.setPushOutlet(makePushOutlet());
5845
6605
  state.bridge = bridge;
5846
6606
  bridge.start();
@@ -5883,6 +6643,8 @@ function apply(ctx, options) {
5883
6643
  const sender = cachedSender;
5884
6644
  cachedSender = void 0;
5885
6645
  updateChecker.dispose();
6646
+ widgetPush.dispose();
6647
+ liveActivityPush.dispose();
5886
6648
  const wssClosed = new Promise((resolve) => wss.close(() => resolve()));
5887
6649
  await Promise.allSettled([
5888
6650
  enrollmentWriteTail,
@@ -5892,6 +6654,6 @@ function apply(ctx, options) {
5892
6654
  }, "deeppilot: process resources");
5893
6655
  }
5894
6656
  //#endregion
5895
- export { Config, HostBridge, apply, inject, name, shouldPrunePushToken, shouldReEnrollRelayToken };
6657
+ export { Config, HostBridge, apply, inject, mayReceivePush, name, shouldPrunePushToken, shouldReEnrollRelayToken };
5896
6658
 
5897
6659
  //# sourceMappingURL=index.js.map