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

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
@@ -1,10 +1,11 @@
1
1
  import { createRequire } from "node:module";
2
- import { createServer } from "node:http";
3
2
  import { createHash, createPrivateKey, createPublicKey, randomBytes, randomUUID, sign, timingSafeEqual, verify } from "node:crypto";
4
- import { access, chmod, mkdir, readFile, rename, unlink, writeFile } from "node:fs/promises";
3
+ import { access, chmod, mkdir, readFile, readdir, rename, rm, unlink, writeFile } from "node:fs/promises";
5
4
  import { dirname, join, resolve } from "node:path";
6
5
  import { WebSocketServer } from "ws";
7
6
  import { homedir, networkInterfaces } from "node:os";
7
+ import * as Cordis from "@deepseek-ai/cordis";
8
+ import { Context } from "@deepseek-ai/cordis";
8
9
  import { TypertRemoteService } from "@deepseek-ai/dsh-typert-protocol";
9
10
  import { connect } from "node:http2";
10
11
  import { spawn } from "node:child_process";
@@ -13,8 +14,11 @@ import { fileURLToPath } from "node:url";
13
14
  import { request } from "node:https";
14
15
  import z from "@deepseek-ai/schemastery";
15
16
  import { isIP } from "node:net";
17
+ import { createServer } from "node:http";
16
18
  //#region src/device-auth.ts
17
- const PAIRING_CODE_TTL_MS = 3e5;
19
+ function getPairingCodeTtlMs() {
20
+ return Number(process.env.DEEPPILOT_PAIRING_TTL_MS) || 3e5;
21
+ }
18
22
  const AUTH_CHALLENGE_TTL_MS = 3e4;
