dsh-deeppilot 0.6.0-alpha.2 → 0.6.0-alpha.3

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
@@ -1,5 +1,5 @@
1
- import z from "@deepseek-ai/schemastery";
2
1
  import { Context } from "@deepseek-ai/cordis";
2
+ import z from "@deepseek-ai/schemastery";
3
3
  //#region src/protocol.d.ts
4
4
  type SessionStatus = "running" | "idle" | "error" | "unknown";
5
5
  type SessionTodoStatus = "pending" | "in_progress" | "completed";
@@ -40,12 +40,13 @@ interface MessageContextInfo {
40
40
  /** One image carried by a user message. `attachmentId` keys the read-back RPC
41
41
  * (c2s.session.attachment); width/height let clients reserve layout space. */
42
42
  interface MessageAttachment {
43
- kind: 'image';
43
+ kind: 'image' | 'document';
44
44
  name?: string;
45
45
  mediaType?: string;
46
46
  attachmentId?: string;
47
47
  width?: number;
48
48
  height?: number;
49
+ truncated?: boolean;
49
50
  }
50
51
  interface MessageProjection {
51
52
  /** Durable row identity; unique within one session/page. */
@@ -402,6 +403,14 @@ interface PushOutlet {
402
403
  isAvailable(): boolean;
403
404
  }
404
405
  //#endregion
406
+ //#region src/document-payload.d.ts
407
+ interface PromptDocument {
408
+ name: string;
409
+ mediaType: string;
410
+ text: string;
411
+ truncated?: boolean;
412
+ }
413
+ //#endregion
405
414
  //#region src/host-bridge.d.ts
406
415
  declare class HostBridge {
407
416
  private readonly apiProxy;
@@ -537,7 +546,7 @@ declare class HostBridge {
537
546
  mediaType: 'image/png' | 'image/jpeg' | 'image/webp' | 'image/gif';
538
547
  data: string;
539
548
  name?: string;
540
- }>): Promise<SessionManagementResult<number>>;
549
+ }>, documents?: PromptDocument[]): Promise<SessionManagementResult<number>>;
541
550
  respondApproval(requestId: string, decision: 'allow' | 'deny', reason?: string): Promise<PendingResponseOutcome>;
542
551
  respondQuestion(requestId: string, answers: unknown): Promise<PendingResponseOutcome>;
543
552
  }
package/lib/index.js CHANGED
@@ -5,6 +5,8 @@ import { access, chmod, mkdir, readFile, rename, unlink, writeFile } from "node:
5
5
  import { dirname, join, resolve } from "node:path";
6
6
  import { WebSocketServer } from "ws";
7
7
  import { homedir, networkInterfaces } from "node:os";
8
+ import * as Cordis from "@deepseek-ai/cordis";
9
+ import { Context } from "@deepseek-ai/cordis";
8
10
  import { TypertRemoteService } from "@deepseek-ai/dsh-typert-protocol";
9
11
  import { connect } from "node:http2";
10
12
  import { spawn } from "node:child_process";
