dsh-deeppilot 0.7.1 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/index.d.ts CHANGED
@@ -264,6 +264,13 @@ interface SessionsApiLike {
264
264
  };
265
265
  data: string;
266
266
  }>>;
267
+ /** DSH 0.1.7+: complete projection baseline for one session; null when the session is gone. */
268
+ projections?(req: RpcRequestLike<{
269
+ sessionId: string;
270
+ }>): Promise<RpcResponseLike<{
271
+ asOfSeq: number;
272
+ values: Record<string, unknown>;
273
+ } | null>>;
267
274
  }
268
275
  interface WorkspaceApiLike {
269
276
  list?(req: RpcRequestLike<Record<string, never>>): Promise<RpcResponseLike<{
@@ -439,6 +446,12 @@ interface ApiProxyLike {
439
446
  sessions: SessionsApiLike;
440
447
  workspace?: WorkspaceApiLike;
441
448
  host?: HostApiLike;
449
+ /**
450
+ * DSH 0.1.7 added `session.projections`. Absent/false keeps the bridge on
451
+ * its existing list-row projection hints — the call is gated on this flag so
452
+ * older hosts never see a failing RPC.
453
+ */
454
+ supportsProjections?: boolean;
442
455
  respond(message: {
443
456
  type: 'client-response';
444
457
  rpcId: string;
@@ -552,6 +565,7 @@ declare class HostBridge {
552
565
  push: boolean;
553
566
  widgetPush: boolean;
554
567
  liveActivityPush: boolean;
568
+ deviceRevoke: boolean;
555
569
  };
556
570
  diagnostic(message: string): void;
557
571
  currentCursor(): number;
@@ -624,6 +638,13 @@ declare class HostBridge {
624
638
  private loadApprovalArguments;
625
639
  /** Tail history for an opened session; pushes s2c.session.tail to the sink. */
626
640
  openSession(sink: BridgeSink, sessionId: string, tailCount: number): Promise<boolean>;
641
+ /**
642
+ * Pull one session's projection baseline (DSH 0.1.7+) and fold every key
643
+ * through applyProjection — unknown keys are ignored there, `null` means the
644
+ * session no longer exists, and any failure degrades to a diagnostic: opening
645
+ * a session must never fail because a baseline could not be read.
646
+ */
647
+ private refreshProjections;
627
648
  historyPage(sessionId: string, beforeSeq: number, limit: number): Promise<{
628
649
  sessionId: string;
629
650
  messages: MessageProjection[];
@@ -722,103 +743,103 @@ interface Config {
722
743
  relayToken?: string;
723
744
  };
724
745
  }
725
- declare const Config: z<Schemastery.ObjectS<{
726
- enabled: z<boolean, boolean>;
727
- devicesPath: z<string, string>;
728
- historyBufferMax: z<number, number>;
729
- debug: z<boolean, boolean>;
730
- local: z<Schemastery.ObjectS<{
731
- enabled: z<boolean, boolean>;
732
- port: z<number, number>;
733
- }>, Schemastery.ObjectT<{
734
- enabled: z<boolean, boolean>;
735
- port: z<number, number>;
736
- }>>;
737
- remote: z<Schemastery.ObjectS<{
738
- enabled: z<boolean, boolean>;
739
- provider: z<"tailscale-funnel", "tailscale-funnel">;
740
- hostname: z<string, string>;
741
- statePath: z<string, string>;
742
- helperPath: z<string, string>;
743
- funnelPort: z<443 | 8443 | 10000, 443 | 8443 | 10000>;
744
- maxConnectionsPerSource: z<number, number>;
745
- }>, Schemastery.ObjectT<{
746
- enabled: z<boolean, boolean>;
747
- provider: z<"tailscale-funnel", "tailscale-funnel">;
748
- hostname: z<string, string>;
749
- statePath: z<string, string>;
750
- helperPath: z<string, string>;
751
- funnelPort: z<443 | 8443 | 10000, 443 | 8443 | 10000>;
752
- maxConnectionsPerSource: z<number, number>;
753
- }>>;
754
- push: z<Schemastery.ObjectS<{
755
- provider: z<"apns" | "none" | "relay", "apns" | "none" | "relay">;
756
- contentMode: z<"generic" | "preview", "generic" | "preview">;
757
- teamId: z<string, string>;
758
- keyId: z<string, string>;
759
- keyPath: z<string, string>;
760
- bundleId: z<string, string>;
761
- relayUrl: z<string, string>;
762
- relayToken: z<string, string>;
763
- }>, Schemastery.ObjectT<{
764
- provider: z<"apns" | "none" | "relay", "apns" | "none" | "relay">;
765
- contentMode: z<"generic" | "preview", "generic" | "preview">;
766
- teamId: z<string, string>;
767
- keyId: z<string, string>;
768
- keyPath: z<string, string>;
769
- bundleId: z<string, string>;
770
- relayUrl: z<string, string>;
771
- relayToken: z<string, string>;
772
- }>>;
773
- }>, Schemastery.ObjectT<{
774
- enabled: z<boolean, boolean>;
775
- devicesPath: z<string, string>;
776
- historyBufferMax: z<number, number>;
777
- debug: z<boolean, boolean>;
778
- local: z<Schemastery.ObjectS<{
779
- enabled: z<boolean, boolean>;
780
- port: z<number, number>;
781
- }>, Schemastery.ObjectT<{
782
- enabled: z<boolean, boolean>;
783
- port: z<number, number>;
784
- }>>;
785
- remote: z<Schemastery.ObjectS<{
786
- enabled: z<boolean, boolean>;
787
- provider: z<"tailscale-funnel", "tailscale-funnel">;
788
- hostname: z<string, string>;
789
- statePath: z<string, string>;
790
- helperPath: z<string, string>;
791
- funnelPort: z<443 | 8443 | 10000, 443 | 8443 | 10000>;
792
- maxConnectionsPerSource: z<number, number>;
793
- }>, Schemastery.ObjectT<{
794
- enabled: z<boolean, boolean>;
795
- provider: z<"tailscale-funnel", "tailscale-funnel">;
796
- hostname: z<string, string>;
797
- statePath: z<string, string>;
798
- helperPath: z<string, string>;
799
- funnelPort: z<443 | 8443 | 10000, 443 | 8443 | 10000>;
800
- maxConnectionsPerSource: z<number, number>;
801
- }>>;
802
- push: z<Schemastery.ObjectS<{
803
- provider: z<"apns" | "none" | "relay", "apns" | "none" | "relay">;
804
- contentMode: z<"generic" | "preview", "generic" | "preview">;
805
- teamId: z<string, string>;
806
- keyId: z<string, string>;
807
- keyPath: z<string, string>;
808
- bundleId: z<string, string>;
809
- relayUrl: z<string, string>;
810
- relayToken: z<string, string>;
811
- }>, Schemastery.ObjectT<{
812
- provider: z<"apns" | "none" | "relay", "apns" | "none" | "relay">;
813
- contentMode: z<"generic" | "preview", "generic" | "preview">;
814
- teamId: z<string, string>;
815
- keyId: z<string, string>;
816
- keyPath: z<string, string>;
817
- bundleId: z<string, string>;
818
- relayUrl: z<string, string>;
819
- relayToken: z<string, string>;
820
- }>>;
821
- }>>;
746
+ declare const Config: z<Schemastery.ObjectS<NoInfer<{
747
+ enabled: z<boolean, boolean, "defined">;
748
+ devicesPath: z<string, string, "defined">;
749
+ historyBufferMax: z<number, number, "defined">;
750
+ debug: z<boolean, boolean, "defined">;
751
+ local: z<Schemastery.ObjectS<NoInfer<{
752
+ enabled: z<boolean, boolean, "defined">;
753
+ port: z<number, number, "defined">;
754
+ }>>, Schemastery.ObjectT<NoInfer<{
755
+ enabled: z<boolean, boolean, "defined">;
756
+ port: z<number, number, "defined">;
757
+ }>>, "defined">;
758
+ remote: z<Schemastery.ObjectS<NoInfer<{
759
+ enabled: z<boolean, boolean, "defined">;
760
+ provider: z<"tailscale-funnel", "tailscale-funnel", "defined">;
761
+ hostname: z<string, string, "defined">;
762
+ statePath: z<string, string, "defined">;
763
+ helperPath: z<string, string, "defined">;
764
+ funnelPort: z<443 | 8443 | 10000, 443 | 8443 | 10000, "defined">;
765
+ maxConnectionsPerSource: z<number, number, "defined">;
766
+ }>>, Schemastery.ObjectT<NoInfer<{
767
+ enabled: z<boolean, boolean, "defined">;
768
+ provider: z<"tailscale-funnel", "tailscale-funnel", "defined">;
769
+ hostname: z<string, string, "defined">;
770
+ statePath: z<string, string, "defined">;
771
+ helperPath: z<string, string, "defined">;
772
+ funnelPort: z<443 | 8443 | 10000, 443 | 8443 | 10000, "defined">;
773
+ maxConnectionsPerSource: z<number, number, "defined">;
774
+ }>>, "defined">;
775
+ push: z<Schemastery.ObjectS<NoInfer<{
776
+ provider: z<"apns" | "none" | "relay", "apns" | "none" | "relay", "defined">;
777
+ contentMode: z<"generic" | "preview", "generic" | "preview", "defined">;
778
+ teamId: z<string, string, "defined">;
779
+ keyId: z<string, string, "defined">;
780
+ keyPath: z<string, string, "defined">;
781
+ bundleId: z<string, string, "defined">;
782
+ relayUrl: z<string, string, "defined">;
783
+ relayToken: z<string, string, "defined">;
784
+ }>>, Schemastery.ObjectT<NoInfer<{
785
+ provider: z<"apns" | "none" | "relay", "apns" | "none" | "relay", "defined">;
786
+ contentMode: z<"generic" | "preview", "generic" | "preview", "defined">;
787
+ teamId: z<string, string, "defined">;
788
+ keyId: z<string, string, "defined">;
789
+ keyPath: z<string, string, "defined">;
790
+ bundleId: z<string, string, "defined">;
791
+ relayUrl: z<string, string, "defined">;
792
+ relayToken: z<string, string, "defined">;
793
+ }>>, "defined">;
794
+ }>>, Schemastery.ObjectT<NoInfer<{
795
+ enabled: z<boolean, boolean, "defined">;
796
+ devicesPath: z<string, string, "defined">;
797
+ historyBufferMax: z<number, number, "defined">;
798
+ debug: z<boolean, boolean, "defined">;
799
+ local: z<Schemastery.ObjectS<NoInfer<{
800
+ enabled: z<boolean, boolean, "defined">;
801
+ port: z<number, number, "defined">;
802
+ }>>, Schemastery.ObjectT<NoInfer<{
803
+ enabled: z<boolean, boolean, "defined">;
804
+ port: z<number, number, "defined">;
805
+ }>>, "defined">;
806
+ remote: z<Schemastery.ObjectS<NoInfer<{
807
+ enabled: z<boolean, boolean, "defined">;
808
+ provider: z<"tailscale-funnel", "tailscale-funnel", "defined">;
809
+ hostname: z<string, string, "defined">;
810
+ statePath: z<string, string, "defined">;
811
+ helperPath: z<string, string, "defined">;
812
+ funnelPort: z<443 | 8443 | 10000, 443 | 8443 | 10000, "defined">;
813
+ maxConnectionsPerSource: z<number, number, "defined">;
814
+ }>>, Schemastery.ObjectT<NoInfer<{
815
+ enabled: z<boolean, boolean, "defined">;
816
+ provider: z<"tailscale-funnel", "tailscale-funnel", "defined">;
817
+ hostname: z<string, string, "defined">;
818
+ statePath: z<string, string, "defined">;
819
+ helperPath: z<string, string, "defined">;
820
+ funnelPort: z<443 | 8443 | 10000, 443 | 8443 | 10000, "defined">;
821
+ maxConnectionsPerSource: z<number, number, "defined">;
822
+ }>>, "defined">;
823
+ push: z<Schemastery.ObjectS<NoInfer<{
824
+ provider: z<"apns" | "none" | "relay", "apns" | "none" | "relay", "defined">;
825
+ contentMode: z<"generic" | "preview", "generic" | "preview", "defined">;
826
+ teamId: z<string, string, "defined">;
827
+ keyId: z<string, string, "defined">;
828
+ keyPath: z<string, string, "defined">;
829
+ bundleId: z<string, string, "defined">;
830
+ relayUrl: z<string, string, "defined">;
831
+ relayToken: z<string, string, "defined">;
832
+ }>>, Schemastery.ObjectT<NoInfer<{
833
+ provider: z<"apns" | "none" | "relay", "apns" | "none" | "relay", "defined">;
834
+ contentMode: z<"generic" | "preview", "generic" | "preview", "defined">;
835
+ teamId: z<string, string, "defined">;
836
+ keyId: z<string, string, "defined">;
837
+ keyPath: z<string, string, "defined">;
838
+ bundleId: z<string, string, "defined">;
839
+ relayUrl: z<string, string, "defined">;
840
+ relayToken: z<string, string, "defined">;
841
+ }>>, "defined">;
842
+ }>>, "plain">;
822
843
  //#endregion
823
844
  //#region src/push-policy.d.ts
824
845
  /** Prune only when the provider supplies an authoritative token-lifecycle verdict. */
package/lib/index.js CHANGED
@@ -203,7 +203,7 @@ function validateRequest(type, value) {
203
203
  "c2s.workspaces.list",
204
204
  "c2s.directory.pick"
205
205
  ]);
206
- if (value === void 0 && (type === "c2s.session.create" || type === "c2s.directory.list")) value = {};
206
+ if (value === void 0 && (type === "c2s.session.create" || type === "c2s.directory.list" || type === "c2s.device.revoke")) value = {};
207
207
  if (value == null && empty.has(type)) return;
208
208
  if (value === null || typeof value !== "object" || Array.isArray(value)) return "payload must be an object";
209
209
  const p = value;
@@ -213,6 +213,7 @@ function validateRequest(type, value) {
213
213
  if (type.startsWith("c2s.session.") && type !== "c2s.session.create" && !text(p.sessionId)) return "invalid sessionId";
214
214
  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";
215
215
  if (type === "c2s.directory.list" && !optional("path", (v) => text(v, 32768, false))) return "invalid path";
216
+ if (type === "c2s.device.revoke" && !optional("deviceId", (v) => text(v))) return "invalid deviceId";
216
217
  if (type === "c2s.workspace.create" && !text(p.path, 32768)) return "invalid path";
217
218
  if (type === "c2s.session.open" && !optional("tailCount", (v) => integer(v, 1, 1e4))) return "invalid tailCount";
218
219
  if (type === "c2s.session.history" && (!integer(p.beforeSeq, 0, Number.MAX_SAFE_INTEGER) || !optional("limit", (v) => integer(v, 1, 500)))) return "invalid history range";
@@ -530,6 +531,7 @@ var DeviceStore = class DeviceStore {
530
531
  }
531
532
  const current = record.apns;
532
533
  if (current && current.token === next.token && current.environment === next.environment && JSON.stringify(current.categories ?? {}) === JSON.stringify(next.categories ?? {})) return;
534
+ this.detachDuplicateToken("apns", normalized, deviceId);
533
535
  record.apns = next;
534
536
  this.flush();
535
537
  }
@@ -566,10 +568,12 @@ var DeviceStore = class DeviceStore {
566
568
  setWidgetPushToken(deviceId, token, environment, now) {
567
569
  const record = this.authorized(deviceId);
568
570
  if (!record || !isValidApnsToken(token)) return;
571
+ const normalized = token.toLowerCase();
569
572
  const previous = record.widgetApns;
570
- if (previous?.token === token && previous.environment === environment && now - previous.updatedAt < 36e5) return;
573
+ if (previous?.token === normalized && previous.environment === environment && now - previous.updatedAt < 36e5) return;
574
+ this.detachDuplicateToken("widgetApns", normalized, deviceId);
571
575
  record.widgetApns = {
572
- token,
576
+ token: normalized,
573
577
  environment,
574
578
  updatedAt: now
575
579
  };
@@ -582,6 +586,22 @@ var DeviceStore = class DeviceStore {
582
586
  delete record.widgetApns;
583
587
  this.flush();
584
588
  }
589
+ /**
590
+ * One physical device owns one APNs token, but a re-pair or a botched unbind
591
+ * can leave the same token attached to a second deviceId. Registering it
592
+ * under `keepDeviceId` detaches it from every other record — only that one
593
+ * token field, never the other record's pairing key or scopes. No flush of
594
+ * its own: the caller batches it with the write that follows, so a single
595
+ * registry write covers both changes.
596
+ */
597
+ detachDuplicateToken(field, token, keepDeviceId) {
598
+ for (const record of this.devices.values()) {
599
+ if (record.deviceId === keepDeviceId) continue;
600
+ if (field === "apns") {
601
+ if (record.apns?.token === token) delete record.apns;
602
+ } else if (record.widgetApns?.token === token) delete record.widgetApns;
603
+ }
604
+ }
585
605
  /** Serialized so concurrent touches can never interleave half-written JSON. */
586
606
  flush() {
587
607
  const next = this.flushTail.then(() => this.writeFile());
@@ -640,6 +660,7 @@ function isEnvelope(value) {
640
660
  function requiredScope(type) {
641
661
  if (typeof type !== "string") return void 0;
642
662
  if (type === "c2s.ping" || type === "c2s.resume") return void 0;
663
+ if (type === "c2s.device.revoke") return void 0;
643
664
  if (type === "c2s.session.sendPrompt" || type === "c2s.session.delivery") return "prompt.send";
644
665
  if (type === "c2s.pending.list") return "interactions.respond";
645
666
  if (type === "c2s.approval.respond" || type === "c2s.question.respond") return "interactions.respond";
@@ -712,6 +733,12 @@ var BridgeConnection = class {
712
733
  authenticated = false;
713
734
  authenticationSettled = false;
714
735
  closed = false;
736
+ /**
737
+ * Set the moment c2s.device.revoke tears this device down. `ws.close()` is
738
+ * asynchronous, so frames that arrive while the 4401 close is in flight must
739
+ * already be refused — a revoked device may not keep driving the bridge.
740
+ */
741
+ revoked = false;
715
742
  helloTimer;
716
743
  openSessions = /* @__PURE__ */ new Set();
717
744
  /**
@@ -868,6 +895,7 @@ var BridgeConnection = class {
868
895
  }
869
896
  async onMessage(raw) {
870
897
  this.lastActivity = Date.now();
898
+ if (this.revoked) return;
871
899
  if (!this.authenticated && raw.length > 65536) {
872
900
  this.close(1009, "pre-auth frame too large");
873
901
  return;
@@ -906,7 +934,9 @@ var BridgeConnection = class {
906
934
  "c2s.sessions.list",
907
935
  "c2s.pending.list",
908
936
  "c2s.widget.push.register"
909
- ].includes(env.type)) return this.fail(env.id, "E_FORBIDDEN", "widget connection is read-only");
937
+ ].includes(env.type)) {
938
+ if (env.type !== "c2s.device.revoke") return this.fail(env.id, "E_FORBIDDEN", "widget connection is read-only");
939
+ }
910
940
  const required = requiredScope(env.type);
911
941
  if (required !== void 0 && !this.scopes.has(required)) {
912
942
  this.fail(env.id, "E_FORBIDDEN", `scope ${required} required`);
@@ -1164,6 +1194,23 @@ var BridgeConnection = class {
1164
1194
  this.send("s2c.ack", {}, env.id);
1165
1195
  return;
1166
1196
  }
1197
+ case "c2s.device.revoke": {
1198
+ const p = env.payload;
1199
+ if (p?.deviceId !== void 0 && typeof p.deviceId !== "string") return this.fail(env.id, "E_PROTOCOL", "deviceId must be a string");
1200
+ if (typeof p?.deviceId === "string" && p.deviceId !== this.deviceId) return this.fail(env.id, "E_FORBIDDEN", "deviceId does not match the authenticated device");
1201
+ if (this.widgetClient) return this.fail(env.id, "E_FORBIDDEN", "widget connection cannot revoke its device");
1202
+ const deviceId = this.deviceId;
1203
+ this.deps.devices.revoke(deviceId, Date.now());
1204
+ this.revoked = true;
1205
+ try {
1206
+ await this.deps.onDeviceRevoke?.(deviceId, this);
1207
+ } catch (error) {
1208
+ if (this.deps.debug === true) this.deps.log("device revoke hook failed: " + String(error));
1209
+ }
1210
+ this.send("s2c.ack", { revoked: true }, env.id);
1211
+ this.close(4401, "device revoked");
1212
+ return;
1213
+ }
1167
1214
  case "c2s.liveActivity.unregister": {
1168
1215
  const p = env.payload;
1169
1216
  if (this.pendingLiveActivityId === p.activityId) {
@@ -1238,6 +1285,7 @@ var BridgeConnection = class {
1238
1285
  }
1239
1286
  }
1240
1287
  async prove(env) {
1288
+ if (this.revoked) return;
1241
1289
  const p = env.payload ?? {};
1242
1290
  if (!p.deviceId) {
1243
1291
  this.fail(env.id, "E_PROTOCOL", "deviceId required");
@@ -1891,7 +1939,8 @@ var HostBridge = class {
1891
1939
  projectSelection: typeof this.apiProxy.workspace?.list === "function" && typeof this.apiProxy.workspace?.create === "function",
1892
1940
  push: this.pushOutlet?.isAvailable() === true,
1893
1941
  widgetPush: true,
1894
- liveActivityPush: true
1942
+ liveActivityPush: true,
1943
+ deviceRevoke: true
1895
1944
  };
1896
1945
  }
1897
1946
  diagnostic(message) {
@@ -2430,11 +2479,38 @@ var HostBridge = class {
2430
2479
  hasMore: Boolean(result.hasMore) || page.dropped > 0
2431
2480
  });
2432
2481
  this.deriveTitleFallback(sessionId, messages);
2482
+ if (this.apiProxy.supportsProjections === true) this.refreshProjections(sessionId);
2433
2483
  return true;
2434
2484
  } catch {
2435
2485
  return false;
2436
2486
  }
2437
2487
  }
2488
+ /**
2489
+ * Pull one session's projection baseline (DSH 0.1.7+) and fold every key
2490
+ * through applyProjection — unknown keys are ignored there, `null` means the
2491
+ * session no longer exists, and any failure degrades to a diagnostic: opening
2492
+ * a session must never fail because a baseline could not be read.
2493
+ */
2494
+ async refreshProjections(sessionId) {
2495
+ try {
2496
+ const response = await this.apiProxy.sessions.projections?.({
2497
+ rpcId: randomUUID(),
2498
+ payload: { sessionId }
2499
+ });
2500
+ if (this.disposed) return;
2501
+ const result = response?.result;
2502
+ if (!result) return;
2503
+ if (!result.ok) {
2504
+ this.diagnostic("sessions.projections failed: " + result.error.code);
2505
+ return;
2506
+ }
2507
+ const baseline = result.value;
2508
+ if (baseline === null || baseline === void 0) return;
2509
+ for (const [key, value] of Object.entries(baseline.values ?? {})) this.applyProjection(sessionId, key, value);
2510
+ } catch (error) {
2511
+ this.diagnostic("sessions.projections threw: " + String(error?.message ?? error));
2512
+ }
2513
+ }
2438
2514
  async historyPage(sessionId, beforeSeq, limit) {
2439
2515
  try {
2440
2516
  const response = await this.apiProxy.sessions.history({
@@ -3391,6 +3467,14 @@ var Dsh012ApiProxy = class {
3391
3467
  this.shouldSurfaceInteraction = options.shouldSurfaceInteraction ?? (() => true);
3392
3468
  }
3393
3469
  /**
3470
+ * Whether the host controller implements `session.projections` (added in
3471
+ * DSH 0.1.7). HostBridge gates its open-time baseline refresh on this so
3472
+ * older hosts keep their current list-row-hint behaviour with no RPC noise.
3473
+ */
3474
+ get supportsProjections() {
3475
+ return typeof this.session.projections === "function";
3476
+ }
3477
+ /**
3394
3478
  * Decide whether this Gateway Client can surface a phone card. The decision
3395
3479
  * fails open to false so a broken device registry delegates this delivery
3396
3480
  * while the official Web Client remains able to answer.
@@ -3444,7 +3528,12 @@ var Dsh012ApiProxy = class {
3444
3528
  selectModel: async (request) => this.call(() => this.session.selectModel(request.payload ?? {})),
3445
3529
  rename: async (request) => this.call(() => this.session.rename(request.payload)),
3446
3530
  cancel: async (request) => this.call(() => this.session.cancel(request.payload)),
3447
- attachment: async (request) => this.call(() => this.session.attachment(request.payload))
3531
+ attachment: async (request) => this.call(() => this.session.attachment(request.payload)),
3532
+ projections: async (request) => this.call(() => {
3533
+ const read = this.session.projections;
3534
+ if (typeof read !== "function") throw unavailable("session projections unavailable");
3535
+ return read.call(this.session, request.payload);
3536
+ })
3448
3537
  };
3449
3538
  workspace = {
3450
3539
  list: async () => this.call(async () => {
@@ -5279,18 +5368,18 @@ function localListenError(error, port) {
5279
5368
  /** Operator-run relay used by distributed builds; overridable via config. */
5280
5369
  const DEFAULT_RELAY_URL = "https://pilot.hailab.dev";
5281
5370
  const Config = z.object({
5282
- enabled: z.boolean().default(true),
5371
+ enabled: live(z.boolean().default(true)),
5283
5372
  devicesPath: z.string().default(join(bridgeDataDir(), "devices-v2.json")),
5284
5373
  historyBufferMax: z.natural().min(100).default(2e3),
5285
- debug: z.boolean().default(false),
5286
- local: z.object({
5374
+ debug: live(z.boolean().default(false)),
5375
+ local: live(z.object({
5287
5376
  enabled: z.boolean().default(true),
5288
5377
  port: z.natural().min(MIN_LOCAL_PORT).max(MAX_LOCAL_PORT).default(DEFAULT_LOCAL_PORT).description("DeepPilot 局域网独立端口(仅 TLS;默认 3098,修改后本地连接会短暂重连)")
5289
5378
  }).default({
5290
5379
  enabled: true,
5291
5380
  port: DEFAULT_LOCAL_PORT
5292
- }),
5293
- remote: z.object({
5381
+ })),
5382
+ remote: live(z.object({
5294
5383
  enabled: z.boolean().default(false),
5295
5384
  provider: z.union(["tailscale-funnel"]).default("tailscale-funnel"),
5296
5385
  hostname: z.string().default(DEFAULT_REMOTE_HOSTNAME),
@@ -5310,7 +5399,7 @@ const Config = z.object({
5310
5399
  helperPath: "",
5311
5400
  funnelPort: 443,
5312
5401
  maxConnectionsPerSource: 8
5313
- }),
5402
+ })),
5314
5403
  push: z.object({
5315
5404
  provider: z.union([
5316
5405
  "none",
@@ -5336,14 +5425,50 @@ const Config = z.object({
5336
5425
  })
5337
5426
  });
5338
5427
  /**
5428
+ * Declare one live-update (volatile) config node when the host supports it.
5429
+ *
5430
+ * DSH 0.1.7's schemastery adds `Schema#volatile()`; the schemas this package
5431
+ * typechecks against (3.18.x line) do not have the method, so the call goes
5432
+ * through a structural check instead of a direct chain — a direct
5433
+ * `.volatile()` would both fail typecheck today and throw at import on an
5434
+ * older host, killing plugin activation.
5435
+ */
5436
+ function live(node) {
5437
+ const schema = node;
5438
+ if (typeof schema.volatile === "function") return schema.volatile();
5439
+ if (typeof schema.extra === "function") return schema.extra("volatile", true);
5440
+ return node;
5441
+ }
5442
+ /**
5443
+ * Deep-unwrap live-update (volatile) references to plain values.
5444
+ *
5445
+ * On a 0.1.7 host, `apply()`'s options — and even a direct `Config(raw)` call
5446
+ * — carry `{ get() }` reference objects for volatile fields. Every consumer of
5447
+ * `currentConfig()` compares plain values (`=== true`, numeric bounds, object
5448
+ * fields), and schemastery refuses to re-parse a reference, so normalization
5449
+ * unwraps on the way in and on the way out. A JSON-shaped config value cannot
5450
+ * carry a function, so the `get` check cannot false-positive; plain input
5451
+ * passes through unchanged, keeping older-host behavior identical.
5452
+ */
5453
+ function plainConfig(value) {
5454
+ if (value === null || typeof value !== "object") return value;
5455
+ if (typeof value.get === "function") return plainConfig(value.get());
5456
+ if (Array.isArray(value)) return value.map(plainConfig);
5457
+ const plain = {};
5458
+ for (const [key, item] of Object.entries(value)) plain[key] = plainConfig(item);
5459
+ return plain;
5460
+ }
5461
+ /**
5339
5462
  * Cordis hands the second argument in different shapes depending on host
5340
5463
  * composition: a reactive options getter, the resolved config value, or
5341
- * nothing when the patch row omits `config`. Normalize all of them.
5464
+ * nothing when the patch row omits `config`. Normalize all of them — and
5465
+ * unwrap volatile references so the returned Config is plain data on every
5466
+ * host generation.
5342
5467
  */
5343
5468
  function normalizeOptions(options) {
5344
- if (typeof options === "function") return options();
5345
- if (options && typeof options === "object") return options;
5346
- return Config(void 0) ?? {};
5469
+ if (typeof options === "function") return plainConfig(options());
5470
+ if (options && typeof options === "object") return plainConfig(options);
5471
+ return plainConfig(Config(void 0) ?? {});
5347
5472
  }
5348
5473
  //#endregion
5349
5474
  //#region src/phone-http.ts
@@ -5945,8 +6070,13 @@ function apply(ctx, options) {
5945
6070
  })
5946
6071
  };
5947
6072
  ctx.inject(["settings"], (settingsCtx) => {
6073
+ if (typeof settingsCtx.settings?.installSection !== "function") return;
5948
6074
  settingsCtx.settings.installSection(ctx, "deeppilot", Config, normalizeOptions(void 0), settingsHooks);
5949
6075
  });
6076
+ ctx.on("loader/volatile-update", () => queueMicrotask(() => {
6077
+ scheduleLocalReconcile?.();
6078
+ scheduleRemoteReconcile?.();
6079
+ }));
5950
6080
  if (currentConfig().enabled !== true) log("disabled via settings; bridge stays inactive (rumors of /phone below are skipped)");
5951
6081
  const dataDir = bridgeDataDir();
5952
6082
  const pushRelayPath = join(dataDir, "push-relay.json");
@@ -6100,6 +6230,28 @@ function apply(ctx, options) {
6100
6230
  for (const connection of connections) connection.closeForServerStop();
6101
6231
  connections.clear();
6102
6232
  };
6233
+ /**
6234
+ * Unbind one device. Single path shared by the settings page (report-service
6235
+ * revokeDevice) and the wire-level c2s.device.revoke frame: mark the registry
6236
+ * tombstone (which also drops every APNs/WidgetKit/LiveActivity token, so the
6237
+ * push fan-out can no longer select it) and hard-drop its live sockets.
6238
+ * `except` is the connection that sent the revoke frame — it closes itself
6239
+ * with 4401 after acking, so it is not terminated here.
6240
+ */
6241
+ const revokeDevice = async (deviceId, except) => {
6242
+ const { devices } = await ready;
6243
+ if (!devices) throw new Error("device registry unavailable");
6244
+ const revoked = devices.revoke(deviceId, Date.now());
6245
+ if (revoked) {
6246
+ for (const connection of [...connections]) {
6247
+ if (connection === except || connection.connectedDeviceId !== deviceId) continue;
6248
+ connection.terminate();
6249
+ connections.delete(connection);
6250
+ }
6251
+ log(`device revoked id=${auditLabel(deviceId)}`);
6252
+ }
6253
+ return revoked;
6254
+ };
6103
6255
  const resolvePushConfig = (config) => {
6104
6256
  const push = config.push ?? {};
6105
6257
  const configured = push.provider ?? "none";
@@ -6490,18 +6642,7 @@ function apply(ctx, options) {
6490
6642
  devices
6491
6643
  };
6492
6644
  }, beginPairing, async (deviceId) => {
6493
- const { devices } = await ready;
6494
- if (!devices) throw new Error("device registry unavailable");
6495
- const revoked = devices.revoke(deviceId, Date.now());
6496
- if (revoked) {
6497
- for (const connection of [...connections]) {
6498
- if (connection.connectedDeviceId !== deviceId) continue;
6499
- connection.terminate();
6500
- connections.delete(connection);
6501
- }
6502
- log(`device revoked id=${auditLabel(deviceId)}`);
6503
- }
6504
- return revoked;
6645
+ return await revokeDevice(deviceId);
6505
6646
  }, async (deviceId, scopes) => {
6506
6647
  const { devices } = await ready;
6507
6648
  if (!devices) throw new Error("device registry unavailable");
@@ -6710,7 +6851,8 @@ function apply(ctx, options) {
6710
6851
  onDeviceAuthenticated: (deviceId) => {
6711
6852
  log(`device authenticated id=${auditLabel(deviceId)} source=${auditLabel(source)}`);
6712
6853
  },
6713
- onPushEnrollKey: handlePushEnrollKey
6854
+ onPushEnrollKey: handlePushEnrollKey,
6855
+ onDeviceRevoke: (deviceId, except) => revokeDevice(deviceId, except)
6714
6856
  });
6715
6857
  connections.add(connection);
6716
6858
  } catch (error) {