19
23
  const DEVICE_SCOPES = [
20
24
  "sessions.read",
@@ -104,7 +108,7 @@ var PairingCodeManager = class {
104
108
  issue(now = Date.now()) {
105
109
  const grant = {
106
110
  code: randomBytes(24).toString("base64url"),
107
- expiresAt: now + PAIRING_CODE_TTL_MS
111
+ expiresAt: now + getPairingCodeTtlMs()
108
112
  };
109
113
  this.active = grant;
110
114
  return { ...grant };
@@ -339,6 +343,9 @@ function requiredScope(type) {
339
343
  function sanitizeImageName(value) {
340
344
  return value.replace(/[\u0000-\u001F\u007F]/g, "").trim().slice(0, 120);
341
345
  }
346
+ function sanitizeDocumentField(value, maxChars) {
347
+ return value.replace(/[\u0000-\u001F\u007F]/g, " ").trim().slice(0, maxChars);
348
+ }
342
349
  /** Error code for a failed approval/question response outcome. */
343
350
  function pendingResponseErrorCode(reason) {
344
351
  switch (reason) {
@@ -724,9 +731,11 @@ var BridgeConnection = class {
724
731
  const p = env.payload;
725
732
  const text = typeof p?.text === "string" ? p.text : "";
726
733
  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");
734
+ const rawDocuments = Array.isArray(p?.documents) ? p.documents : [];
735
+ 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
736
  if (text.length > 262144) return this.fail(env.id, "E_PROTOCOL", "prompt text too long");
729
737
  if (rawImages.length > 4) return this.fail(env.id, "E_PROTOCOL", "too many images");
738
+ if (rawDocuments.length > 4 || rawImages.length + rawDocuments.length > 4) return this.fail(env.id, "E_PROTOCOL", "too many prompt attachments");
730
739
  const images = [];
731
740
  for (const image of rawImages) {
732
741
  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 +745,20 @@ var BridgeConnection = class {
736
745
  ...typeof image.name === "string" && sanitizeImageName(image.name).length > 0 ? { name: sanitizeImageName(image.name) } : {}
737
746
  });
738
747
  }
739
- const userSeq = await this.deps.bridge.sendPrompt(p.sessionId, text, images);
748
+ const documents = [];
749
+ for (const document of rawDocuments) {
750
+ 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");
751
+ const name = sanitizeDocumentField(document.name, 180);
752
+ const mediaType = sanitizeDocumentField(document.mediaType, 120).toLowerCase();
753
+ if (!name || !mediaType || mediaType.startsWith("image/")) return this.fail(env.id, "E_PROTOCOL", "invalid document attachment");
754
+ documents.push({
755
+ name,
756
+ mediaType,
757
+ text: document.text,
758
+ ...document.truncated === true ? { truncated: true } : {}
759
+ });
760
+ }
761
+ const userSeq = await this.deps.bridge.sendPrompt(p.sessionId, text, images, documents);
740
762
  if (!userSeq.ok) return this.fail(env.id, managementErrorCode(userSeq.kind), userSeq.message);
741
763
  this.send("s2c.ack", { userSeq: userSeq.value }, env.id);
742
764
  return;
@@ -855,6 +877,44 @@ function unwrapStreamItem(item) {
855
877
  return item;
856
878
  }
857
879
  //#endregion
880
+ //#region src/document-payload.ts
881
+ const PREFIX = "[DeepPilot document:";
882
+ const SUFFIX = "]";
883
+ /**
884
+ * DSH 0.1.2 has durable image attachments but no generic file content block.
885
+ * Documents therefore travel as a bounded, explicitly-labelled text block.
886
+ * The marker lets the canonical phone row recover an attachment chip without
887
+ * exposing the complete document body as ordinary user-authored bubble text.
888
+ */
889
+ function documentPromptBlock(document) {
890
+ const marker = {
891
+ v: 1,
892
+ name: document.name,
893
+ mediaType: document.mediaType,
894
+ ...document.truncated ? { truncated: true } : {}
895
+ };
896
+ const encoded = Buffer.from(JSON.stringify(marker), "utf8").toString("base64url");
897
+ return `${PREFIX}${encoded}${SUFFIX}\nAttached document: ${document.name}\n\n${document.text}`;
898
+ }
899
+ function projectedDocument(text) {
900
+ if (!text.startsWith(PREFIX)) return void 0;
901
+ const end = text.indexOf(SUFFIX, 20);
902
+ if (end < 0) return void 0;
903
+ try {
904
+ const decoded = JSON.parse(Buffer.from(text.slice(20, end), "base64url").toString("utf8"));
905
+ if (decoded.v !== 1 || typeof decoded.name !== "string" || typeof decoded.mediaType !== "string") return void 0;
906
+ if (!decoded.name.trim() || !decoded.mediaType.trim()) return void 0;
907
+ return {
908
+ name: decoded.name,
909
+ mediaType: decoded.mediaType,
910
+ text: text.slice(end + 1).replace(/^\nAttached document:[^\n]*\n\n/, ""),
911
+ ...decoded.truncated === true ? { truncated: true } : {}
912
+ };
913
+ } catch {
914
+ return;
915
+ }
916
+ }
917
+ //#endregion
858
918
  //#region src/host-event-projection.ts
859
919
  const MAX_MESSAGE_PROJECTION_BYTES = 262144;
860
920
  /** One durable host event sequence becomes exactly one phone message row.
@@ -1049,7 +1109,7 @@ function contentText(content) {
1049
1109
  if (typeof part === "string") return part;
1050
1110
  if (part && typeof part === "object") {
1051
1111
  const piece = part;
1052
- if (piece.type === "text" && typeof piece.text === "string") return piece.text;
1112
+ if (piece.type === "text" && typeof piece.text === "string") return projectedDocument(piece.text) ? "" : piece.text;
1053
1113
  }
1054
1114
  return "";
1055
1115
  }).join("");
@@ -1060,22 +1120,34 @@ function messageAttachments(data) {
1060
1120
  const obj = data;
1061
1121
  if (obj.message && typeof obj.message === "object") return messageAttachments(obj.message);
1062
1122
  if (!Array.isArray(obj.content)) return [];
1063
- return obj.content.flatMap((part) => {
1064
- if (!part || typeof part !== "object") return [];
1123
+ const attachments = [];
1124
+ for (const part of obj.content) {
1125
+ if (!part || typeof part !== "object") continue;
1065
1126
  const block = part;
1066
- if (block.type !== "image" || !block.attachment) return [];
1127
+ if (block.type === "text" && typeof block.text === "string") {
1128
+ const document = projectedDocument(block.text);
1129
+ if (document) attachments.push({
1130
+ kind: "document",
1131
+ name: document.name,
1132
+ mediaType: document.mediaType,
1133
+ ...document.truncated ? { truncated: true } : {}
1134
+ });
1135
+ continue;
1136
+ }
1137
+ if (block.type !== "image" || !block.attachment) continue;
1067
1138
  const attachmentId = typeof block.attachment.attachmentId === "string" && block.attachment.attachmentId.length > 0 ? block.attachment.attachmentId : void 0;
1068
1139
  const width = typeof block.attachment.width === "number" && Number.isFinite(block.attachment.width) ? block.attachment.width : void 0;
1069
1140
  const height = typeof block.attachment.height === "number" && Number.isFinite(block.attachment.height) ? block.attachment.height : void 0;
1070
- return [{
1141
+ attachments.push({
1071
1142
  kind: "image",
1072
1143
  ...typeof block.attachment.name === "string" ? { name: block.attachment.name } : {},
1073
1144
  ...typeof block.attachment.mediaType === "string" ? { mediaType: block.attachment.mediaType } : {},
1074
1145
  ...attachmentId ? { attachmentId } : {},
1075
1146
  ...width !== void 0 ? { width } : {},
1076
1147
  ...height !== void 0 ? { height } : {}
1077
- }];
1078
- });
1148
+ });
1149
+ }
1150
+ return attachments;
1079
1151
  }
1080
1152
  function attachmentProjection(data) {
1081
1153
  const attachments = messageAttachments(data);
@@ -2139,7 +2211,7 @@ var HostBridge = class {
2139
2211
  return null;
2140
2212
  }
2141
2213
  }
2142
- async sendPrompt(sessionId, text, images = []) {
2214
+ async sendPrompt(sessionId, text, images = [], documents = []) {
2143
2215
  try {
2144
2216
  const content = [];
2145
2217
  if (text.trim().length > 0) content.push({
@@ -2150,6 +2222,10 @@ var HostBridge = class {
2150
2222
  type: "image",
2151
2223
  ...image
2152
2224
  });
2225
+ for (const document of documents) content.push({
2226
+ type: "text",
2227
+ text: documentPromptBlock(document)
2228
+ });
2153
2229
  const response = await this.apiProxy.sessions.prompt({
2154
2230
  rpcId: randomUUID(),
2155
2231
  payload: {
@@ -2476,16 +2552,150 @@ function projectSessionModels(value) {
2476
2552
  }
2477
2553
  /** Project a history page (raw events) into MessageProjection rows. */
2478
2554
  //#endregion
2555
+ //#region src/dsh012-remote-interactions.ts
2556
+ /**
2557
+ * Resident DSH 0.1.2-alpha.3 Remote Events client for phone interactions.
2558
+ *
2559
+ * The Host-side `approval/request` and `user-questions/request` events are
2560
+ * owned once by DSH API Remotes. API Gateway then fans each pending waterfall
2561
+ * out to every connected Client and settles the first result. DeepPilot joins
2562
+ * that official Client plane in-process instead of registering a competing
2563
+ * Host waterfall listener or replacing the official Web composer.
2564
+ */
2565
+ let clientFaces;
2566
+ /**
2567
+ * Load DSH's browser-distributed Client faces into a tiny Host-side module
2568
+ * table. Published `lib/client.js` files register with `window.__ModuleLoader__`
2569
+ * rather than exporting ESM values, so a normal Node import cannot consume
2570
+ * them. The shim runs only while the three official bundles register; their
2571
+ * apply functions then operate against a non-browser Cordis Context.
2572
+ */
2573
+ function pluginModule(value, specifier) {
2574
+ if (typeof value !== "object" || value === null) throw new Error(`${specifier} Client bundle returned no exports`);
2575
+ if (typeof value.apply !== "function") throw new Error(`${specifier} has no Client apply() export`);
2576
+ return value;
2577
+ }
2578
+ async function loadClientFaces() {
2579
+ clientFaces ??= (async () => {
2580
+ const modules = /* @__PURE__ */ new Map([["@deepseek-ai/cordis", Cordis]]);
2581
+ const loaderGlobal = globalThis;
2582
+ const previousWindow = loaderGlobal.window;
2583
+ loaderGlobal.window = { __ModuleLoader__: { load: (definition) => {
2584
+ const exports = definition.factory((id) => {
2585
+ if (!modules.has(id)) throw new Error(`resident DSH Client cannot resolve ${JSON.stringify(id)}`);
2586
+ return modules.get(id);
2587
+ });
2588
+ modules.set(definition.id, exports);
2589
+ } } };
2590
+ try {
2591
+ for (const specifier of [
2592
+ "@deepseek-ai/dsh-typert-registry/client",
2593
+ "@deepseek-ai/dsh-client-connection/client",
2594
+ "@deepseek-ai/dsh-api-gateway/client"
2595
+ ]) await import(specifier);
2596
+ } finally {
2597
+ if (previousWindow === void 0) delete loaderGlobal.window;
2598
+ else loaderGlobal.window = previousWindow;
2599
+ }
2600
+ return {
2601
+ typert: pluginModule(modules.get("@deepseek-ai/dsh-typert-registry"), "@deepseek-ai/dsh-typert-registry/client"),
2602
+ connection: pluginModule(modules.get("@deepseek-ai/dsh-client-connection"), "@deepseek-ai/dsh-client-connection/client"),
2603
+ gateway: pluginModule(modules.get("@deepseek-ai/dsh-api-gateway"), "@deepseek-ai/dsh-api-gateway/client")
2604
+ };
2605
+ })();
2606
+ return clientFaces;
2607
+ }
2608
+ const agentScopeKey = Symbol("deeppilot.dsh-client.agent-scope");
2609
+ function scopeOf(ctx) {
2610
+ return ctx[agentScopeKey];
2611
+ }
2612
+ function createAgentScope(ctx, identity) {
2613
+ return ctx.plugin(function deeppilotAgentScope() {}).ctx.extend({
2614
+ [agentScopeKey]: identity,
2615
+ [Context.filter](listenerCtx) {
2616
+ const listenerIdentity = scopeOf(listenerCtx);
2617
+ return listenerIdentity === void 0 || listenerIdentity === identity;
2618
+ }
2619
+ });
2620
+ }
2621
+ function inProcessStream(gateway, endpoint, payload, signal) {
2622
+ return (async function* () {
2623
+ yield* await gateway.wireStream.open(endpoint, payload, signal);
2624
+ })();
2625
+ }
2626
+ /**
2627
+ * Start one headless official DSH Client backed by the Host's in-process
2628
+ * Connection and Gateway carriers.
2629
+ *
2630
+ * The returned disposer tears down the Remote Events generation, all pending
2631
+ * listeners, and every lazily minted Agent scope.
2632
+ */
2633
+ async function startDsh012RemoteInteractions(hostCtx, handlers) {
2634
+ const connection = hostCtx.get("connection");
2635
+ const gateway = hostCtx.get("typertGateway");
2636
+ if (connection === void 0) throw new Error("dsh 0.1.2-alpha.3 Host connection is unavailable");
2637
+ if (gateway === void 0) throw new Error("dsh 0.1.2-alpha.3 typertGateway is unavailable");
2638
+ const faces = await loadClientFaces();
2639
+ const client = new Context();
2640
+ const fetchHandler = connection.createSharedFetchHandler("/api");
2641
+ const transport = {
2642
+ fetch: (input, init) => fetchHandler.fetch(new Request(input, init)),
2643
+ openStream: (endpoint, payload, signal) => inProcessStream(gateway, endpoint, payload, signal),
2644
+ ownsHost: true
2645
+ };
2646
+ try {
2647
+ faces.typert.apply(client);
2648
+ const transportGlobal = globalThis;
2649
+ const previousTransport = transportGlobal.__DSH_TRANSPORT__;
2650
+ transportGlobal.__DSH_TRANSPORT__ = transport;
2651
+ try {
2652
+ faces.connection.apply(client);
2653
+ } finally {
2654
+ if (previousTransport === void 0) delete transportGlobal.__DSH_TRANSPORT__;
2655
+ else transportGlobal.__DSH_TRANSPORT__ = previousTransport;
2656
+ }
2657
+ const typert = client.get("typert");
2658
+ if (typert === void 0) throw new Error("resident DSH Client typert service was not installed");
2659
+ const scopes = /* @__PURE__ */ new Map();
2660
+ typert.contexts.registerClient("agent", {
2661
+ identity: (candidate) => scopeOf(candidate),
2662
+ resolve: (identity) => {
2663
+ let scope = scopes.get(identity);
2664
+ if (scope === void 0) {
2665
+ scope = createAgentScope(client, identity);
2666
+ scopes.set(identity, scope);
2667
+ }
2668
+ return scope;
2669
+ }
2670
+ });
2671
+ faces.gateway.apply(client);
2672
+ const remote = client.get("remote");
2673
+ if (remote === void 0) throw new Error("resident DSH Client remote service was not installed");
2674
+ remote.$on("approval/request", function(request, next) {
2675
+ return handlers.approval(scopeOf(this) ?? "", request, next);
2676
+ });
2677
+ remote.$on("user-questions/request", function(request, next) {
2678
+ return handlers.question(scopeOf(this) ?? "", request, next);
2679
+ });
2680
+ return async () => {
2681
+ await client.fiber.dispose();
2682
+ };
2683
+ } catch (error) {
2684
+ await client.fiber.dispose();
2685
+ throw error;
2686
+ }
2687
+ }
2688
+ //#endregion
2479
2689
  //#region src/dsh012-api-proxy.ts
2480
2690
  /**
2481
2691
  * Compatibility façade for the DSH 0.1.2 controller API.
2482
2692
  *
2483
2693
  * DeepPilot's phone protocol deliberately speaks one stable in-process
2484
2694
  * `apiProxy` vocabulary. Harness 0.1.2 removed that service in favor of
2485
- * direct Session/Workspace controllers plus scoped Cordis interaction events.
2695
+ * direct Session/Workspace controllers plus Gateway-backed Remote Events.
2486
2696
  * 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.
2697
+ * controllers and joins the official Client interaction plane in-process,
2698
+ * keeping the phone protocol isolated from the Host API migration.
2489
2699
  */
2490
2700
  /** Direct-controller facade with the exact legacy shape HostBridge consumes. */
2491
2701
  var Dsh012ApiProxy = class {
@@ -2494,13 +2704,27 @@ var Dsh012ApiProxy = class {
2494
2704
  workspaceController;
2495
2705
  directoryPicker;
2496
2706
  interactions = /* @__PURE__ */ new Map();
2497
- constructor(ctx) {
2707
+ shouldSurfaceInteraction;
2708
+ constructor(ctx, options = {}) {
2498
2709
  this.ctx = ctx;
2499
2710
  const session = ctx.get("sessionController");
2500
2711
  if (session === void 0) throw new Error("dsh 0.1.2 sessionController is unavailable");
2501
2712
  this.session = session;
2502
2713
  this.workspaceController = ctx.get("workspaceController");
2503
2714
  this.directoryPicker = ctx.get("directoryPickerController");
2715
+ this.shouldSurfaceInteraction = options.shouldSurfaceInteraction ?? (() => true);
2716
+ }
2717
+ /**
2718
+ * Decide whether this Gateway Client can surface a phone card. The decision
2719
+ * fails open to false so a broken device registry delegates this delivery
2720
+ * while the official Web Client remains able to answer.
2721
+ */
2722
+ canSurfaceInteraction(kind) {
2723
+ try {
2724
+ return this.shouldSurfaceInteraction(kind);
2725
+ } catch {
2726
+ return false;
2727
+ }
2504
2728
  }
2505
2729
  sessions = {
2506
2730
  list: async () => this.call(async () => {
@@ -2613,71 +2837,79 @@ var Dsh012ApiProxy = class {
2613
2837
  value
2614
2838
  });
2615
2839
  });
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
- });
2840
+ let disposeInteractions;
2841
+ try {
2842
+ disposeInteractions = await startDsh012RemoteInteractions(this.ctx, {
2843
+ approval: (sessionId, request, next) => this.answerApproval(queue, sessionId, request, next),
2844
+ question: (sessionId, request, next) => this.answerQuestion(queue, sessionId, request, next)
2669
2845
  });
2670
- }), { global: true });
2846
+ } catch (error) {
2847
+ console.warn(`[deeppilot] DSH Remote interaction client unavailable: ${toError(error).message}`);
2848
+ }
2671
2849
  try {
2672
2850
  yield* queue.iterate();
2673
2851
  } finally {
2674
2852
  offEvent();
2675
2853
  offProjection?.();
2676
- offApproval();
2677
- offQuestion();
2854
+ await disposeInteractions?.();
2678
2855
  queue.close();
2679
2856
  }
2680
2857
  }
2858
+ answerApproval(queue, sessionId, request, next) {
2859
+ if (!this.canSurfaceInteraction("approval")) return next();
2860
+ const rpcId = randomUUID();
2861
+ const response = deferred();
2862
+ const abort = () => response.resolve("cancelled");
2863
+ request.signal?.addEventListener("abort", abort, { once: true });
2864
+ this.interactions.set(rpcId, {
2865
+ resolve: response.resolve,
2866
+ map: (value) => {
2867
+ const outcome = value?.outcome;
2868
+ return outcome === "allowed-once" || outcome === "rejected" ? outcome : "unavailable";
2869
+ }
2870
+ });
2871
+ queue.push({
2872
+ type: "approval/requested",
2873
+ rpcId,
2874
+ sessionId,
2875
+ approvalId: rpcId,
2876
+ toolName: String(request.toolName ?? "tool"),
2877
+ reason: String(request.reason ?? "")
2878
+ });
2879
+ return response.promise.finally(() => {
2880
+ request.signal?.removeEventListener("abort", abort);
2881
+ this.interactions.delete(rpcId);
2882
+ queue.push({
2883
+ type: "approval/resolved",
2884
+ approvalId: rpcId
2885
+ });
2886
+ });
2887
+ }
2888
+ answerQuestion(queue, sessionId, request, next) {
2889
+ if (!this.canSurfaceInteraction("question")) return next();
2890
+ const rpcId = randomUUID();
2891
+ const response = deferred();
2892
+ const abort = () => response.reject(/* @__PURE__ */ new Error("question cancelled"));
2893
+ request.signal?.addEventListener("abort", abort, { once: true });
2894
+ this.interactions.set(rpcId, {
2895
+ resolve: response.resolve,
2896
+ map: (value) => value?.answer ?? value
2897
+ });
2898
+ queue.push({
2899
+ type: "question/requested",
2900
+ rpcId,
2901
+ sessionId,
2902
+ questions: request.questions ?? []
2903
+ });
2904
+ return response.promise.finally(() => {
2905
+ request.signal?.removeEventListener("abort", abort);
2906
+ this.interactions.delete(rpcId);
2907
+ queue.push({
2908
+ type: "question/resolved",
2909
+ questionRpcId: rpcId
2910
+ });
2911
+ });
2912
+ }
2681
2913
  async *hostEvents(signal) {
2682
2914
  const queue = new AsyncFrameQueue(signal);
2683
2915
  const listen = (event, type, project) => this.ctx.on(event, ((...args) => queue.push({
@@ -2967,6 +3199,28 @@ function parseRemote(value) {
2967
3199
  updatedAt: int(s, "updatedAt", "remote.updatedAt")
2968
3200
  };
2969
3201
  }
3202
+ function parseLocal(value) {
3203
+ const s = rec(value, "local");
3204
+ const phase = str(s, "phase", "local.phase");
3205
+ if (![
3206
+ "disabled",
3207
+ "starting",
3208
+ "online",
3209
+ "error",
3210
+ "stopped"
3211
+ ].includes(phase)) reject("local.phase");
3212
+ const endpoints = s.endpoints;
3213
+ if (!Array.isArray(endpoints) || endpoints.some((entry) => typeof entry !== "string")) reject("local.endpoints");
3214
+ const message = s.message;
3215
+ if (message !== void 0 && typeof message !== "string") reject("local.message");
3216
+ return {
3217
+ phase,
3218
+ port: int(s, "port", "local.port"),
3219
+ endpoints,
3220
+ ...typeof message === "string" ? { message } : {},
3221
+ updatedAt: int(s, "updatedAt", "local.updatedAt")
3222
+ };
3223
+ }
2970
3224
  function parseRelayTestStep(value) {
2971
3225
  const st = rec(value, "step");
2972
3226
  const id = str(st, "id", "step.id");
@@ -3048,6 +3302,7 @@ function parseReport(value) {
3048
3302
  historyBufferMax: int(s, "historyBufferMax", "historyBufferMax"),
3049
3303
  debug: bool(s, "debug", "debug"),
3050
3304
  lanAddresses,
3305
+ local: parseLocal(s.local),
3051
3306
  remote: parseRemote(s.remote),
3052
3307
  devices: devices.map(parseDevice)
3053
3308
  };
@@ -3207,7 +3462,32 @@ function applyReportRemote(ctx, snapshot, pairingStarter, deviceRevoker, deviceS
3207
3462
  });
3208
3463
  }
3209
3464
  //#endregion
3465
+ //#region src/relay-url.ts
3466
+ /**
3467
+ * Normalize an operator-configured relay URL without a backtracking regular
3468
+ * expression. Scanning from the end keeps even pathological inputs linear.
3469
+ */
3470
+ function normalizeRelayBaseUrl(value) {
3471
+ const trimmed = value.trim();
3472
+ let end = trimmed.length;
3473
+ while (end > 0 && trimmed.charCodeAt(end - 1) === 47) end -= 1;
3474
+ return end === trimmed.length ? trimmed : trimmed.slice(0, end);
3475
+ }
3476
+ //#endregion
3210
3477
  //#region src/relay-test.ts
3478
+ /**
3479
+ * Relay connectivity self-test used by the settings page "测试中继" button.
3480
+ *
3481
+ * Two steps mirror what zero-touch enrollment actually does:
3482
+ * 1. health — GET {url}/healthz → is the relay reachable?
3483
+ * 2. enroll — POST {url}/v1/enroll → does the shared key grant a token?
3484
+ *
3485
+ * The enroll step doubles as a repair path: a token it issues is handed back
3486
+ * via `onEnrolled` so the bridge caches it and flips push readiness without
3487
+ * waiting for the next app registration.
3488
+ *
3489
+ * Transport is injectable (`fetchImpl`) so tests never touch the network.
3490
+ */
3211
3491
  const DEFAULT_TIMEOUT_MS = 6e3;
3212
3492
  async function requestJson(fetchImpl, url, init, timeoutMs) {
3213
3493
  const response = await fetchImpl(url, {
@@ -3224,7 +3504,7 @@ async function requestJson(fetchImpl, url, init, timeoutMs) {
3224
3504
  };
3225
3505
  }
3226
3506
  async function runRelayProbe(options) {
3227
- const base = options.url.trim().replace(/\/+$/, "");
3507
+ const base = normalizeRelayBaseUrl(options.url);
3228
3508
  const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
3229
3509
  const fetchImpl = options.fetchImpl ?? fetch;
3230
3510
  const steps = [];
@@ -3534,7 +3814,7 @@ var RelayClient = class {
3534
3814
  debug;
3535
3815
  log;
3536
3816
  constructor(opts) {
3537
- this.base = opts.url.trim().replace(/\/+$/, "");
3817
+ this.base = normalizeRelayBaseUrl(opts.url);
3538
3818
  this.token = (opts.token ?? "").trim();
3539
3819
  this.timeoutMs = opts.timeoutMs ?? 1e4;
3540
3820
  this.debug = opts.debug === true;
@@ -3665,6 +3945,137 @@ function tunnelHelperArguments(originURL, statePath, options) {
3665
3945
  String(normalizeFunnelConnectionLimit(options.maxConnectionsPerSource))
3666
3946
  ];
3667
3947
  }
3948
+ /**
3949
+ * Summarize a fatal helper crash from its stderr tail. Go panics dump the
3950
+ * actual reason on the `panic:` line while the trailing stack frames are
3951
+ * `file.go:NN +0xOFF` noise — the old behavior of reporting only the final
3952
+ * line surfaced exactly that noise (e.g. `.../singleflight.go:194 +0x45c`)
3953
+ * and hid the real panic message. Keep the crash header line (a raw
3954
+ * `panic:`, a runtime `fatal error:`, or net/http's `http: panic serving`),
3955
+ * plus an optional `[signal ...]` continuation and the first non-runtime
3956
+ * stack frame's symbol + `file.go:NN` as a location hint. Pointer offsets
3957
+ * are not architecture stable, so they are deliberately dropped.
3958
+ *
3959
+ * The frame symbol sits on the line just above the indented source line in a
3960
+ * Go dump, e.g.:
3961
+ * tailscale.com/net/dnscache.(*Resolver).lookupIP(0x1400012c000, {...})
3962
+ * tailscale.com@v1.102.3/net/dnscache/dnscache.go:604 +0x25c
3963
+ * The first symbol (shortened) is used because a bare `file.go:NN` is not
3964
+ * unique across tailscale (singleflight.go is one example).
3965
+ */
3966
+ function helperCrashSummary(stderr) {
3967
+ const lines = stderr.split(/\r?\n/);
3968
+ for (let i = 0; i < lines.length; i++) {
3969
+ const line = lines[i].trim();
3970
+ if (line === "") continue;
3971
+ const logTimestamp = /^(\d{4}\/\d{2}\/\d{2} \d{2}:\d{2}:\d{2} )/.exec(line)?.[1] ?? "";
3972
+ if (!/^(panic:|fatal error:|http: panic serving)/.test(logTimestamp.length > 0 ? line.slice(logTimestamp.length) : line)) continue;
3973
+ let summary = logTimestamp.length > 0 ? line.slice(logTimestamp.length) : line;
3974
+ const next = lines[i + 1]?.trim();
3975
+ if (next?.startsWith("[signal")) summary += " " + next;
3976
+ let lastSymbol;
3977
+ for (let j = i + 1; j < lines.length; j++) {
3978
+ const frame = lines[j];
3979
+ const trimmed = frame.trim();
3980
+ if (trimmed === "") continue;
3981
+ const sourceMatch = /^\s+(\S+\.go:\d+)/.exec(frame);
3982
+ if (sourceMatch !== null) {
3983
+ const source = sourceMatch[1];
3984
+ if (source.startsWith("runtime/") || source.startsWith("runtime\\")) {
3985
+ lastSymbol = void 0;
3986
+ continue;
3987
+ }
3988
+ const slash = Math.max(source.lastIndexOf("/"), source.lastIndexOf("\\"));
3989
+ const file = slash >= 0 ? source.slice(slash + 1) : source;
3990
+ const symbol = lastSymbol !== void 0 ? shortenSymbol(lastSymbol) : void 0;
3991
+ summary += " @ " + (symbol !== void 0 ? `${symbol} (${file})` : file);
3992
+ break;
3993
+ }
3994
+ if (trimmed.includes("(") || trimmed.endsWith(")")) lastSymbol = trimmed;
3995
+ }
3996
+ return summary.replace(/\s+/g, " ").trim();
3997
+ }
3998
+ for (let i = lines.length - 1; i >= 0; i--) {
3999
+ const line = lines[i].trim();
4000
+ if (line !== "") return line;
4001
+ }
4002
+ return "";
4003
+ }
4004
+ /** Shrink a Go frame symbol to `pkg.Receiver.method` form (drop module-root
4005
+ * path, generic type arguments and the trailing call arguments), so the
4006
+ * summary hint stays readable. Keeps a `.funcN` closure suffix when present
4007
+ * (e.g. `doCall.func2`), since it identifies the exact closure.
4008
+ *
4009
+ * A symbol looks like:
4010
+ * tailscale.com/util/singleflight.(*Group[...]).doCall.func2(0x…, …)
4011
+ * The trailing call arguments form a balanced paren group that starts at
4012
+ * the function name — that group is stripped first; generics appear only
4013
+ * inside `(*Group[...])` receiver brackets before the final method dot, so
4014
+ * they are removed by trimming from the first `[` to the matching `]`.
4015
+ */
4016
+ function shortenSymbol(symbol) {
4017
+ const trimmed = symbol.trim();
4018
+ let end = trimmed.length;
4019
+ if (trimmed.endsWith(")")) {
4020
+ let depth = 0;
4021
+ for (let i = trimmed.length - 1; i >= 0; i--) {
4022
+ const ch = trimmed[i];
4023
+ if (ch === ")") depth++;
4024
+ else if (ch === "(") {
4025
+ depth--;
4026
+ if (depth === 0) {
4027
+ end = i;
4028
+ break;
4029
+ }
4030
+ }
4031
+ }
4032
+ }
4033
+ let name = trimmed.slice(0, end).trim();
4034
+ const bracketOpen = name.indexOf("[");
4035
+ if (bracketOpen >= 0) {
4036
+ const bracketClose = name.indexOf("]", bracketOpen);
4037
+ if (bracketClose > bracketOpen) name = name.slice(0, bracketOpen) + name.slice(bracketClose + 1);
4038
+ }
4039
+ const slash = Math.max(name.lastIndexOf("/"), name.lastIndexOf("\\"));
4040
+ return slash >= 0 ? name.slice(slash + 1) : name;
4041
+ }
4042
+ /** Shared by crash summary and dump: whether the stderr looks like a Go
4043
+ * panic/fatal-error crash (vs. an ordinary exit). */
4044
+ function looksLikeCrash(stderr) {
4045
+ return /(?:^|\n)\s*(?:\d{4}\/\d{2}\/\d{2} \d{2}:\d{2}:\d{2} )?(?:panic:|fatal error:|http: panic serving)/.test(stderr);
4046
+ }
4047
+ /** Turn a crash stderr tail into a status message and optionally persist the
4048
+ * full dump under the state dir. Keeps at most MAX_CRASH_DUMPS dump files. */
4049
+ async function analyzeCrashExit(stderr, statePath) {
4050
+ const summary = helperCrashSummary(stderr) || "helper exited";
4051
+ if (!looksLikeCrash(stderr) || stderr.trim() === "") return { summary };
4052
+ const target = statePath.trim();
4053
+ if (target === "") return { summary };
4054
+ try {
4055
+ const file = join(target, `crash-${Date.now()}.log`);
4056
+ await writeFile(file, stderr, { mode: 384 });
4057
+ await trimCrashDumps(target);
4058
+ return {
4059
+ summary,
4060
+ dumpPath: file
4061
+ };
4062
+ } catch {
4063
+ return { summary };
4064
+ }
4065
+ }
4066
+ /** Remove oldest crash-*.log files beyond MAX_CRASH_DUMPS. */
4067
+ async function trimCrashDumps(dir) {
4068
+ let names;
4069
+ try {
4070
+ names = (await readdir(dir)).filter((name) => name.startsWith("crash-") && name.endsWith(".log"));
4071
+ } catch {
4072
+ return;
4073
+ }
4074
+ if (names.length <= 6) return;
4075
+ names.sort();
4076
+ const excess = names.length - 6;
4077
+ await Promise.all(names.slice(0, excess).map((name) => rm(join(dir, name), { force: true }).catch(() => {})));
4078
+ }
3668
4079
  /** Parse one helper IPC line without ever evaluating or interpolating it. */
3669
4080
  function parseHelperEvent(line) {
3670
4081
  try {
@@ -3819,7 +4230,7 @@ var RemoteSupervisor = class {
3819
4230
  let stderrBuffer = "";
3820
4231
  child.stderr.setEncoding("utf8");
3821
4232
  child.stderr.on("data", (chunk) => {
3822
- stderrBuffer = (stderrBuffer + chunk).slice(-2e3);
4233
+ stderrBuffer = (stderrBuffer + chunk).slice(-16e3);
3823
4234
  });
3824
4235
  child.once("error", (error) => {
3825
4236
  this.setStatus({
@@ -3827,7 +4238,7 @@ var RemoteSupervisor = class {
3827
4238
  message: `helper launch failed: ${String(error)}`
3828
4239
  });
3829
4240
  });
3830
- child.once("exit", (code, signal) => {
4241
+ child.once("exit", () => {
3831
4242
  if (this.child === child) this.child = void 0;
3832
4243
  if (this.stopping) {
3833
4244
  this.setStatus({
@@ -3836,12 +4247,15 @@ var RemoteSupervisor = class {
3836
4247
  });
3837
4248
  return;
3838
4249
  }
3839
- const detail = stderrBuffer.trim().split("\n").at(-1);
3840
- this.setStatus({
3841
- phase: "error",
3842
- message: detail || `helper exited (${signal ?? String(code)})`
4250
+ analyzeCrashExit(stderrBuffer, this.options.statePath).then((analysis) => {
4251
+ if (this.stopping) return;
4252
+ const message = analysis.summary + (analysis.dumpPath !== void 0 ? ` (full dump: ${analysis.dumpPath})` : "");
4253
+ this.setStatus({
4254
+ phase: "error",
4255
+ message
4256
+ });
4257
+ this.scheduleRestart(originURL, "crash");
3843
4258
  });
3844
- this.scheduleRestart(originURL, "crash");
3845
4259
  });
3846
4260
  }
3847
4261
  async dispose() {
@@ -4134,6 +4548,24 @@ var UpdateChecker = class {
4134
4548
  dispose() {}
4135
4549
  };
4136
4550
  //#endregion
4551
+ //#region src/local-policy.ts
4552
+ const DEFAULT_LOCAL_PORT = 3098;
4553
+ const MIN_LOCAL_PORT = 1024;
4554
+ const MAX_LOCAL_PORT = 65535;
4555
+ function normalizeLocalPort(value) {
4556
+ return typeof value === "number" && Number.isInteger(value) && value >= 1024 && value <= 65535 ? value : DEFAULT_LOCAL_PORT;
4557
+ }
4558
+ function localEndpointURLs(addresses, port) {
4559
+ const normalizedPort = normalizeLocalPort(port);
4560
+ return [...new Set(addresses)].map((address) => `http://${address}:${normalizedPort}`);
4561
+ }
4562
+ function localListenError(error, port) {
4563
+ const code = error?.code;
4564
+ if (code === "EADDRINUSE") return `local port ${port} is already in use`;
4565
+ if (code === "EACCES") return `permission denied while opening local port ${port}`;
4566
+ return error instanceof Error ? error.message : String(error);
4567
+ }
4568
+ //#endregion
4137
4569
  //#region src/config.ts
4138
4570
  /** Operator-run relay used by distributed builds; overridable via config. */
4139
4571
  const DEFAULT_RELAY_URL = "https://pilot.hailab.dev";
@@ -4142,6 +4574,13 @@ const Config = z.object({
4142
4574
  devicesPath: z.string().default(join(bridgeDataDir(), "devices-v2.json")),
4143
4575
  historyBufferMax: z.natural().min(100).default(2e3),
4144
4576
  debug: z.boolean().default(false),
4577
+ local: z.object({
4578
+ enabled: z.boolean().default(true),
4579
+ port: z.natural().min(MIN_LOCAL_PORT).max(MAX_LOCAL_PORT).default(DEFAULT_LOCAL_PORT).description("DeepPilot 局域网独立端口(默认 3098,修改后本地连接会短暂重连)")
4580
+ }).default({
4581
+ enabled: true,
4582
+ port: DEFAULT_LOCAL_PORT
4583
+ }),
4145
4584
  remote: z.object({
4146
4585
  enabled: z.boolean().default(false),
4147
4586
  provider: z.union(["tailscale-funnel"]).default("tailscale-funnel"),
@@ -4364,12 +4803,64 @@ function shouldReEnrollRelayToken(transport, outcome, reason, opts) {
4364
4803
  return transport === "relay" && outcome === "failed" && reason === "HTTP 401" && opts.hasEnrollKey && opts.usedCellToken && opts.tokenStillCurrent;
4365
4804
  }
4366
4805
  //#endregion
4806
+ //#region src/phone-server.ts
4807
+ /**
4808
+ * A deliberately narrow transport listener. Both the LAN endpoint and the
4809
+ * loopback-only Funnel origin use this factory, so neither can accidentally
4810
+ * inherit DSH's wider web/API route surface.
4811
+ */
4812
+ function createPhoneServer(handlers) {
4813
+ const server = createServer((req, res) => {
4814
+ const path = requestPath(req);
4815
+ if (path === "/phone/health") handlers.health(req, res);
4816
+ else if (path === "/phone/pair") handlers.pair(req, res);
4817
+ else {
4818
+ res.statusCode = 404;
4819
+ res.end("not found");
4820
+ }
4821
+ });
4822
+ server.on("upgrade", (req, socket, head) => {
4823
+ if (requestPath(req) !== "/phone") {
4824
+ socket.end("HTTP/1.1 404 Not Found\r\nConnection: close\r\n\r\n");
4825
+ return;
4826
+ }
4827
+ handlers.upgrade(req, socket, head);
4828
+ });
4829
+ return server;
4830
+ }
4831
+ function requestPath(req) {
4832
+ try {
4833
+ return new URL(req.url ?? "/", "http://phone.local").pathname;
4834
+ } catch {
4835
+ return "/";
4836
+ }
4837
+ }
4838
+ function listen(server, port, host) {
4839
+ return new Promise((resolve, reject) => {
4840
+ const onError = (error) => {
4841
+ server.off("listening", onListening);
4842
+ reject(error);
4843
+ };
4844
+ const onListening = () => {
4845
+ server.off("error", onError);
4846
+ resolve();
4847
+ };
4848
+ server.once("error", onError);
4849
+ server.once("listening", onListening);
4850
+ server.listen(port, host);
4851
+ });
4852
+ }
4853
+ function closeServer(server) {
4854
+ if (server === void 0 || !server.listening) return Promise.resolve();
4855
+ return new Promise((resolve) => server.close(() => resolve()));
4856
+ }
4857
+ //#endregion
4367
4858
  //#region src/index.ts
4368
4859
  /**
4369
4860
  * dsh-deeppilot — data bridge between the DSH host and DeepPilot
4370
- * clients. Registers exactly one WebSocket upgrade route (/phone) plus an
4371
- * optional health probe (/phone/health) on the existing web server. The web
4372
- * UI is never touched.
4861
+ * clients. Owns independent, narrowly routed LAN and Funnel-origin listeners;
4862
+ * temporary compatibility routes remain on the existing DSH web server. The
4863
+ * web UI and the rest of DSH's API are never exposed by these listeners.
4373
4864
  *
4374
4865
  * Data plane: an in-process HostBridge consumes a local compatibility façade
4375
4866
  * over DSH 0.1.2 Session/Workspace controllers, mirrors session summaries,
@@ -4380,7 +4871,7 @@ function shouldReEnrollRelayToken(transport, outcome, reason, opts) {
4380
4871
  * Swift models mirror that v2 contract.
4381
4872
  */
4382
4873
  const name = "deeppilot";
4383
- /** No eager service requirement: profiles without a web stack simply skip. */
4874
+ /** No eager web-service requirement: independent transports start on their own. */
4384
4875
  const inject = [];
4385
4876
  const SERVER_VERSION = readOwnPackageVersion();
4386
4877
  const MAX_CLIENT_CONNECTIONS = 16;
@@ -4420,6 +4911,7 @@ function apply(ctx, options) {
4420
4911
  */
4421
4912
  let liveSource;
4422
4913
  let scheduleRemoteReconcile;
4914
+ let scheduleLocalReconcile;
4423
4915
  const currentConfig = () => {
4424
4916
  if (liveSource !== void 0) return normalizeOptions(liveSource());
4425
4917
  return normalizeOptions(options);
@@ -4428,9 +4920,15 @@ function apply(ctx, options) {
4428
4920
  const settingsHooks = {
4429
4921
  setSource: (source) => {
4430
4922
  liveSource = source;
4431
- queueMicrotask(() => scheduleRemoteReconcile?.());
4923
+ queueMicrotask(() => {
4924
+ scheduleLocalReconcile?.();
4925
+ scheduleRemoteReconcile?.();
4926
+ });
4432
4927
  },
4433
- onChange: () => queueMicrotask(() => scheduleRemoteReconcile?.())
4928
+ onChange: () => queueMicrotask(() => {
4929
+ scheduleLocalReconcile?.();
4930
+ scheduleRemoteReconcile?.();
4931
+ })
4434
4932
  };
4435
4933
  ctx.inject(["settings"], (settingsCtx) => {
4436
4934
  settingsCtx.settings.installSection(ctx, "deeppilot", Config, normalizeOptions(void 0), settingsHooks);
@@ -4836,6 +5334,16 @@ function apply(ctx, options) {
4836
5334
  maxPayload: MAX_FRAME_BYTES
4837
5335
  });
4838
5336
  let remoteSupervisor;
5337
+ let localState = {
5338
+ phase: currentConfig().enabled === true && currentConfig().local?.enabled !== false ? "starting" : "disabled",
5339
+ port: normalizeLocalPort(currentConfig().local?.port),
5340
+ endpoints: [],
5341
+ updatedAt: Date.now()
5342
+ };
5343
+ const localStatus = (addresses) => ({
5344
+ ...localState,
5345
+ endpoints: localState.phase === "online" ? localEndpointURLs(addresses, localState.port) : []
5346
+ });
4839
5347
  const remoteStatus = () => remoteSupervisor?.status() ?? {
4840
5348
  provider: "tailscale-funnel",
4841
5349
  phase: currentConfig().remote?.enabled === true ? "stopped" : "disabled",
@@ -4869,6 +5377,7 @@ function apply(ctx, options) {
4869
5377
  }));
4870
5378
  } catch {}
4871
5379
  const update = updateInfo();
5380
+ const lanAddresses = localLANIPv4Addresses();
4872
5381
  return {
4873
5382
  protocolVersion: 2,
4874
5383
  serverVersion: SERVER_VERSION,
@@ -4881,7 +5390,8 @@ function apply(ctx, options) {
4881
5390
  activeConnections: connections.size,
4882
5391
  historyBufferMax: currentConfig().historyBufferMax ?? 2e3,
4883
5392
  debug: currentConfig().debug === true,
4884
- lanAddresses: localLANIPv4Addresses(),
5393
+ lanAddresses,
5394
+ local: localStatus(lanAddresses),
4885
5395
  remote: remoteStatus(),
4886
5396
  devices
4887
5397
  };
@@ -5151,7 +5661,173 @@ function apply(ctx, options) {
5151
5661
  res.end(JSON.stringify({ ok: false }));
5152
5662
  }
5153
5663
  };
5154
- ctx.inject(["sessionController"], (sub) => {
5664
+ const phoneHandlers = {
5665
+ health: handleHealth,
5666
+ pair: handlePair,
5667
+ upgrade: handleUpgrade
5668
+ };
5669
+ let localServer;
5670
+ let appliedLocalKey;
5671
+ let localDisposed = false;
5672
+ let localTail = Promise.resolve();
5673
+ const reconcileLocal = async () => {
5674
+ if (localDisposed) return;
5675
+ const config = currentConfig();
5676
+ const localConfig = config.local ?? {};
5677
+ const next = {
5678
+ enabled: config.enabled === true && localConfig.enabled !== false,
5679
+ port: normalizeLocalPort(localConfig.port)
5680
+ };
5681
+ const nextKey = JSON.stringify(next);
5682
+ if (nextKey === appliedLocalKey) return;
5683
+ appliedLocalKey = nextKey;
5684
+ const previous = localServer;
5685
+ localServer = void 0;
5686
+ await closeServer(previous);
5687
+ if (localDisposed) return;
5688
+ if (!next.enabled) {
5689
+ localState = {
5690
+ phase: "disabled",
5691
+ port: next.port,
5692
+ endpoints: [],
5693
+ updatedAt: Date.now()
5694
+ };
5695
+ log("local transport disabled");
5696
+ return;
5697
+ }
5698
+ localState = {
5699
+ phase: "starting",
5700
+ port: next.port,
5701
+ endpoints: [],
5702
+ updatedAt: Date.now()
5703
+ };
5704
+ const server = createPhoneServer(phoneHandlers);
5705
+ localServer = server;
5706
+ try {
5707
+ await listen(server, next.port, "0.0.0.0");
5708
+ if (localDisposed || localServer !== server) {
5709
+ await closeServer(server);
5710
+ return;
5711
+ }
5712
+ localState = {
5713
+ phase: "online",
5714
+ port: next.port,
5715
+ endpoints: [],
5716
+ updatedAt: Date.now()
5717
+ };
5718
+ log(`local transport listening on 0.0.0.0:${next.port}`);
5719
+ } catch (error) {
5720
+ if (localServer === server) localServer = void 0;
5721
+ await closeServer(server);
5722
+ const message = localListenError(error, next.port);
5723
+ localState = {
5724
+ phase: "error",
5725
+ port: next.port,
5726
+ endpoints: [],
5727
+ message,
5728
+ updatedAt: Date.now()
5729
+ };
5730
+ log("local transport failed: " + message);
5731
+ }
5732
+ };
5733
+ scheduleLocalReconcile = () => {
5734
+ localTail = localTail.then(reconcileLocal).catch((error) => log("local reconcile failed: " + String(error)));
5735
+ };
5736
+ const originServer = createPhoneServer(phoneHandlers);
5737
+ let originURL;
5738
+ let appliedRemoteKey;
5739
+ let remoteDisposed = false;
5740
+ let remoteTail = Promise.resolve();
5741
+ const reconcileRemote = async () => {
5742
+ if (remoteDisposed || originURL === void 0) return;
5743
+ const config = currentConfig();
5744
+ const remoteConfig = config.remote ?? {};
5745
+ const remotePort = remoteConfig.funnelPort === 8443 || remoteConfig.funnelPort === 1e4 ? remoteConfig.funnelPort : 443;
5746
+ const helperPath = remoteConfig.helperPath?.trim() || void 0;
5747
+ const next = {
5748
+ enabled: config.enabled === true && remoteConfig.enabled === true && remoteConfig.provider === "tailscale-funnel",
5749
+ hostname: normalizeRemoteHostname(remoteConfig.hostname),
5750
+ statePath: remoteConfig.statePath?.trim() || join(dataDir, "tailscale"),
5751
+ helperPath,
5752
+ funnelPort: remotePort,
5753
+ maxConnectionsPerSource: normalizeFunnelConnectionLimit(remoteConfig.maxConnectionsPerSource)
5754
+ };
5755
+ const nextKey = JSON.stringify(next);
5756
+ if (nextKey === appliedRemoteKey) return;
5757
+ const previous = remoteSupervisor;
5758
+ remoteSupervisor = void 0;
5759
+ if (previous !== void 0) await previous.dispose();
5760
+ if (remoteDisposed) return;
5761
+ const supervisor = new RemoteSupervisor({
5762
+ enabled: next.enabled,
5763
+ hostname: next.hostname,
5764
+ statePath: next.statePath,
5765
+ ...next.helperPath ? { helperPath: next.helperPath } : {},
5766
+ funnelPort: next.funnelPort,
5767
+ maxConnectionsPerSource: next.maxConnectionsPerSource,
5768
+ log
5769
+ });
5770
+ remoteSupervisor = supervisor;
5771
+ appliedRemoteKey = nextKey;
5772
+ await supervisor.start(originURL);
5773
+ };
5774
+ scheduleRemoteReconcile = () => {
5775
+ remoteTail = remoteTail.then(reconcileRemote).catch((error) => log("remote reconcile failed: " + String(error)));
5776
+ };
5777
+ ctx.effect(() => {
5778
+ scheduleLocalReconcile?.();
5779
+ listen(originServer, 0, "127.0.0.1").then(() => {
5780
+ const address = originServer.address();
5781
+ if (address && typeof address === "object") {
5782
+ originURL = `http://127.0.0.1:${address.port}`;
5783
+ scheduleRemoteReconcile?.();
5784
+ }
5785
+ }, (error) => log("remote origin failed: " + String(error)));
5786
+ return async () => {
5787
+ localDisposed = true;
5788
+ remoteDisposed = true;
5789
+ scheduleLocalReconcile = void 0;
5790
+ scheduleRemoteReconcile = void 0;
5791
+ const activeLocal = localServer;
5792
+ localServer = void 0;
5793
+ await Promise.allSettled([closeServer(activeLocal), closeServer(originServer)]);
5794
+ localState = {
5795
+ phase: "stopped",
5796
+ port: localState.port,
5797
+ endpoints: [],
5798
+ updatedAt: Date.now()
5799
+ };
5800
+ await Promise.allSettled([localTail, remoteTail.then(async () => {
5801
+ const supervisor = remoteSupervisor;
5802
+ remoteSupervisor = void 0;
5803
+ if (supervisor !== void 0) await supervisor.dispose();
5804
+ })]);
5805
+ };
5806
+ }, "deeppilot: independent transports");
5807
+ const sweep = setInterval(() => {
5808
+ const now = Date.now();
5809
+ for (const connection of connections) if (connection.isStale(now, 6e4)) {
5810
+ log("dropping stale connection");
5811
+ connection.closeIdle();
5812
+ connections.delete(connection);
5813
+ }
5814
+ }, 3e4);
5815
+ ctx.effect(() => () => clearInterval(sweep), "deeppilot: stale sweep");
5816
+ /**
5817
+ * Whether the resident Gateway Client represents a real phone surface. A
5818
+ * paired device remains answerable even while offline: HostBridge retains an
5819
+ * authoritative pending snapshot that the app pulls on reconnect, while
5820
+ * APNs is only a best-effort wakeup. With no paired device this Client calls
5821
+ * `next()`; Gateway's independent official Web delivery is unaffected.
5822
+ */
5823
+ const hasPairedPhoneSurface = (_kind) => {
5824
+ return (auth.devices?.list() ?? []).some((device) => device.revokedAt === void 0);
5825
+ };
5826
+ ctx.inject([
5827
+ "sessionController",
5828
+ "connection",
5829
+ "typertGateway"
5830
+ ], (sub) => {
5155
5831
  if (currentConfig().enabled !== true) {
5156
5832
  log("bridge disabled; data plane stays inactive");
5157
5833
  return;
@@ -5159,7 +5835,7 @@ function apply(ctx, options) {
5159
5835
  const apiCtx = sub;
5160
5836
  let proxy;
5161
5837
  try {
5162
- proxy = new Dsh012ApiProxy(apiCtx);
5838
+ proxy = new Dsh012ApiProxy(apiCtx, { shouldSurfaceInteraction: hasPairedPhoneSurface });
5163
5839
  } catch (error) {
5164
5840
  log("dsh 0.1.2 session bridge unavailable: " + String(error));
5165
5841
  return;
@@ -5179,7 +5855,7 @@ function apply(ctx, options) {
5179
5855
  const webCtx = sub;
5180
5856
  const web = webCtx.webServer;
5181
5857
  if (!web) {
5182
- log("webServer service absent in this profile; bridge stays inactive");
5858
+ log("webServer service absent; 3080 compatibility routes unavailable (independent transports remain active)");
5183
5859
  return;
5184
5860
  }
5185
5861
  webCtx.effect(() => web.registerUpgrade({
@@ -5196,98 +5872,8 @@ function apply(ctx, options) {
5196
5872
  path: "/phone/pair",
5197
5873
  handler: handlePair
5198
5874
  }), "deeppilot: /phone/pair");
5199
- const sweep = setInterval(() => {
5200
- const now = Date.now();
5201
- for (const connection of connections) if (connection.isStale(now, 6e4)) {
5202
- log("dropping stale connection");
5203
- connection.closeIdle();
5204
- connections.delete(connection);
5205
- }
5206
- }, 3e4);
5207
- webCtx.effect(() => () => clearInterval(sweep), "deeppilot: stale sweep");
5208
- const originServer = createServer((req, res) => {
5209
- let path = "/";
5210
- try {
5211
- path = new URL(req.url ?? "/", "http://phone.local").pathname;
5212
- } catch {}
5213
- if (path === "/phone/health") handleHealth(req, res);
5214
- else if (path === "/phone/pair") handlePair(req, res);
5215
- else {
5216
- res.statusCode = 404;
5217
- res.end("not found");
5218
- }
5219
- });
5220
- originServer.on("upgrade", (req, socket, head) => {
5221
- let path = "/";
5222
- try {
5223
- path = new URL(req.url ?? "/", "http://phone.local").pathname;
5224
- } catch {}
5225
- if (path !== "/phone") {
5226
- socket.end("HTTP/1.1 404 Not Found\r\nConnection: close\r\n\r\n");
5227
- return;
5228
- }
5229
- handleUpgrade(req, socket, head);
5230
- });
5231
- let originURL;
5232
- let appliedRemoteKey;
5233
- let remoteDisposed = false;
5234
- let reconcileTail = Promise.resolve();
5235
- const reconcileRemote = async () => {
5236
- if (remoteDisposed || originURL === void 0) return;
5237
- const config = currentConfig();
5238
- const remoteConfig = config.remote ?? {};
5239
- const remotePort = remoteConfig.funnelPort === 8443 || remoteConfig.funnelPort === 1e4 ? remoteConfig.funnelPort : 443;
5240
- const helperPath = remoteConfig.helperPath?.trim() || void 0;
5241
- const next = {
5242
- enabled: config.enabled === true && remoteConfig.enabled === true && remoteConfig.provider === "tailscale-funnel",
5243
- hostname: normalizeRemoteHostname(remoteConfig.hostname),
5244
- statePath: remoteConfig.statePath?.trim() || join(dataDir, "tailscale"),
5245
- helperPath,
5246
- funnelPort: remotePort,
5247
- maxConnectionsPerSource: normalizeFunnelConnectionLimit(remoteConfig.maxConnectionsPerSource)
5248
- };
5249
- const nextKey = JSON.stringify(next);
5250
- if (nextKey === appliedRemoteKey) return;
5251
- const previous = remoteSupervisor;
5252
- remoteSupervisor = void 0;
5253
- if (previous !== void 0) await previous.dispose();
5254
- if (remoteDisposed) return;
5255
- const supervisor = new RemoteSupervisor({
5256
- enabled: next.enabled,
5257
- hostname: next.hostname,
5258
- statePath: next.statePath,
5259
- ...next.helperPath ? { helperPath: next.helperPath } : {},
5260
- funnelPort: next.funnelPort,
5261
- maxConnectionsPerSource: next.maxConnectionsPerSource,
5262
- log
5263
- });
5264
- remoteSupervisor = supervisor;
5265
- appliedRemoteKey = nextKey;
5266
- await supervisor.start(originURL);
5267
- };
5268
- scheduleRemoteReconcile = () => {
5269
- reconcileTail = reconcileTail.then(reconcileRemote).catch((error) => log("remote reconcile failed: " + String(error)));
5270
- };
5271
- originServer.listen(0, "127.0.0.1", () => {
5272
- const address = originServer.address();
5273
- if (address && typeof address === "object") {
5274
- originURL = `http://127.0.0.1:${address.port}`;
5275
- scheduleRemoteReconcile?.();
5276
- }
5277
- });
5278
- originServer.on("error", (error) => log("remote origin failed: " + String(error)));
5279
- webCtx.effect(() => () => {
5280
- remoteDisposed = true;
5281
- scheduleRemoteReconcile = void 0;
5282
- originServer.close();
5283
- reconcileTail = reconcileTail.then(async () => {
5284
- const supervisor = remoteSupervisor;
5285
- remoteSupervisor = void 0;
5286
- if (supervisor !== void 0) await supervisor.dispose();
5287
- });
5288
- }, "deeppilot: embedded Funnel");
5289
- if (enabledNow()) log("/phone WebSocket registered");
5290
- else log("bridge disabled; /phone refuses connections until re-enabled and restarted");
5875
+ if (enabledNow()) log("/phone compatibility routes registered on DSH web server");
5876
+ else log("bridge disabled; /phone refuses connections until re-enabled");
5291
5877
  });
5292
5878
  ctx.effect(() => async () => {
5293
5879
  closeAllConnections();