dsh-deeppilot 0.6.0-alpha.3 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/index.js CHANGED
@@ -1,7 +1,6 @@
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";
@@ -15,8 +14,11 @@ import { fileURLToPath } from "node:url";
15
14
  import { request } from "node:https";
16
15
  import z from "@deepseek-ai/schemastery";
17
16
  import { isIP } from "node:net";
17
+ import { createServer } from "node:http";
18
18
  //#region src/device-auth.ts
19
- const PAIRING_CODE_TTL_MS = 3e5;
19
+ function getPairingCodeTtlMs() {
20
+ return Number(process.env.DEEPPILOT_PAIRING_TTL_MS) || 3e5;
21
+ }
20
22
  const AUTH_CHALLENGE_TTL_MS = 3e4;
21
23
  const DEVICE_SCOPES = [
22
24
  "sessions.read",
@@ -106,7 +108,7 @@ var PairingCodeManager = class {
106
108
  issue(now = Date.now()) {
107
109
  const grant = {
108
110
  code: randomBytes(24).toString("base64url"),
109
- expiresAt: now + PAIRING_CODE_TTL_MS
111
+ expiresAt: now + getPairingCodeTtlMs()
110
112
  };
111
113
  this.active = grant;
112
114
  return { ...grant };
@@ -3197,6 +3199,28 @@ function parseRemote(value) {
3197
3199
  updatedAt: int(s, "updatedAt", "remote.updatedAt")
3198
3200
  };
3199
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
+ }
3200
3224
  function parseRelayTestStep(value) {
3201
3225
  const st = rec(value, "step");
3202
3226
  const id = str(st, "id", "step.id");
@@ -3278,6 +3302,7 @@ function parseReport(value) {
3278
3302
  historyBufferMax: int(s, "historyBufferMax", "historyBufferMax"),
3279
3303
  debug: bool(s, "debug", "debug"),
3280
3304
  lanAddresses,
3305
+ local: parseLocal(s.local),
3281
3306
  remote: parseRemote(s.remote),
3282
3307
  devices: devices.map(parseDevice)
3283
3308
  };
@@ -3437,7 +3462,32 @@ function applyReportRemote(ctx, snapshot, pairingStarter, deviceRevoker, deviceS
3437
3462
  });
3438
3463
  }
3439
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
3440
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
+ */
3441
3491
  const DEFAULT_TIMEOUT_MS = 6e3;
3442
3492
  async function requestJson(fetchImpl, url, init, timeoutMs) {
3443
3493
  const response = await fetchImpl(url, {
@@ -3454,7 +3504,7 @@ async function requestJson(fetchImpl, url, init, timeoutMs) {
3454
3504
  };
3455
3505
  }
3456
3506
  async function runRelayProbe(options) {
3457
- const base = options.url.trim().replace(/\/+$/, "");
3507
+ const base = normalizeRelayBaseUrl(options.url);
3458
3508
  const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
3459
3509
  const fetchImpl = options.fetchImpl ?? fetch;
3460
3510
  const steps = [];
@@ -3764,7 +3814,7 @@ var RelayClient = class {
3764
3814
  debug;
3765
3815
  log;
3766
3816
  constructor(opts) {
3767
- this.base = opts.url.trim().replace(/\/+$/, "");
3817
+ this.base = normalizeRelayBaseUrl(opts.url);
3768
3818
  this.token = (opts.token ?? "").trim();
3769
3819
  this.timeoutMs = opts.timeoutMs ?? 1e4;
3770
3820
  this.debug = opts.debug === true;
@@ -3895,6 +3945,137 @@ function tunnelHelperArguments(originURL, statePath, options) {
3895
3945
  String(normalizeFunnelConnectionLimit(options.maxConnectionsPerSource))
3896
3946
  ];
3897
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
+ }
3898
4079
  /** Parse one helper IPC line without ever evaluating or interpolating it. */
3899
4080
  function parseHelperEvent(line) {
3900
4081
  try {
@@ -4049,7 +4230,7 @@ var RemoteSupervisor = class {
4049
4230
  let stderrBuffer = "";
4050
4231
  child.stderr.setEncoding("utf8");
4051
4232
  child.stderr.on("data", (chunk) => {
4052
- stderrBuffer = (stderrBuffer + chunk).slice(-2e3);
4233
+ stderrBuffer = (stderrBuffer + chunk).slice(-16e3);
4053
4234
  });
4054
4235
  child.once("error", (error) => {
4055
4236
  this.setStatus({
@@ -4057,7 +4238,7 @@ var RemoteSupervisor = class {
4057
4238
  message: `helper launch failed: ${String(error)}`
4058
4239
  });
4059
4240
  });
4060
- child.once("exit", (code, signal) => {
4241
+ child.once("exit", () => {
4061
4242
  if (this.child === child) this.child = void 0;
4062
4243
  if (this.stopping) {
4063
4244
  this.setStatus({
@@ -4066,12 +4247,15 @@ var RemoteSupervisor = class {
4066
4247
  });
4067
4248
  return;
4068
4249
  }
4069
- const detail = stderrBuffer.trim().split("\n").at(-1);
4070
- this.setStatus({
4071
- phase: "error",
4072
- 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");
4073
4258
  });
4074
- this.scheduleRestart(originURL, "crash");
4075
4259
  });
4076
4260
  }
4077
4261
  async dispose() {
@@ -4364,6 +4548,24 @@ var UpdateChecker = class {
4364
4548
  dispose() {}
4365
4549
  };
4366
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
4367
4569
  //#region src/config.ts
4368
4570
  /** Operator-run relay used by distributed builds; overridable via config. */
4369
4571
  const DEFAULT_RELAY_URL = "https://pilot.hailab.dev";
@@ -4372,6 +4574,13 @@ const Config = z.object({
4372
4574
  devicesPath: z.string().default(join(bridgeDataDir(), "devices-v2.json")),
4373
4575
  historyBufferMax: z.natural().min(100).default(2e3),
4374
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
+ }),
4375
4584
  remote: z.object({
4376
4585
  enabled: z.boolean().default(false),
4377
4586
  provider: z.union(["tailscale-funnel"]).default("tailscale-funnel"),
@@ -4594,12 +4803,64 @@ function shouldReEnrollRelayToken(transport, outcome, reason, opts) {
4594
4803
  return transport === "relay" && outcome === "failed" && reason === "HTTP 401" && opts.hasEnrollKey && opts.usedCellToken && opts.tokenStillCurrent;
4595
4804
  }
4596
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
4597
4858
  //#region src/index.ts
4598
4859
  /**
4599
4860
  * dsh-deeppilot — data bridge between the DSH host and DeepPilot
4600
- * clients. Registers exactly one WebSocket upgrade route (/phone) plus an
4601
- * optional health probe (/phone/health) on the existing web server. The web
4602
- * 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.
4603
4864
  *
4604
4865
  * Data plane: an in-process HostBridge consumes a local compatibility façade
4605
4866
  * over DSH 0.1.2 Session/Workspace controllers, mirrors session summaries,
@@ -4610,7 +4871,7 @@ function shouldReEnrollRelayToken(transport, outcome, reason, opts) {
4610
4871
  * Swift models mirror that v2 contract.
4611
4872
  */
4612
4873
  const name = "deeppilot";
4613
- /** No eager service requirement: profiles without a web stack simply skip. */
4874
+ /** No eager web-service requirement: independent transports start on their own. */
4614
4875
  const inject = [];
4615
4876
  const SERVER_VERSION = readOwnPackageVersion();
4616
4877
  const MAX_CLIENT_CONNECTIONS = 16;
@@ -4650,6 +4911,7 @@ function apply(ctx, options) {
4650
4911
  */
4651
4912
  let liveSource;
4652
4913
  let scheduleRemoteReconcile;
4914
+ let scheduleLocalReconcile;
4653
4915
  const currentConfig = () => {
4654
4916
  if (liveSource !== void 0) return normalizeOptions(liveSource());
4655
4917
  return normalizeOptions(options);
@@ -4658,9 +4920,15 @@ function apply(ctx, options) {
4658
4920
  const settingsHooks = {
4659
4921
  setSource: (source) => {
4660
4922
  liveSource = source;
4661
- queueMicrotask(() => scheduleRemoteReconcile?.());
4923
+ queueMicrotask(() => {
4924
+ scheduleLocalReconcile?.();
4925
+ scheduleRemoteReconcile?.();
4926
+ });
4662
4927
  },
4663
- onChange: () => queueMicrotask(() => scheduleRemoteReconcile?.())
4928
+ onChange: () => queueMicrotask(() => {
4929
+ scheduleLocalReconcile?.();
4930
+ scheduleRemoteReconcile?.();
4931
+ })
4664
4932
  };
4665
4933
  ctx.inject(["settings"], (settingsCtx) => {
4666
4934
  settingsCtx.settings.installSection(ctx, "deeppilot", Config, normalizeOptions(void 0), settingsHooks);
@@ -5066,6 +5334,16 @@ function apply(ctx, options) {
5066
5334
  maxPayload: MAX_FRAME_BYTES
5067
5335
  });
5068
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
+ });
5069
5347
  const remoteStatus = () => remoteSupervisor?.status() ?? {
5070
5348
  provider: "tailscale-funnel",
5071
5349
  phase: currentConfig().remote?.enabled === true ? "stopped" : "disabled",
@@ -5099,6 +5377,7 @@ function apply(ctx, options) {
5099
5377
  }));
5100
5378
  } catch {}
5101
5379
  const update = updateInfo();
5380
+ const lanAddresses = localLANIPv4Addresses();
5102
5381
  return {
5103
5382
  protocolVersion: 2,
5104
5383
  serverVersion: SERVER_VERSION,
@@ -5111,7 +5390,8 @@ function apply(ctx, options) {
5111
5390
  activeConnections: connections.size,
5112
5391
  historyBufferMax: currentConfig().historyBufferMax ?? 2e3,
5113
5392
  debug: currentConfig().debug === true,
5114
- lanAddresses: localLANIPv4Addresses(),
5393
+ lanAddresses,
5394
+ local: localStatus(lanAddresses),
5115
5395
  remote: remoteStatus(),
5116
5396
  devices
5117
5397
  };
@@ -5381,6 +5661,158 @@ function apply(ctx, options) {
5381
5661
  res.end(JSON.stringify({ ok: false }));
5382
5662
  }
5383
5663
  };
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");
5384
5816
  /**
5385
5817
  * Whether the resident Gateway Client represents a real phone surface. A
5386
5818
  * paired device remains answerable even while offline: HostBridge retains an
@@ -5423,7 +5855,7 @@ function apply(ctx, options) {
5423
5855
  const webCtx = sub;
5424
5856
  const web = webCtx.webServer;
5425
5857
  if (!web) {
5426
- log("webServer service absent in this profile; bridge stays inactive");
5858
+ log("webServer service absent; 3080 compatibility routes unavailable (independent transports remain active)");
5427
5859
  return;
5428
5860
  }
5429
5861
  webCtx.effect(() => web.registerUpgrade({
@@ -5440,98 +5872,8 @@ function apply(ctx, options) {
5440
5872
  path: "/phone/pair",
5441
5873
  handler: handlePair
5442
5874
  }), "deeppilot: /phone/pair");
5443
- const sweep = setInterval(() => {
5444
- const now = Date.now();
5445
- for (const connection of connections) if (connection.isStale(now, 6e4)) {
5446
- log("dropping stale connection");
5447
- connection.closeIdle();
5448
- connections.delete(connection);
5449
- }
5450
- }, 3e4);
5451
- webCtx.effect(() => () => clearInterval(sweep), "deeppilot: stale sweep");
5452
- const originServer = createServer((req, res) => {
5453
- let path = "/";
5454
- try {
5455
- path = new URL(req.url ?? "/", "http://phone.local").pathname;
5456
- } catch {}
5457
- if (path === "/phone/health") handleHealth(req, res);
5458
- else if (path === "/phone/pair") handlePair(req, res);
5459
- else {
5460
- res.statusCode = 404;
5461
- res.end("not found");
5462
- }
5463
- });
5464
- originServer.on("upgrade", (req, socket, head) => {
5465
- let path = "/";
5466
- try {
5467
- path = new URL(req.url ?? "/", "http://phone.local").pathname;
5468
- } catch {}
5469
- if (path !== "/phone") {
5470
- socket.end("HTTP/1.1 404 Not Found\r\nConnection: close\r\n\r\n");
5471
- return;
5472
- }
5473
- handleUpgrade(req, socket, head);
5474
- });
5475
- let originURL;
5476
- let appliedRemoteKey;
5477
- let remoteDisposed = false;
5478
- let reconcileTail = Promise.resolve();
5479
- const reconcileRemote = async () => {
5480
- if (remoteDisposed || originURL === void 0) return;
5481
- const config = currentConfig();
5482
- const remoteConfig = config.remote ?? {};
5483
- const remotePort = remoteConfig.funnelPort === 8443 || remoteConfig.funnelPort === 1e4 ? remoteConfig.funnelPort : 443;
5484
- const helperPath = remoteConfig.helperPath?.trim() || void 0;
5485
- const next = {
5486
- enabled: config.enabled === true && remoteConfig.enabled === true && remoteConfig.provider === "tailscale-funnel",
5487
- hostname: normalizeRemoteHostname(remoteConfig.hostname),
5488
- statePath: remoteConfig.statePath?.trim() || join(dataDir, "tailscale"),
5489
- helperPath,
5490
- funnelPort: remotePort,
5491
- maxConnectionsPerSource: normalizeFunnelConnectionLimit(remoteConfig.maxConnectionsPerSource)
5492
- };
5493
- const nextKey = JSON.stringify(next);
5494
- if (nextKey === appliedRemoteKey) return;
5495
- const previous = remoteSupervisor;
5496
- remoteSupervisor = void 0;
5497
- if (previous !== void 0) await previous.dispose();
5498
- if (remoteDisposed) return;
5499
- const supervisor = new RemoteSupervisor({
5500
- enabled: next.enabled,
5501
- hostname: next.hostname,
5502
- statePath: next.statePath,
5503
- ...next.helperPath ? { helperPath: next.helperPath } : {},
5504
- funnelPort: next.funnelPort,
5505
- maxConnectionsPerSource: next.maxConnectionsPerSource,
5506
- log
5507
- });
5508
- remoteSupervisor = supervisor;
5509
- appliedRemoteKey = nextKey;
5510
- await supervisor.start(originURL);
5511
- };
5512
- scheduleRemoteReconcile = () => {
5513
- reconcileTail = reconcileTail.then(reconcileRemote).catch((error) => log("remote reconcile failed: " + String(error)));
5514
- };
5515
- originServer.listen(0, "127.0.0.1", () => {
5516
- const address = originServer.address();
5517
- if (address && typeof address === "object") {
5518
- originURL = `http://127.0.0.1:${address.port}`;
5519
- scheduleRemoteReconcile?.();
5520
- }
5521
- });
5522
- originServer.on("error", (error) => log("remote origin failed: " + String(error)));
5523
- webCtx.effect(() => () => {
5524
- remoteDisposed = true;
5525
- scheduleRemoteReconcile = void 0;
5526
- originServer.close();
5527
- reconcileTail = reconcileTail.then(async () => {
5528
- const supervisor = remoteSupervisor;
5529
- remoteSupervisor = void 0;
5530
- if (supervisor !== void 0) await supervisor.dispose();
5531
- });
5532
- }, "deeppilot: embedded Funnel");
5533
- if (enabledNow()) log("/phone WebSocket registered");
5534
- 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");
5535
5877
  });
5536
5878
  ctx.effect(() => async () => {
5537
5879
  closeAllConnections();