dsh-deeppilot 0.7.1 → 0.8.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
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({
@@ -3293,9 +3369,32 @@ function createAgentScope(ctx, identity) {
3293
3369
  }
3294
3370
  });
3295
3371
  }
3372
+ /**
3373
+ * Arity of the Host's published `wireStream.open`. 0.1.7-alpha.1 declares
3374
+ * `(endpoint, payload, uplink, peer, signal)`; every earlier generation
3375
+ * declares `(endpoint, payload, signal)`. Both are plain arrow functions with
3376
+ * no default or rest parameters, so `length` is a reliable contract selector —
3377
+ * unlike a capability probe, it opens no trial stream. A future signature that
3378
+ * rest-parameterizes (reporting `0`) falls back to the 3-argument call, which
3379
+ * every generation through `0.1.6-*` accepts.
3380
+ */
3381
+ function hostWireStreamArity(open) {
3382
+ return open.length;
3383
+ }
3384
+ /**
3385
+ * Open one Host logical stream on whichever `wireStream.open` contract the
3386
+ * running Host publishes. DeepPilot's carrier owns the Host in process and has
3387
+ * no Client-to-Host uplink, so the 5-argument form sends `undefined` for both
3388
+ * `uplink` and `peer` — the documented "operator's in-process carrier" case.
3389
+ */
3390
+ function openHostStream(gateway, endpoint, payload, signal) {
3391
+ const open = gateway.wireStream.open;
3392
+ if (hostWireStreamArity(open) >= 5) return open(endpoint, payload, void 0, void 0, signal);
3393
+ return open(endpoint, payload, signal);
3394
+ }
3296
3395
  function inProcessStream(gateway, endpoint, payload, signal) {
3297
3396
  return (async function* () {
3298
- yield* await gateway.wireStream.open(endpoint, payload, signal);
3397
+ yield* await openHostStream(gateway, endpoint, payload, signal);
3299
3398
  })();
3300
3399
  }
3301
3400
  /**
@@ -3391,6 +3490,14 @@ var Dsh012ApiProxy = class {
3391
3490
  this.shouldSurfaceInteraction = options.shouldSurfaceInteraction ?? (() => true);
3392
3491
  }
3393
3492
  /**
3493
+ * Whether the host controller implements `session.projections` (added in
3494
+ * DSH 0.1.7). HostBridge gates its open-time baseline refresh on this so
3495
+ * older hosts keep their current list-row-hint behaviour with no RPC noise.
3496
+ */
3497
+ get supportsProjections() {
3498
+ return typeof this.session.projections === "function";
3499
+ }
3500
+ /**
3394
3501
  * Decide whether this Gateway Client can surface a phone card. The decision
3395
3502
  * fails open to false so a broken device registry delegates this delivery
3396
3503
  * while the official Web Client remains able to answer.
@@ -3444,7 +3551,12 @@ var Dsh012ApiProxy = class {
3444
3551
  selectModel: async (request) => this.call(() => this.session.selectModel(request.payload ?? {})),
3445
3552
  rename: async (request) => this.call(() => this.session.rename(request.payload)),
3446
3553
  cancel: async (request) => this.call(() => this.session.cancel(request.payload)),
3447
- attachment: async (request) => this.call(() => this.session.attachment(request.payload))
3554
+ attachment: async (request) => this.call(() => this.session.attachment(request.payload)),
3555
+ projections: async (request) => this.call(() => {
3556
+ const read = this.session.projections;
3557
+ if (typeof read !== "function") throw unavailable("session projections unavailable");
3558
+ return read.call(this.session, request.payload);
3559
+ })
3448
3560
  };
3449
3561
  workspace = {
3450
3562
  list: async () => this.call(async () => {
@@ -5279,18 +5391,18 @@ function localListenError(error, port) {
5279
5391
  /** Operator-run relay used by distributed builds; overridable via config. */
5280
5392
  const DEFAULT_RELAY_URL = "https://pilot.hailab.dev";