@@ -339,6 +341,9 @@ function requiredScope(type) {
339
341
  function sanitizeImageName(value) {
340
342
  return value.replace(/[\u0000-\u001F\u007F]/g, "").trim().slice(0, 120);
341
343
  }
344
+ function sanitizeDocumentField(value, maxChars) {
345
+ return value.replace(/[\u0000-\u001F\u007F]/g, " ").trim().slice(0, maxChars);
346
+ }
342
347
  /** Error code for a failed approval/question response outcome. */
343
348
  function pendingResponseErrorCode(reason) {
344
349
  switch (reason) {
@@ -724,9 +729,11 @@ var BridgeConnection = class {
724
729
  const p = env.payload;
725
730
  const text = typeof p?.text === "string" ? p.text : "";
726
731
  const rawImages = Array.isArray(p?.images) ? p.images : [];
727
- if (!p?.sessionId || text.trim().length === 0 && rawImages.length === 0) return this.fail(env.id, "E_PROTOCOL", "sessionId and text or images required");
732
+ const rawDocuments = Array.isArray(p?.documents) ? p.documents : [];
733
+ if (!p?.sessionId || text.trim().length === 0 && rawImages.length === 0 && rawDocuments.length === 0) return this.fail(env.id, "E_PROTOCOL", "sessionId and prompt content required");
728
734
  if (text.length > 262144) return this.fail(env.id, "E_PROTOCOL", "prompt text too long");
729
735
  if (rawImages.length > 4) return this.fail(env.id, "E_PROTOCOL", "too many images");
736
+ if (rawDocuments.length > 4 || rawImages.length + rawDocuments.length > 4) return this.fail(env.id, "E_PROTOCOL", "too many prompt attachments");
730
737
  const images = [];
731
738
  for (const image of rawImages) {
732
739
  if (!IMAGE_MEDIA_TYPES.has(String(image?.mediaType)) || typeof image?.data !== "string" || image.data.length === 0 || image.data.length > 8388608) return this.fail(env.id, "E_PROTOCOL", "invalid image attachment");
@@ -736,7 +743,20 @@ var BridgeConnection = class {
736
743
  ...typeof image.name === "string" && sanitizeImageName(image.name).length > 0 ? { name: sanitizeImageName(image.name) } : {}
737
744
  });
738
745
  }
739
- const userSeq = await this.deps.bridge.sendPrompt(p.sessionId, text, images);
746
+ const documents = [];
747
+ for (const document of rawDocuments) {
748
+ if (typeof document?.name !== "string" || typeof document?.mediaType !== "string" || typeof document?.text !== "string" || document.text.length === 0 || document.text.length > 262144) return this.fail(env.id, "E_PROTOCOL", "invalid document attachment");
749
+ const name = sanitizeDocumentField(document.name, 180);
750
+ const mediaType = sanitizeDocumentField(document.mediaType, 120).toLowerCase();
751
+ if (!name || !mediaType || mediaType.startsWith("image/")) return this.fail(env.id, "E_PROTOCOL", "invalid document attachment");
752
+ documents.push({
753
+ name,
754
+ mediaType,
755
+ text: document.text,
756
+ ...document.truncated === true ? { truncated: true } : {}
757
+ });
758
+ }
759
+ const userSeq = await this.deps.bridge.sendPrompt(p.sessionId, text, images, documents);
740
760
  if (!userSeq.ok) return this.fail(env.id, managementErrorCode(userSeq.kind), userSeq.message);
741
761
  this.send("s2c.ack", { userSeq: userSeq.value }, env.id);
742
762
  return;
@@ -855,6 +875,44 @@ function unwrapStreamItem(item) {
855
875
  return item;
856
876
  }
857
877
  //#endregion
878
+ //#region src/document-payload.ts
879
+ const PREFIX = "[DeepPilot document:";
880
+ const SUFFIX = "]";
881
+ /**
882
+ * DSH 0.1.2 has durable image attachments but no generic file content block.
883
+ * Documents therefore travel as a bounded, explicitly-labelled text block.
884
+ * The marker lets the canonical phone row recover an attachment chip without
885
+ * exposing the complete document body as ordinary user-authored bubble text.
886
+ */
887
+ function documentPromptBlock(document) {
888
+ const marker = {
889
+ v: 1,
890
+ name: document.name,
891
+ mediaType: document.mediaType,
892
+ ...document.truncated ? { truncated: true } : {}
893
+ };
894
+ const encoded = Buffer.from(JSON.stringify(marker), "utf8").toString("base64url");
895
+ return `${PREFIX}${encoded}${SUFFIX}\nAttached document: ${document.name}\n\n${document.text}`;
896
+ }
897
+ function projectedDocument(text) {
898
+ if (!text.startsWith(PREFIX)) return void 0;
899
+ const end = text.indexOf(SUFFIX, 20);
900
+ if (end < 0) return void 0;
901
+ try {
902
+ const decoded = JSON.parse(Buffer.from(text.slice(20, end), "base64url").toString("utf8"));
903
+ if (decoded.v !== 1 || typeof decoded.name !== "string" || typeof decoded.mediaType !== "string") return void 0;
904
+ if (!decoded.name.trim() || !decoded.mediaType.trim()) return void 0;
905
+ return {
906
+ name: decoded.name,
907
+ mediaType: decoded.mediaType,
908
+ text: text.slice(end + 1).replace(/^\nAttached document:[^\n]*\n\n/, ""),
909
+ ...decoded.truncated === true ? { truncated: true } : {}
910
+ };
911
+ } catch {
912
+ return;
913
+ }
914
+ }
915
+ //#endregion
858
916
  //#region src/host-event-projection.ts
859
917
  const MAX_MESSAGE_PROJECTION_BYTES = 262144;
860
918
  /** One durable host event sequence becomes exactly one phone message row.
@@ -1049,7 +1107,7 @@ function contentText(content) {
1049
1107
  if (typeof part === "string") return part;
1050
1108
  if (part && typeof part === "object") {
1051
1109
  const piece = part;
1052
- if (piece.type === "text" && typeof piece.text === "string") return piece.text;
1110
+ if (piece.type === "text" && typeof piece.text === "string") return projectedDocument(piece.text) ? "" : piece.text;
1053
1111
  }
1054
1112
  return "";
1055
1113
  }).join("");
@@ -1060,22 +1118,34 @@ function messageAttachments(data) {
1060
1118
  const obj = data;
1061
1119
  if (obj.message && typeof obj.message === "object") return messageAttachments(obj.message);
1062
1120
  if (!Array.isArray(obj.content)) return [];
1063
- return obj.content.flatMap((part) => {
1064
- if (!part || typeof part !== "object") return [];
1121
+ const attachments = [];
1122
+ for (const part of obj.content) {
1123
+ if (!part || typeof part !== "object") continue;
1065
1124
  const block = part;
1066
- if (block.type !== "image" || !block.attachment) return [];
1125
+ if (block.type === "text" && typeof block.text === "string") {
1126
+ const document = projectedDocument(block.text);
1127
+ if (document) attachments.push({
1128
+ kind: "document",
1129
+ name: document.name,
1130
+ mediaType: document.mediaType,
1131
+ ...document.truncated ? { truncated: true } : {}
1132
+ });
1133
+ continue;
1134
+ }
1135
+ if (block.type !== "image" || !block.attachment) continue;
1067
1136
  const attachmentId = typeof block.attachment.attachmentId === "string" && block.attachment.attachmentId.length > 0 ? block.attachment.attachmentId : void 0;
1068
1137
  const width = typeof block.attachment.width === "number" && Number.isFinite(block.attachment.width) ? block.attachment.width : void 0;
1069
1138
  const height = typeof block.attachment.height === "number" && Number.isFinite(block.attachment.height) ? block.attachment.height : void 0;
1070
- return [{
1139
+ attachments.push({
1071
1140
  kind: "image",
1072
1141
  ...typeof block.attachment.name === "string" ? { name: block.attachment.name } : {},
1073
1142
  ...typeof block.attachment.mediaType === "string" ? { mediaType: block.attachment.mediaType } : {},
1074
1143
  ...attachmentId ? { attachmentId } : {},
1075
1144
  ...width !== void 0 ? { width } : {},
1076
1145
  ...height !== void 0 ? { height } : {}
1077
- }];
1078
- });
1146
+ });
1147
+ }
1148
+ return attachments;
1079
1149
  }
1080
1150
  function attachmentProjection(data) {
1081
1151
  const attachments = messageAttachments(data);
@@ -2139,7 +2209,7 @@ var HostBridge = class {
2139
2209
  return null;
2140
2210
  }
2141
2211
  }
2142
- async sendPrompt(sessionId, text, images = []) {
2212
+ async sendPrompt(sessionId, text, images = [], documents = []) {
2143
2213
  try {
2144
2214
  const content = [];
2145
2215
  if (text.trim().length > 0) content.push({
@@ -2150,6 +2220,10 @@ var HostBridge = class {
2150
2220
  type: "image",
2151
2221
  ...image
2152
2222
  });
2223
+ for (const document of documents) content.push({
2224
+ type: "text",
2225
+ text: documentPromptBlock(document)
2226
+ });
2153
2227
  const response = await this.apiProxy.sessions.prompt({
2154
2228
  rpcId: randomUUID(),
2155
2229
  payload: {
@@ -2476,16 +2550,150 @@ function projectSessionModels(value) {
2476
2550
  }
2477
2551
  /** Project a history page (raw events) into MessageProjection rows. */
2478
2552
  //#endregion
2553
+ //#region src/dsh012-remote-interactions.ts
2554
+ /**
2555
+ * Resident DSH 0.1.2-alpha.3 Remote Events client for phone interactions.
2556
+ *
2557
+ * The Host-side `approval/request` and `user-questions/request` events are
2558
+ * owned once by DSH API Remotes. API Gateway then fans each pending waterfall
2559
+ * out to every connected Client and settles the first result. DeepPilot joins
2560
+ * that official Client plane in-process instead of registering a competing
2561
+ * Host waterfall listener or replacing the official Web composer.
2562
+ */
2563
+ let clientFaces;
2564
+ /**
2565
+ * Load DSH's browser-distributed Client faces into a tiny Host-side module
2566
+ * table. Published `lib/client.js` files register with `window.__ModuleLoader__`
2567
+ * rather than exporting ESM values, so a normal Node import cannot consume
2568
+ * them. The shim runs only while the three official bundles register; their
2569
+ * apply functions then operate against a non-browser Cordis Context.
2570
+ */
2571
+ function pluginModule(value, specifier) {
2572
+ if (typeof value !== "object" || value === null) throw new Error(`${specifier} Client bundle returned no exports`);
2573
+ if (typeof value.apply !== "function") throw new Error(`${specifier} has no Client apply() export`);
2574
+ return value;
2575
+ }
2576
+ async function loadClientFaces() {
2577
+ clientFaces ??= (async () => {
2578
+ const modules = /* @__PURE__ */ new Map([["@deepseek-ai/cordis", Cordis]]);
2579
+ const loaderGlobal = globalThis;
2580
+ const previousWindow = loaderGlobal.window;
2581
+ loaderGlobal.window = { __ModuleLoader__: { load: (definition) => {
2582
+ const exports = definition.factory((id) => {
2583
+ if (!modules.has(id)) throw new Error(`resident DSH Client cannot resolve ${JSON.stringify(id)}`);
2584
+ return modules.get(id);
2585
+ });
2586
+ modules.set(definition.id, exports);
2587
+ } } };
2588
+ try {
2589
+ for (const specifier of [
2590
+ "@deepseek-ai/dsh-typert-registry/client",
2591
+ "@deepseek-ai/dsh-client-connection/client",
2592
+ "@deepseek-ai/dsh-api-gateway/client"
2593
+ ]) await import(specifier);
2594
+ } finally {
2595
+ if (previousWindow === void 0) delete loaderGlobal.window;
2596
+ else loaderGlobal.window = previousWindow;
2597
+ }
2598
+ return {
2599
+ typert: pluginModule(modules.get("@deepseek-ai/dsh-typert-registry"), "@deepseek-ai/dsh-typert-registry/client"),
2600
+ connection: pluginModule(modules.get("@deepseek-ai/dsh-client-connection"), "@deepseek-ai/dsh-client-connection/client"),
2601
+ gateway: pluginModule(modules.get("@deepseek-ai/dsh-api-gateway"), "@deepseek-ai/dsh-api-gateway/client")
2602
+ };
2603
+ })();
2604
+ return clientFaces;
2605
+ }
2606
+ const agentScopeKey = Symbol("deeppilot.dsh-client.agent-scope");
2607
+ function scopeOf(ctx) {
2608
+ return ctx[agentScopeKey];
2609
+ }
2610
+ function createAgentScope(ctx, identity) {
2611
+ return ctx.plugin(function deeppilotAgentScope() {}).ctx.extend({
2612
+ [agentScopeKey]: identity,
2613
+ [Context.filter](listenerCtx) {
2614
+ const listenerIdentity = scopeOf(listenerCtx);
2615
+ return listenerIdentity === void 0 || listenerIdentity === identity;
2616
+ }
2617
+ });
2618
+ }
2619
+ function inProcessStream(gateway, endpoint, payload, signal) {
2620
+ return (async function* () {
2621
+ yield* await gateway.wireStream.open(endpoint, payload, signal);
2622
+ })();
2623
+ }
2624
+ /**
2625
+ * Start one headless official DSH Client backed by the Host's in-process
2626
+ * Connection and Gateway carriers.
2627
+ *
2628
+ * The returned disposer tears down the Remote Events generation, all pending
2629
+ * listeners, and every lazily minted Agent scope.
2630
+ */
2631
+ async function startDsh012RemoteInteractions(hostCtx, handlers) {
2632
+ const connection = hostCtx.get("connection");
2633
+ const gateway = hostCtx.get("typertGateway");
2634
+ if (connection === void 0) throw new Error("dsh 0.1.2-alpha.3 Host connection is unavailable");
2635
+ if (gateway === void 0) throw new Error("dsh 0.1.2-alpha.3 typertGateway is unavailable");
2636
+ const faces = await loadClientFaces();
2637
+ const client = new Context();
2638
+ const fetchHandler = connection.createSharedFetchHandler("/api");
2639
+ const transport = {
2640
+ fetch: (input, init) => fetchHandler.fetch(new Request(input, init)),
2641
+ openStream: (endpoint, payload, signal) => inProcessStream(gateway, endpoint, payload, signal),
2642
+ ownsHost: true
2643
+ };
2644
+ try {
2645
+ faces.typert.apply(client);
2646
+ const transportGlobal = globalThis;
2647
+ const previousTransport = transportGlobal.__DSH_TRANSPORT__;
2648
+ transportGlobal.__DSH_TRANSPORT__ = transport;
2649
+ try {
2650
+ faces.connection.apply(client);
2651
+ } finally {
2652
+ if (previousTransport === void 0) delete transportGlobal.__DSH_TRANSPORT__;
2653
+ else transportGlobal.__DSH_TRANSPORT__ = previousTransport;
2654
+ }
2655
+ const typert = client.get("typert");
2656
+ if (typert === void 0) throw new Error("resident DSH Client typert service was not installed");
2657
+ const scopes = /* @__PURE__ */ new Map();
2658
+ typert.contexts.registerClient("agent", {
2659
+ identity: (candidate) => scopeOf(candidate),
2660
+ resolve: (identity) => {
2661
+ let scope = scopes.get(identity);
2662
+ if (scope === void 0) {
2663
+ scope = createAgentScope(client, identity);
2664
+ scopes.set(identity, scope);
2665
+ }
2666
+ return scope;
2667
+ }
2668
+ });
2669
+ faces.gateway.apply(client);
2670
+ const remote = client.get("remote");
2671
+ if (remote === void 0) throw new Error("resident DSH Client remote service was not installed");
2672
+ remote.$on("approval/request", function(request, next) {
2673
+ return handlers.approval(scopeOf(this) ?? "", request, next);
2674
+ });
2675
+ remote.$on("user-questions/request", function(request, next) {
2676
+ return handlers.question(scopeOf(this) ?? "", request, next);
2677
+ });
2678
+ return async () => {
2679
+ await client.fiber.dispose();
2680
+ };
2681
+ } catch (error) {
2682
+ await client.fiber.dispose();
2683
+ throw error;
2684
+ }
2685
+ }
2686
+ //#endregion
2479
2687
  //#region src/dsh012-api-proxy.ts
2480
2688
  /**
2481
2689
  * Compatibility façade for the DSH 0.1.2 controller API.
2482
2690
  *
2483
2691
  * DeepPilot's phone protocol deliberately speaks one stable in-process
2484
2692
  * `apiProxy` vocabulary. Harness 0.1.2 removed that service in favor of
2485
- * direct Session/Workspace controllers plus scoped Cordis interaction events.
2693
+ * direct Session/Workspace controllers plus Gateway-backed Remote Events.
2486
2694
  * This adapter rebuilds the small subset the bridge needs from those public
2487
- * controllers, keeping the protocol implementation isolated from the Host API
2488
- * migration. It is intentionally Host-only.
2695
+ * controllers and joins the official Client interaction plane in-process,
2696
+ * keeping the phone protocol isolated from the Host API migration.
2489
2697
  */
2490
2698
  /** Direct-controller facade with the exact legacy shape HostBridge consumes. */
2491
2699
  var Dsh012ApiProxy = class {
@@ -2494,13 +2702,27 @@ var Dsh012ApiProxy = class {
2494
2702
  workspaceController;
2495
2703
  directoryPicker;
2496
2704
  interactions = /* @__PURE__ */ new Map();
2497
- constructor(ctx) {
2705
+ shouldSurfaceInteraction;
2706
+ constructor(ctx, options = {}) {
2498
2707
  this.ctx = ctx;
2499
2708
  const session = ctx.get("sessionController");
2500
2709
  if (session === void 0) throw new Error("dsh 0.1.2 sessionController is unavailable");
2501
2710
  this.session = session;
2502
2711
  this.workspaceController = ctx.get("workspaceController");
2503
2712
  this.directoryPicker = ctx.get("directoryPickerController");
2713
+ this.shouldSurfaceInteraction = options.shouldSurfaceInteraction ?? (() => true);
2714
+ }
2715
+ /**
2716
+ * Decide whether this Gateway Client can surface a phone card. The decision
2717
+ * fails open to false so a broken device registry delegates this delivery
2718
+ * while the official Web Client remains able to answer.
2719
+ */
2720
+ canSurfaceInteraction(kind) {
2721
+ try {
2722
+ return this.shouldSurfaceInteraction(kind);
2723
+ } catch {
2724
+ return false;
2725
+ }
2504
2726
  }
2505
2727
  sessions = {
2506
2728
  list: async () => this.call(async () => {
@@ -2613,71 +2835,79 @@ var Dsh012ApiProxy = class {
2613
2835
  value
2614
2836
  });
2615
2837
  });
2616
- const offApproval = this.ctx.on("approval/request", ((request) => {
2617
- const rpcId = randomUUID();
2618
- const sessionId = String(request.agent?.session?.id ?? request.agent?.id ?? "");
2619
- const response = deferred();
2620
- const abort = () => response.resolve("cancelled");
2621
- request.signal?.addEventListener("abort", abort, { once: true });
2622
- this.interactions.set(rpcId, {
2623
- resolve: response.resolve,
2624
- map: (value) => {
2625
- const outcome = value?.outcome;
2626
- return outcome === "allowed-once" || outcome === "rejected" ? outcome : "unavailable";
2627
- }
2628
- });
2629
- queue.push({
2630
- type: "approval/requested",
2631
- rpcId,
2632
- sessionId,
2633
- approvalId: rpcId,
2634
- toolName: String(request.toolName ?? "tool"),
2635
- reason: String(request.reason ?? "")
2636
- });
2637
- return response.promise.finally(() => {
2638
- request.signal?.removeEventListener("abort", abort);
2639
- this.interactions.delete(rpcId);
2640
- queue.push({
2641
- type: "approval/resolved",
2642
- approvalId: rpcId
2643
- });
2644
- });
2645
- }), { global: true });
2646
- const offQuestion = this.ctx.on("user-questions/request", ((request) => {
2647
- const rpcId = randomUUID();
2648
- const sessionId = String(request.agent?.session?.id ?? request.agent?.id ?? "");
2649
- const response = deferred();
2650
- const abort = () => response.reject(/* @__PURE__ */ new Error("question cancelled"));
2651
- request.signal?.addEventListener("abort", abort, { once: true });
2652
- this.interactions.set(rpcId, {
2653
- resolve: response.resolve,
2654
- map: (value) => value?.answer ?? value
2655
- });
2656
- queue.push({
2657
- type: "question/requested",
2658
- rpcId,
2659
- sessionId,
2660
- questions: request.questions ?? []
2661
- });
2662
- return response.promise.finally(() => {
2663
- request.signal?.removeEventListener("abort", abort);
2664
- this.interactions.delete(rpcId);
2665
- queue.push({
2666
- type: "question/resolved",
2667
- questionRpcId: rpcId
2668
- });
2838
+ let disposeInteractions;
2839
+ try {
2840
+ disposeInteractions = await startDsh012RemoteInteractions(this.ctx, {
2841
+ approval: (sessionId, request, next) => this.answerApproval(queue, sessionId, request, next),
2842
+ question: (sessionId, request, next) => this.answerQuestion(queue, sessionId, request, next)
2669
2843
  });
2670
- }), { global: true });
2844
+ } catch (error) {
2845
+ console.warn(`[deeppilot] DSH Remote interaction client unavailable: ${toError(error).message}`);
2846
+ }
2671
2847
  try {
2672
2848
  yield* queue.iterate();
2673
2849
  } finally {
2674
2850
  offEvent();
2675
2851
  offProjection?.();
2676
- offApproval();
2677
- offQuestion();
2852
+ await disposeInteractions?.();
2678
2853
  queue.close();
2679
2854
  }
2680
2855
  }
2856
+ answerApproval(queue, sessionId, request, next) {
2857
+ if (!this.canSurfaceInteraction("approval")) return next();
2858
+ const rpcId = randomUUID();
2859
+ const response = deferred();
2860
+ const abort = () => response.resolve("cancelled");
2861
+ request.signal?.addEventListener("abort", abort, { once: true });
2862
+ this.interactions.set(rpcId, {
2863
+ resolve: response.resolve,
2864
+ map: (value) => {
2865
+ const outcome = value?.outcome;
2866
+ return outcome === "allowed-once" || outcome === "rejected" ? outcome : "unavailable";
2867
+ }
2868
+ });
2869
+ queue.push({
2870
+ type: "approval/requested",
2871
+ rpcId,
2872
+ sessionId,
2873
+ approvalId: rpcId,
2874
+ toolName: String(request.toolName ?? "tool"),
2875
+ reason: String(request.reason ?? "")
2876
+ });
2877
+ return response.promise.finally(() => {
2878
+ request.signal?.removeEventListener("abort", abort);
2879
+ this.interactions.delete(rpcId);
2880
+ queue.push({
2881
+ type: "approval/resolved",
2882
+ approvalId: rpcId
2883
+ });
2884
+ });
2885
+ }
2886
+ answerQuestion(queue, sessionId, request, next) {
2887
+ if (!this.canSurfaceInteraction("question")) return next();
2888
+ const rpcId = randomUUID();
2889
+ const response = deferred();
2890
+ const abort = () => response.reject(/* @__PURE__ */ new Error("question cancelled"));
2891
+ request.signal?.addEventListener("abort", abort, { once: true });
2892
+ this.interactions.set(rpcId, {
2893
+ resolve: response.resolve,
2894
+ map: (value) => value?.answer ?? value
2895
+ });
2896
+ queue.push({
2897
+ type: "question/requested",
2898
+ rpcId,
2899
+ sessionId,
2900
+ questions: request.questions ?? []
2901
+ });
2902
+ return response.promise.finally(() => {
2903
+ request.signal?.removeEventListener("abort", abort);
2904
+ this.interactions.delete(rpcId);
2905
+ queue.push({
2906
+ type: "question/resolved",
2907
+ questionRpcId: rpcId
2908
+ });
2909
+ });
2910
+ }
2681
2911
  async *hostEvents(signal) {
2682
2912
  const queue = new AsyncFrameQueue(signal);
2683
2913
  const listen = (event, type, project) => this.ctx.on(event, ((...args) => queue.push({
@@ -5151,7 +5381,21 @@ function apply(ctx, options) {
5151
5381
  res.end(JSON.stringify({ ok: false }));
5152
5382
  }
5153
5383
  };
5154
- ctx.inject(["sessionController"], (sub) => {
5384
+ /**
5385
+ * Whether the resident Gateway Client represents a real phone surface. A
5386
+ * paired device remains answerable even while offline: HostBridge retains an
5387
+ * authoritative pending snapshot that the app pulls on reconnect, while
5388
+ * APNs is only a best-effort wakeup. With no paired device this Client calls
5389
+ * `next()`; Gateway's independent official Web delivery is unaffected.
5390
+ */
5391
+ const hasPairedPhoneSurface = (_kind) => {
5392
+ return (auth.devices?.list() ?? []).some((device) => device.revokedAt === void 0);
5393
+ };
5394
+ ctx.inject([
5395
+ "sessionController",
5396
+ "connection",
5397
+ "typertGateway"
5398
+ ], (sub) => {
5155
5399
  if (currentConfig().enabled !== true) {
5156
5400
  log("bridge disabled; data plane stays inactive");
5157
5401
  return;
@@ -5159,7 +5403,7 @@ function apply(ctx, options) {
5159
5403
  const apiCtx = sub;
5160
5404
  let proxy;
5161
5405
  try {
5162
- proxy = new Dsh012ApiProxy(apiCtx);
5406
+ proxy = new Dsh012ApiProxy(apiCtx, { shouldSurfaceInteraction: hasPairedPhoneSurface });
5163
5407
  } catch (error) {
5164
5408
  log("dsh 0.1.2 session bridge unavailable: " + String(error));
5165
5409
  return;