5281
5393
  const Config = z.object({
5282
- enabled: z.boolean().default(true),
5394
+ enabled: live(z.boolean().default(true)),
5283
5395
  devicesPath: z.string().default(join(bridgeDataDir(), "devices-v2.json")),
5284
5396
  historyBufferMax: z.natural().min(100).default(2e3),
5285
- debug: z.boolean().default(false),
5286
- local: z.object({
5397
+ debug: live(z.boolean().default(false)),
5398
+ local: live(z.object({
5287
5399
  enabled: z.boolean().default(true),
5288
5400
  port: z.natural().min(MIN_LOCAL_PORT).max(MAX_LOCAL_PORT).default(DEFAULT_LOCAL_PORT).description("DeepPilot 局域网独立端口(仅 TLS;默认 3098,修改后本地连接会短暂重连)")
5289
5401
  }).default({
5290
5402
  enabled: true,
5291
5403
  port: DEFAULT_LOCAL_PORT
5292
- }),
5293
- remote: z.object({
5404
+ })),
5405
+ remote: live(z.object({
5294
5406
  enabled: z.boolean().default(false),
5295
5407
  provider: z.union(["tailscale-funnel"]).default("tailscale-funnel"),
5296
5408
  hostname: z.string().default(DEFAULT_REMOTE_HOSTNAME),
@@ -5310,7 +5422,7 @@ const Config = z.object({
5310
5422
  helperPath: "",
5311
5423
  funnelPort: 443,
5312
5424
  maxConnectionsPerSource: 8
5313
- }),
5425
+ })),
5314
5426
  push: z.object({
5315
5427
  provider: z.union([
5316
5428
  "none",
@@ -5336,14 +5448,50 @@ const Config = z.object({
5336
5448
  })
5337
5449
  });
5338
5450
  /**
5451
+ * Declare one live-update (volatile) config node when the host supports it.
5452
+ *
5453
+ * DSH 0.1.7's schemastery adds `Schema#volatile()`; the schemas this package
5454
+ * typechecks against (3.18.x line) do not have the method, so the call goes
5455
+ * through a structural check instead of a direct chain — a direct
5456
+ * `.volatile()` would both fail typecheck today and throw at import on an
5457
+ * older host, killing plugin activation.
5458
+ */
5459
+ function live(node) {
5460
+ const schema = node;
5461
+ if (typeof schema.volatile === "function") return schema.volatile();
5462
+ if (typeof schema.extra === "function") return schema.extra("volatile", true);
5463
+ return node;
5464
+ }
5465
+ /**
5466
+ * Deep-unwrap live-update (volatile) references to plain values.
5467
+ *
5468
+ * On a 0.1.7 host, `apply()`'s options — and even a direct `Config(raw)` call
5469
+ * — carry `{ get() }` reference objects for volatile fields. Every consumer of
5470
+ * `currentConfig()` compares plain values (`=== true`, numeric bounds, object
5471
+ * fields), and schemastery refuses to re-parse a reference, so normalization
5472
+ * unwraps on the way in and on the way out. A JSON-shaped config value cannot
5473
+ * carry a function, so the `get` check cannot false-positive; plain input
5474
+ * passes through unchanged, keeping older-host behavior identical.
5475
+ */
5476
+ function plainConfig(value) {
5477
+ if (value === null || typeof value !== "object") return value;
5478
+ if (typeof value.get === "function") return plainConfig(value.get());
5479
+ if (Array.isArray(value)) return value.map(plainConfig);
5480
+ const plain = {};
5481
+ for (const [key, item] of Object.entries(value)) plain[key] = plainConfig(item);
5482
+ return plain;
5483
+ }
5484
+ /**
5339
5485
  * Cordis hands the second argument in different shapes depending on host
5340
5486
  * composition: a reactive options getter, the resolved config value, or
5341
- * nothing when the patch row omits `config`. Normalize all of them.
5487
+ * nothing when the patch row omits `config`. Normalize all of them — and
5488
+ * unwrap volatile references so the returned Config is plain data on every
5489
+ * host generation.
5342
5490
  */
5343
5491
  function normalizeOptions(options) {
5344
- if (typeof options === "function") return options();
5345
- if (options && typeof options === "object") return options;
5346
- return Config(void 0) ?? {};
5492
+ if (typeof options === "function") return plainConfig(options());
5493
+ if (options && typeof options === "object") return plainConfig(options);
5494
+ return plainConfig(Config(void 0) ?? {});
5347
5495
  }
5348
5496
  //#endregion
5349
5497
  //#region src/phone-http.ts
@@ -5945,8 +6093,13 @@ function apply(ctx, options) {
5945
6093
  })
5946
6094
  };
5947
6095
  ctx.inject(["settings"], (settingsCtx) => {
6096
+ if (typeof settingsCtx.settings?.installSection !== "function") return;
5948
6097
  settingsCtx.settings.installSection(ctx, "deeppilot", Config, normalizeOptions(void 0), settingsHooks);
5949
6098
  });
6099
+ ctx.on("loader/volatile-update", () => queueMicrotask(() => {
6100
+ scheduleLocalReconcile?.();
6101
+ scheduleRemoteReconcile?.();
6102
+ }));
5950
6103
  if (currentConfig().enabled !== true) log("disabled via settings; bridge stays inactive (rumors of /phone below are skipped)");
5951
6104
  const dataDir = bridgeDataDir();
5952
6105
  const pushRelayPath = join(dataDir, "push-relay.json");
@@ -6100,6 +6253,28 @@ function apply(ctx, options) {
6100
6253
  for (const connection of connections) connection.closeForServerStop();
6101
6254
  connections.clear();
6102
6255
  };
6256
+ /**
6257
+ * Unbind one device. Single path shared by the settings page (report-service
6258
+ * revokeDevice) and the wire-level c2s.device.revoke frame: mark the registry
6259
+ * tombstone (which also drops every APNs/WidgetKit/LiveActivity token, so the
6260
+ * push fan-out can no longer select it) and hard-drop its live sockets.
6261
+ * `except` is the connection that sent the revoke frame — it closes itself
6262
+ * with 4401 after acking, so it is not terminated here.
6263
+ */
6264
+ const revokeDevice = async (deviceId, except) => {
6265
+ const { devices } = await ready;
6266
+ if (!devices) throw new Error("device registry unavailable");
6267
+ const revoked = devices.revoke(deviceId, Date.now());
6268
+ if (revoked) {
6269
+ for (const connection of [...connections]) {
6270
+ if (connection === except || connection.connectedDeviceId !== deviceId) continue;
6271
+ connection.terminate();
6272
+ connections.delete(connection);
6273
+ }
6274
+ log(`device revoked id=${auditLabel(deviceId)}`);
6275
+ }
6276
+ return revoked;
6277
+ };
6103
6278
  const resolvePushConfig = (config) => {
6104
6279
  const push = config.push ?? {};
6105
6280
  const configured = push.provider ?? "none";
@@ -6490,18 +6665,7 @@ function apply(ctx, options) {
6490
6665
  devices
6491
6666
  };
6492
6667
  }, 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;
6668
+ return await revokeDevice(deviceId);
6505
6669
  }, async (deviceId, scopes) => {
6506
6670
  const { devices } = await ready;
6507
6671
  if (!devices) throw new Error("device registry unavailable");
@@ -6710,7 +6874,8 @@ function apply(ctx, options) {
6710
6874
  onDeviceAuthenticated: (deviceId) => {
6711
6875
  log(`device authenticated id=${auditLabel(deviceId)} source=${auditLabel(source)}`);
6712
6876
  },
6713
- onPushEnrollKey: handlePushEnrollKey
6877
+ onPushEnrollKey: handlePushEnrollKey,
6878
+ onDeviceRevoke: (deviceId, except) => revokeDevice(deviceId, except)
6714
6879
  });
6715
6880
  connections.add(connection);
6716
6881
  } catch (error) {