dsh-deeppilot 0.2.0 → 0.2.2

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,3 +1,4 @@
1
+ import { createRequire } from "node:module";
1
2
  import { createServer } from "node:http";
2
3
  import { createPrivateKey, randomBytes, randomUUID, sign, timingSafeEqual } from "node:crypto";
3
4
  import { access, mkdir, readFile, rename, writeFile } from "node:fs/promises";
@@ -11,6 +12,7 @@ import { connect } from "node:http2";
11
12
  import { spawn } from "node:child_process";
12
13
  import { constants } from "node:fs";
13
14
  import { fileURLToPath } from "node:url";
15
+ import { request } from "node:https";
14
16
  //#region src/token.ts
15
17
  /** Expand a leading ~ using the process home directory. */
16
18
  function expandHome(p) {
@@ -362,6 +364,9 @@ var BridgeConnection = class {
362
364
  sessions: this.deps.bridge.listSessions()
363
365
  }, env.id);
364
366
  return;
367
+ case "c2s.pending.list":
368
+ this.send("s2c.pending.snapshot", this.deps.bridge.pendingSnapshot(), env.id);
369
+ return;
365
370
  case "c2s.workspaces.list": {
366
371
  if (!this.deps.bridge.capabilities.projectSelection) return this.fail(env.id, "E_UNSUPPORTED", "project selection unavailable on this host version");
367
372
  const result = await this.deps.bridge.listWorkspaces();
@@ -395,10 +400,15 @@ var BridgeConnection = class {
395
400
  }
396
401
  case "c2s.session.open": {
397
402
  const p = env.payload;
398
- if (!p?.sessionId) return this.fail(env.id, "E_PROTOCOL", "sessionId required");
399
- this.openSessions.add(p.sessionId);
400
- this.deps.bridge.markSinkOpen(this, p.sessionId);
401
- if (!await this.deps.bridge.openSession(this, p.sessionId, p.tailCount ?? 100)) this.fail(env.id, "E_NOT_FOUND", "session history unavailable");
403
+ if (!p?.sessionId || typeof p.sessionId !== "string") return this.fail(env.id, "E_PROTOCOL", "sessionId required");
404
+ const sessionId = p.sessionId;
405
+ this.openSessions.add(sessionId);
406
+ this.deps.bridge.markSinkOpen(this, sessionId);
407
+ if (!await this.deps.bridge.openSession(this, sessionId, p.tailCount ?? 100)) {
408
+ this.openSessions.delete(sessionId);
409
+ this.deps.bridge.markSinkClosed(this, sessionId);
410
+ return this.fail(env.id, "E_NOT_FOUND", "session history unavailable");
411
+ }
402
412
  return;
403
413
  }
404
414
  case "c2s.session.close": {
@@ -526,21 +536,23 @@ var BridgeConnection = class {
526
536
  });
527
537
  }
528
538
  const userSeq = await this.deps.bridge.sendPrompt(p.sessionId, text, images);
529
- if (userSeq === null) return this.fail(env.id, "E_BUSY", "session busy or unavailable");
530
- this.send("s2c.ack", { userSeq }, env.id);
539
+ if (!userSeq.ok) return this.fail(env.id, managementErrorCode(userSeq.kind), userSeq.message);
540
+ this.send("s2c.ack", { userSeq: userSeq.value }, env.id);
531
541
  return;
532
542
  }
533
543
  case "c2s.approval.respond": {
534
544
  const p = env.payload;
535
545
  if (!p?.requestId || p.decision !== "allow" && p.decision !== "deny") return this.fail(env.id, "E_PROTOCOL", "requestId and decision required");
536
- if (!await this.deps.bridge.respondApproval(p.requestId, p.decision)) return this.fail(env.id, "E_NOT_FOUND", "approval not pending");
546
+ const outcome = await this.deps.bridge.respondApproval(p.requestId, p.decision, typeof p.reason === "string" ? p.reason : void 0);
547
+ if (!outcome.ok) return this.fail(env.id, pendingResponseErrorCode(outcome.reason), pendingResponseMessage("approval", outcome.reason));
537
548
  this.send("s2c.ack", {}, env.id);
538
549
  return;
539
550
  }
540
551
  case "c2s.question.respond": {
541
552
  const p = env.payload;
542
553
  if (!p?.requestId || !Array.isArray(p.answers)) return this.fail(env.id, "E_PROTOCOL", "requestId and answers required");
543
- if (!await this.deps.bridge.respondQuestion(p.requestId, p.answers)) return this.fail(env.id, "E_NOT_FOUND", "question not pending");
554
+ const outcome = await this.deps.bridge.respondQuestion(p.requestId, p.answers);
555
+ if (!outcome.ok) return this.fail(env.id, pendingResponseErrorCode(outcome.reason), pendingResponseMessage("question", outcome.reason));
544
556
  this.send("s2c.ack", {}, env.id);
545
557
  return;
546
558
  }
@@ -612,6 +624,23 @@ var BridgeConnection = class {
612
624
  }
613
625
  }
614
626
  };
627
+ /** Error code for a failed approval/question response outcome. */
628
+ function pendingResponseErrorCode(reason) {
629
+ switch (reason) {
630
+ case "not-pending": return "E_NOT_FOUND";
631
+ case "bad-response": return "E_PROTOCOL";
632
+ case "transport": return "E_INTERNAL";
633
+ }
634
+ }
635
+ /** Human-readable failure detail; `question not pending` must only ever mean
636
+ * "nothing pending", never "the host rejected the answer". */
637
+ function pendingResponseMessage(kind, reason) {
638
+ switch (reason) {
639
+ case "not-pending": return kind + " not pending";
640
+ case "bad-response": return kind + " answer rejected by host: answer does not match the asked questions";
641
+ case "transport": return "host connection failed while answering " + kind;
642
+ }
643
+ }
615
644
  function managementErrorCode(kind) {
616
645
  switch (kind) {
617
646
  case "unsupported": return "E_UNSUPPORTED";
@@ -677,6 +706,7 @@ var HostBridge = class {
677
706
  replay: true,
678
707
  approvals: true,
679
708
  questions: true,
709
+ pendingSnapshot: true,
680
710
  models: typeof this.apiProxy.sessions.models === "function" && typeof this.apiProxy.sessions.selectModel === "function",
681
711
  sessionManagement: typeof this.apiProxy.sessions.rename === "function" && typeof this.apiProxy.workspace?.archiveSession === "function",
682
712
  projectSelection: typeof this.apiProxy.workspace?.list === "function" && typeof this.apiProxy.workspace?.create === "function",
@@ -698,7 +728,7 @@ var HostBridge = class {
698
728
  /** Whether the ring still holds everything after the cursor. */
699
729
  canResumeFrom(cursor) {
700
730
  const oldest = this.ring.length > 0 ? this.ring[0].seq : this.cursor + 1;
701
- return cursor + 1 >= oldest;
731
+ return cursor <= this.cursor && cursor + 1 >= oldest;
702
732
  }
703
733
  sinkSessions = /* @__PURE__ */ new Map();
704
734
  lastAssistantText = /* @__PURE__ */ new Map();
@@ -1040,6 +1070,27 @@ var HostBridge = class {
1040
1070
  listSessions() {
1041
1071
  return [...this.summaries.values()].sort((a, b) => b.lastActivityTs - a.lastActivityTs);
1042
1072
  }
1073
+ /**
1074
+ * Complete transient interaction state. Unlike the replay ring, this remains
1075
+ * authoritative after a long disconnect and is rehydrated by apiProxy's mux
1076
+ * stream when the bridge itself restarts.
1077
+ */
1078
+ pendingSnapshot() {
1079
+ return {
1080
+ approvals: [...this.approvals.entries()].map(([requestId, pending]) => ({
1081
+ requestId,
1082
+ sessionId: pending.sessionId,
1083
+ toolName: pending.toolName,
1084
+ summary: pending.reason,
1085
+ riskLevel: riskOf(pending.toolName)
1086
+ })),
1087
+ questions: [...this.questions.entries()].map(([requestId, pending]) => ({
1088
+ requestId,
1089
+ sessionId: pending.sessionId,
1090
+ questions: Array.isArray(pending.questions) ? pending.questions : []
1091
+ }))
1092
+ };
1093
+ }
1043
1094
  /** Tail history for an opened session; pushes s2c.session.tail to the sink. */
1044
1095
  async openSession(sink, sessionId, tailCount) {
1045
1096
  try {
@@ -1445,22 +1496,38 @@ var HostBridge = class {
1445
1496
  clientTimeZone: localTimeZone()
1446
1497
  }
1447
1498
  });
1448
- if (!response.result || !response.result.ok) return null;
1499
+ if (!response.result) return {
1500
+ ok: false,
1501
+ kind: "internal",
1502
+ message: "prompt returned no result"
1503
+ };
1504
+ if (!response.result.ok) return hostSessionManagementError(response.result.error);
1449
1505
  const row = this.summaries.get(sessionId);
1450
1506
  if (row) {
1451
1507
  row.lastActivityTs = Date.now();
1452
1508
  this.pushSummary(row);
1453
1509
  }
1454
- return Date.now();
1455
- } catch {
1456
- return null;
1510
+ return {
1511
+ ok: true,
1512
+ value: Date.now()
1513
+ };
1514
+ } catch (error) {
1515
+ return {
1516
+ ok: false,
1517
+ kind: "internal",
1518
+ message: String(error)
1519
+ };
1457
1520
  }
1458
1521
  }
1459
- async respondApproval(requestId, decision) {
1522
+ async respondApproval(requestId, decision, reason) {
1460
1523
  const pending = this.approvals.get(requestId);
1461
- if (!pending) return false;
1524
+ if (!pending) return {
1525
+ ok: false,
1526
+ reason: "not-pending"
1527
+ };
1462
1528
  this.approvals.delete(requestId);
1463
1529
  const outcome = decision === "allow" ? "allowed-once" : "rejected";
1530
+ const denialReason = typeof reason === "string" ? reason.trim().slice(0, 500) : "";
1464
1531
  try {
1465
1532
  const receipt = await this.apiProxy.respond({
1466
1533
  type: "client-response",
@@ -1470,21 +1537,35 @@ var HostBridge = class {
1470
1537
  value: {
1471
1538
  sessionId: pending.sessionId,
1472
1539
  approvalId: requestId,
1473
- outcome
1540
+ outcome,
1541
+ ...denialReason.length > 0 ? { reason: denialReason } : {}
1474
1542
  }
1475
1543
  }
1476
1544
  });
1477
- const accepted = Boolean(receipt?.accepted);
1478
- if (!accepted && !this.approvals.has(requestId)) this.approvals.set(requestId, pending);
1479
- return accepted;
1545
+ if (!Boolean(receipt?.accepted)) {
1546
+ const failure = receiptFailureReason(receipt);
1547
+ if (failure !== "not-pending" && !this.approvals.has(requestId)) this.approvals.set(requestId, pending);
1548
+ return {
1549
+ ok: false,
1550
+ reason: failure
1551
+ };
1552
+ }
1553
+ this.bumpPendingFlags(pending.sessionId);
1554
+ return { ok: true };
1480
1555
  } catch {
1481
1556
  if (!this.approvals.has(requestId)) this.approvals.set(requestId, pending);
1482
- return false;
1557
+ return {
1558
+ ok: false,
1559
+ reason: "transport"
1560
+ };
1483
1561
  }
1484
1562
  }
1485
1563
  async respondQuestion(requestId, answers) {
1486
1564
  const pending = this.questions.get(requestId);
1487
- if (!pending) return false;
1565
+ if (!pending) return {
1566
+ ok: false,
1567
+ reason: "not-pending"
1568
+ };
1488
1569
  this.questions.delete(requestId);
1489
1570
  try {
1490
1571
  const receipt = await this.apiProxy.respond({
@@ -1494,19 +1575,65 @@ var HostBridge = class {
1494
1575
  ok: true,
1495
1576
  value: {
1496
1577
  sessionId: pending.sessionId,
1497
- answer: { answers }
1578
+ answer: { answers: normalizeAnswerItems(answers, pending.questions) }
1498
1579
  }
1499
1580
  }
1500
1581
  });
1501
- const accepted = Boolean(receipt?.accepted);
1502
- if (!accepted && !this.questions.has(requestId)) this.questions.set(requestId, pending);
1503
- return accepted;
1582
+ if (!Boolean(receipt?.accepted)) {
1583
+ const failure = receiptFailureReason(receipt);
1584
+ if (failure !== "not-pending" && !this.questions.has(requestId)) this.questions.set(requestId, pending);
1585
+ return {
1586
+ ok: false,
1587
+ reason: failure
1588
+ };
1589
+ }
1590
+ this.bumpPendingFlags(pending.sessionId);
1591
+ return { ok: true };
1504
1592
  } catch {
1505
1593
  if (!this.questions.has(requestId)) this.questions.set(requestId, pending);
1506
- return false;
1594
+ return {
1595
+ ok: false,
1596
+ reason: "transport"
1597
+ };
1507
1598
  }
1508
1599
  }
1509
1600
  };
1601
+ /**
1602
+ * The host validates question answers strictly (core dsh-user-questions via
1603
+ * apiProxy): a present-but-empty `custom` fails `matchesQuestions`, and a
1604
+ * single-select question rejects `custom` combined with a selection. Clients
1605
+ * may send lenient shapes (the phone historically always attached
1606
+ * `"custom": ""`, which made EVERY option-only answer fail), so normalize to
1607
+ * exactly what the host accepts before forwarding.
1608
+ */
1609
+ function normalizeAnswerItems(raw, questions) {
1610
+ if (!Array.isArray(raw)) return [];
1611
+ const askedById = /* @__PURE__ */ new Map();
1612
+ if (Array.isArray(questions)) {
1613
+ for (const q of questions) if (typeof q === "object" && q !== null && typeof q.id === "string") askedById.set(q.id, q);
1614
+ }
1615
+ const items = [];
1616
+ for (const entry of raw) {
1617
+ if (typeof entry !== "object" || entry === null) continue;
1618
+ const r = entry;
1619
+ if (typeof r.id !== "string") continue;
1620
+ const selected = [...new Set(Array.isArray(r.selected) ? r.selected.filter((s) => typeof s === "string") : [])];
1621
+ const customText = typeof r.custom === "string" ? r.custom : "";
1622
+ let custom;
1623
+ if (customText.trim().length > 0) custom = customText;
1624
+ if (custom !== void 0 && selected.length > 0 && askedById.get(r.id)?.multiSelect !== true) custom = void 0;
1625
+ items.push({
1626
+ id: r.id,
1627
+ selected,
1628
+ ...custom !== void 0 ? { custom } : {}
1629
+ });
1630
+ }
1631
+ return items;
1632
+ }
1633
+ /** Map an apiProxy respond receipt onto the failure vocabulary. */
1634
+ function receiptFailureReason(receipt) {
1635
+ return receipt?.reason === "not-pending" ? "not-pending" : "bad-response";
1636
+ }
1510
1637
  function clampTail(n) {
1511
1638
  if (!Number.isFinite(n)) return 100;
1512
1639
  return Math.max(10, Math.min(500, Math.floor(n)));
@@ -1598,9 +1725,10 @@ function projectEvent(sessionId, event) {
1598
1725
  kind: "message.final",
1599
1726
  data: {
1600
1727
  seq: event.seq,
1601
- role: "user",
1728
+ role: userRoleOf(event.data),
1602
1729
  text: messageText(event.data),
1603
1730
  ...attachmentProjection(event.data),
1731
+ ...contextProjectionOf(event.data),
1604
1732
  ts: tsOf(event)
1605
1733
  }
1606
1734
  };
@@ -1644,27 +1772,99 @@ function projectEvent(sessionId, event) {
1644
1772
  tool: {
1645
1773
  name: String(data?.name ?? "tool"),
1646
1774
  state: "running",
1647
- summary: summarizeArgs(data?.arguments)
1775
+ summary: summarizeArgs(data?.arguments),
1776
+ ...data?.callId ? { callId: String(data.callId) } : {}
1648
1777
  },
1649
1778
  ts: tsOf(event)
1650
1779
  }
1651
1780
  };
1652
1781
  }
1653
- case "tool/result": return {
1654
- kind: "tool.end",
1655
- data: {
1656
- seq: event.seq,
1657
- role: "tool",
1658
- ok: !event.data || event.data.error === void 0,
1659
- ts: tsOf(event)
1660
- }
1661
- };
1782
+ case "tool/result": {
1783
+ const data = event.data;
1784
+ return {
1785
+ kind: "tool.end",
1786
+ data: {
1787
+ seq: event.seq,
1788
+ role: "tool",
1789
+ ok: !event.data || data?.error === void 0,
1790
+ ...data?.callId ? { callId: String(data.callId) } : {},
1791
+ ts: tsOf(event)
1792
+ }
1793
+ };
1794
+ }
1662
1795
  default: return null;
1663
1796
  }
1664
1797
  }
1665
1798
  function tsOf(event) {
1666
1799
  return typeof event.time === "number" ? event.time : Date.now();
1667
1800
  }
1801
+ /** Read the durable message source off one user/message payload. Handles both
1802
+ * bare-message payloads and older `{message: {...}}` wrappers; undefined when
1803
+ * the shape carries no readable source (legacy hosts). */
1804
+ function userMessageSource(data) {
1805
+ if (!data || typeof data !== "object") return void 0;
1806
+ const obj = data;
1807
+ if (obj.source && typeof obj.source === "object") return obj.source;
1808
+ if (obj.message && typeof obj.message === "object" && obj.message.source && typeof obj.message.source === "object") return obj.message.source;
1809
+ }
1810
+ /** Wire role for one user/message payload. A payload without any readable
1811
+ * source degrades to 'user' so history written by older hosts stays visible;
1812
+ * a present source follows the host's own trajectory rule — anything whose
1813
+ * `kind` is not 'user' is injected context and projects as 'system'. */
1814
+ function userRoleOf(data) {
1815
+ const source = userMessageSource(data);
1816
+ if (!source) return "user";
1817
+ return source.kind === "user" ? "user" : "system";
1818
+ }
1819
+ /** Producer name of one injected-context source, mirroring how the DSH client
1820
+ * runtime derives its trajectory label: plugin name, skill name, instruction
1821
+ * paths, session-reference labels, or the raw kind as fallback. */
1822
+ function contextLabelOf(source) {
1823
+ const kind = typeof source.kind === "string" ? source.kind : "";
1824
+ const joined = (member) => {
1825
+ const list = source[member];
1826
+ if (!Array.isArray(list)) return void 0;
1827
+ const names = list.flatMap((entry) => {
1828
+ if (!entry || typeof entry !== "object") return [];
1829
+ const record = entry;
1830
+ return [typeof record.label === "string" ? record.label : typeof record.path === "string" ? record.path : ""];
1831
+ }).filter((name) => name.length > 0);
1832
+ return names.length > 0 ? names.join(", ") : void 0;
1833
+ };
1834
+ switch (kind) {
1835
+ case "session-reference": return joined("references") ?? (kind || void 0);
1836
+ case "agent-instructions": return joined("changes") ?? (kind || void 0);
1837
+ case "plugin": return typeof source.plugin === "string" && source.plugin.length > 0 ? source.plugin : kind || void 0;
1838
+ case "skill-invocation": return typeof source.name === "string" && source.name.length > 0 ? source.name : kind || void 0;
1839
+ default: return kind || void 0;
1840
+ }
1841
+ }
1842
+ /** Semantic ContextForm declared by the producer ('snapshot', 'notice', …);
1843
+ * anything unrecognized stays undefined so clients render it opaque. */
1844
+ function contextFormOf(source) {
1845
+ if (typeof source.form !== "string" || source.form.length === 0) return void 0;
1846
+ return [
1847
+ "instructions",
1848
+ "catalog",
1849
+ "snapshot",
1850
+ "notice",
1851
+ "relay",
1852
+ "recall"
1853
+ ].includes(source.form) ? source.form : void 0;
1854
+ }
1855
+ /** Optional `context` metadata for one system row; {} on user rows. */
1856
+ function contextProjectionOf(data) {
1857
+ if (userRoleOf(data) !== "system") return {};
1858
+ const source = userMessageSource(data);
1859
+ if (!source) return {};
1860
+ const label = contextLabelOf(source);
1861
+ const form = contextFormOf(source);
1862
+ if (!label && !form) return {};
1863
+ return { context: {
1864
+ ...label ? { label } : {},
1865
+ ...form ? { form } : {}
1866
+ } };
1867
+ }
1668
1868
  /** Extract plain text from user/assistant message payloads across shapes. */
1669
1869
  function messageText(data) {
1670
1870
  if (typeof data === "string") return data;
@@ -1874,9 +2074,10 @@ function projectHistory(events) {
1874
2074
  case "user/message":
1875
2075
  messages.push({
1876
2076
  ...base,
1877
- role: "user",
2077
+ role: userRoleOf(event.data),
1878
2078
  text: messageText(event.data),
1879
- ...attachmentProjection(event.data)
2079
+ ...attachmentProjection(event.data),
2080
+ ...contextProjectionOf(event.data)
1880
2081
  });
1881
2082
  break;
1882
2083
  case "assistant/message": {
@@ -2123,9 +2324,13 @@ function parseReport(value) {
2123
2324
  const lanAddresses = s.lanAddresses;
2124
2325
  if (!Array.isArray(devices)) reject("devices");
2125
2326
  if (!Array.isArray(lanAddresses) || lanAddresses.some((value) => typeof value !== "string")) reject("lanAddresses");
2327
+ const releaseUrl = s.releaseUrl;
2126
2328
  return {
2127
2329
  protocolVersion: num(s, "protocolVersion", "protocolVersion"),
2128
2330
  serverVersion: str(s, "serverVersion", "serverVersion"),
2331
+ pluginVersion: str(s, "pluginVersion", "pluginVersion"),
2332
+ ...s.updateAvailable === true ? { updateAvailable: true } : {},
2333
+ ...typeof releaseUrl === "string" && releaseUrl.length > 0 ? { releaseUrl } : {},
2129
2334
  enabled: bool(s, "enabled", "enabled"),
2130
2335
  tokenPath: str(s, "tokenPath", "tokenPath"),
2131
2336
  tokenReady: bool(s, "tokenReady", "tokenReady"),
@@ -2677,9 +2882,30 @@ function parseHelperEvent(line) {
2677
2882
  return null;
2678
2883
  }
2679
2884
  }
2680
- function bundledHelperPath() {
2885
+ /** Build the list of candidate locations for the embedded tunnel helper, in
2886
+ * priority order. The first existing executable wins at start() time. The
2887
+ * order matters: explicit config (handled by the caller) > npm install
2888
+ * layout > DSH-bundled layout > user data dir. */
2889
+ function bundledHelperCandidates() {
2681
2890
  const here = dirname(fileURLToPath(import.meta.url));
2682
- return resolve(here, "..", "bin", `${process.platform}-${process.arch}`, "dsh-deeppilot-tunnel");
2891
+ const pkgRoot = resolve(here, "..");
2892
+ const fileName = process.platform === "win32" ? "dsh-deeppilot-tunnel.exe" : "dsh-deeppilot-tunnel";
2893
+ const platformDir = `${process.platform}-${process.arch}`;
2894
+ const candidates = [];
2895
+ candidates.push(resolve(pkgRoot, "bin", platformDir, fileName));
2896
+ candidates.push(resolve(pkgRoot, "..", "..", "..", "node_modules", "dsh-deeppilot", "bin", platformDir, fileName));
2897
+ candidates.push(resolve(pkgRoot, "..", "..", "dsh-deeppilot", "bin", platformDir, fileName));
2898
+ candidates.push(resolve(pkgRoot, "..", "..", "..", "..", "node_modules", "dsh-deeppilot", "bin", platformDir, fileName));
2899
+ try {
2900
+ const resolved = createRequire(import.meta.url).resolve(`dsh-deeppilot/bin/${platformDir}/${fileName}`);
2901
+ if (!candidates.includes(resolved)) candidates.push(resolved);
2902
+ } catch {}
2903
+ const home = process.env.DSH_HOME?.trim() || process.env.HOME || process.env.USERPROFILE;
2904
+ if (home && home.length > 0) {
2905
+ const dataDir = resolve(home, ".dsh");
2906
+ candidates.push(join(dataDir, "deeppilot", "bin", platformDir, fileName));
2907
+ }
2908
+ return candidates;
2683
2909
  }
2684
2910
  /** Owns exactly one embedded tunnel helper and restarts it after failures. */
2685
2911
  var RemoteSupervisor = class {
@@ -2702,21 +2928,42 @@ var RemoteSupervisor = class {
2702
2928
  }
2703
2929
  async start(originURL) {
2704
2930
  if (!this.options.enabled || this.child !== void 0 || this.stopping) return;
2705
- const helper = expandHome(this.options.helperPath ?? bundledHelperPath());
2706
2931
  const statePath = expandHome(this.options.statePath);
2932
+ const configured = this.options.helperPath?.trim() ?? "";
2933
+ const candidates = configured ? [expandHome(configured)] : bundledHelperCandidates();
2934
+ let helper;
2935
+ let lastError;
2936
+ for (const candidate of candidates) try {
2937
+ await access(candidate, constants.X_OK);
2938
+ helper = candidate;
2939
+ break;
2940
+ } catch (error) {
2941
+ lastError = error;
2942
+ }
2943
+ if (helper === void 0) {
2944
+ if (this.stopping) return;
2945
+ const platform = `${process.platform}-${process.arch}`;
2946
+ const message = configured ? `embedded tunnel helper unavailable: ${configured}: ${String(lastError ?? "not found")}` : `embedded tunnel helper not found for ${platform} (tried: ${candidates.join(", ")}); set remote.helperPath to override`;
2947
+ this.setStatus({
2948
+ phase: "unavailable",
2949
+ message
2950
+ });
2951
+ return;
2952
+ }
2707
2953
  try {
2708
- await access(helper, constants.X_OK);
2709
2954
  await mkdir(statePath, {
2710
2955
  recursive: true,
2711
2956
  mode: 448
2712
2957
  });
2713
2958
  } catch (error) {
2959
+ if (this.stopping) return;
2714
2960
  this.setStatus({
2715
2961
  phase: "unavailable",
2716
- message: `embedded tunnel helper unavailable: ${String(error)}`
2962
+ message: `cannot create remote state dir: ${String(error)}`
2717
2963
  });
2718
2964
  return;
2719
2965
  }
2966
+ if (this.stopping) return;
2720
2967
  this.setStatus({
2721
2968
  phase: "starting",
2722
2969
  message: void 0
@@ -2875,6 +3122,203 @@ function localLANIPv4Addresses() {
2875
3122
  return [...new Set(candidates.map(({ address }) => address))];
2876
3123
  }
2877
3124
  //#endregion
3125
+ //#region src/update-check.ts
3126
+ /**
3127
+ * Lightweight self-update check for dsh-deeppilot.
3128
+ *
3129
+ * On Host boot we ask the GitHub Releases API (REST) which is the latest
3130
+ * stable tag, compare it to the installed plugin version, and surface a
3131
+ * "newer release exists" flag plus the GitHub release URL through the
3132
+ * report Remote. The settings page renders one small line at the bottom;
3133
+ * a successful check is enough — no manual button, no persistent cache
3134
+ * (the host process is the lifetime of the answer).
3135
+ *
3136
+ * Deliberately no third-party dependency: we use {@link https.request}
3137
+ * directly to keep parity with the rest of the project (remote-supervisor
3138
+ * uses node:http, host-bridge uses ws, etc).
3139
+ *
3140
+ * Failure policy: every network/parse error collapses to a single log
3141
+ * line and the in-memory snapshot stays "unknown". The bridge must never
3142
+ * crash because GitHub rate-limited us, returned a 5xx, or the user is
3143
+ * offline.
3144
+ */
3145
+ /** GitHub repo (no .git suffix). Public, unauthenticated, low rate limit. */
3146
+ const RELEASES_PATH = "/repos/Mars-Sea/dsh-deeppilot/releases";
3147
+ /** Hard ceiling on the network round-trip. The host must never hang. */
3148
+ const FETCH_TIMEOUT_MS = 8e3;
3149
+ /** Per-page limit. We only need the first stable release, but pre-releases
3150
+ * tend to be listed first; fetching 20 gives the comparator enough room. */
3151
+ const PER_PAGE = 20;
3152
+ function isPlainObject(value) {
3153
+ return typeof value === "object" && value !== null && !Array.isArray(value);
3154
+ }
3155
+ function parseStableEntry(value) {
3156
+ if (!isPlainObject(value)) return null;
3157
+ const tag = value.tag_name;
3158
+ if (typeof tag !== "string") return null;
3159
+ if (value.prerelease === true || value.draft === true) return null;
3160
+ if (parseStableTag(tag) === null) return null;
3161
+ const url = value.html_url;
3162
+ return {
3163
+ tag,
3164
+ url: typeof url === "string" ? url : null
3165
+ };
3166
+ }
3167
+ /** Parse one stable release from the `tag_name` shape `vX.Y.Z` (the v is
3168
+ * optional; `1.2.3` is also accepted). Pre-release tags like `0.3.0-rc.1`
3169
+ * return null — the policy is "stable channel only". */
3170
+ function parseStableTag(tag) {
3171
+ const match = /^v?(\d+)\.(\d+)\.(\d+)$/.exec(tag.trim());
3172
+ if (match === null) return null;
3173
+ return {
3174
+ major: Number(match[1]),
3175
+ minor: Number(match[2]),
3176
+ patch: Number(match[3])
3177
+ };
3178
+ }
3179
+ /** Semver compare for X.Y.Z. Returns -1 / 0 / 1. */
3180
+ function compareSemver(a, b) {
3181
+ const pa = parseStableTag(a);
3182
+ const pb = parseStableTag(b);
3183
+ if (pa === null && pb === null) return 0;
3184
+ if (pa === null) return -1;
3185
+ if (pb === null) return 1;
3186
+ if (pa.major !== pb.major) return pa.major < pb.major ? -1 : 1;
3187
+ if (pa.minor !== pb.minor) return pa.minor < pb.minor ? -1 : 1;
3188
+ if (pa.patch !== pb.patch) return pa.patch < pb.patch ? -1 : 1;
3189
+ return 0;
3190
+ }
3191
+ /** Hit the GitHub Releases API. Resolves with the first stable release
3192
+ * GitHub returned, or null if the list contains no stable entries.
3193
+ * Network / parse errors reject — the caller is responsible for
3194
+ * collapsing them to a log line. */
3195
+ function fetchLatestStableRelease() {
3196
+ return new Promise((resolve, reject) => {
3197
+ const req = request({
3198
+ method: "GET",
3199
+ host: "api.github.com",
3200
+ path: `${RELEASES_PATH}?per_page=${PER_PAGE}`,
3201
+ headers: {
3202
+ "user-agent": "dsh-deeppilot-update-check",
3203
+ "accept": "application/vnd.github+json"
3204
+ }
3205
+ }, (res) => {
3206
+ const status = res.statusCode ?? 0;
3207
+ if (status < 200 || status >= 300) {
3208
+ res.resume();
3209
+ reject(/* @__PURE__ */ new Error(`github releases http ${status}`));
3210
+ return;
3211
+ }
3212
+ const chunks = [];
3213
+ res.on("data", (chunk) => chunks.push(chunk));
3214
+ res.on("end", () => {
3215
+ try {
3216
+ const body = Buffer.concat(chunks).toString("utf8");
3217
+ const parsed = JSON.parse(body);
3218
+ if (!Array.isArray(parsed)) {
3219
+ reject(/* @__PURE__ */ new Error("github releases: response is not an array"));
3220
+ return;
3221
+ }
3222
+ for (const entry of parsed) {
3223
+ const stable = parseStableEntry(entry);
3224
+ if (stable !== null) {
3225
+ resolve(stable);
3226
+ return;
3227
+ }
3228
+ }
3229
+ resolve(null);
3230
+ } catch (error) {
3231
+ reject(error instanceof Error ? error : new Error(String(error)));
3232
+ }
3233
+ });
3234
+ res.on("error", (error) => reject(error));
3235
+ });
3236
+ req.setTimeout(FETCH_TIMEOUT_MS, () => {
3237
+ req.destroy(/* @__PURE__ */ new Error("github releases: timeout after 8000ms"));
3238
+ });
3239
+ req.on("error", (error) => reject(error));
3240
+ req.end();
3241
+ });
3242
+ }
3243
+ /**
3244
+ * Process-wide check state. Constructed once in `apply()`, queried
3245
+ * synchronously by the report snapshot. The check itself fires once in
3246
+ * the background shortly after boot; the answer lives for the lifetime
3247
+ * of the host process — re-running the page in the Web UI does not
3248
+ * trigger another network call.
3249
+ */
3250
+ var UpdateChecker = class {
3251
+ log;
3252
+ currentVersion;
3253
+ fetchImpl;
3254
+ initialDelayMs;
3255
+ snapshot;
3256
+ inflight = null;
3257
+ constructor(options) {
3258
+ this.log = options.log;
3259
+ this.currentVersion = options.currentVersion;
3260
+ this.fetchImpl = options.fetchImpl ?? fetchLatestStableRelease;
3261
+ this.initialDelayMs = options.initialDelayMs ?? 2e3;
3262
+ this.snapshot = {
3263
+ currentVersion: this.currentVersion,
3264
+ available: false,
3265
+ releaseUrl: null,
3266
+ latestVersion: null
3267
+ };
3268
+ }
3269
+ /** Return the current in-memory snapshot — safe to call from any host
3270
+ * thread. Never throws, never awaits. */
3271
+ get() {
3272
+ return this.snapshot;
3273
+ }
3274
+ /**
3275
+ * Schedule one background refresh after the configured initial delay.
3276
+ * Used by the plugin entry to do the first check without blocking boot.
3277
+ */
3278
+ scheduleInitial() {
3279
+ if (this.initialDelayMs <= 0) {
3280
+ this.runOnce();
3281
+ return;
3282
+ }
3283
+ const timer = setTimeout(() => {
3284
+ this.runOnce();
3285
+ }, this.initialDelayMs);
3286
+ if (typeof timer.unref === "function") timer.unref();
3287
+ }
3288
+ async runOnce() {
3289
+ if (this.inflight !== null) {
3290
+ await this.inflight;
3291
+ return;
3292
+ }
3293
+ const task = (async () => {
3294
+ try {
3295
+ const stable = await this.fetchImpl();
3296
+ if (stable === null) return;
3297
+ if (compareSemver(stable.tag, this.currentVersion) > 0) this.snapshot = {
3298
+ currentVersion: this.currentVersion,
3299
+ available: true,
3300
+ releaseUrl: stable.url,
3301
+ latestVersion: stable.tag
3302
+ };
3303
+ else this.snapshot = {
3304
+ currentVersion: this.currentVersion,
3305
+ available: false,
3306
+ releaseUrl: null,
3307
+ latestVersion: null
3308
+ };
3309
+ } catch (error) {
3310
+ this.log("update check failed: " + (error instanceof Error ? error.message : String(error)));
3311
+ }
3312
+ })();
3313
+ this.inflight = task.finally(() => {
3314
+ this.inflight = null;
3315
+ });
3316
+ return this.inflight;
3317
+ }
3318
+ /** No-op kept for API symmetry with the host lifecycle wiring. */
3319
+ dispose() {}
3320
+ };
3321
+ //#endregion
2878
3322
  //#region src/index.ts
2879
3323
  /**
2880
3324
  * dsh-deeppilot — data bridge between the DSH host and DeepPilot
@@ -2933,7 +3377,7 @@ const Config = z.object({
2933
3377
  relayToken: ""
2934
3378
  })
2935
3379
  });
2936
- const SERVER_VERSION = "0.2.0";
3380
+ const SERVER_VERSION = readOwnPackageVersion();
2937
3381
  const MAX_CLIENT_CONNECTIONS = 16;
2938
3382
  /**
2939
3383
  * Single-frame bound. Covers the protocol maximum (4 × 8 MB base64 images
@@ -2941,6 +3385,22 @@ const MAX_CLIENT_CONNECTIONS = 16;
2941
3385
  * pre-hello buffering far below ws's 100 MiB default.
2942
3386
  */
2943
3387
  const MAX_FRAME_BYTES = 67108864;
3388
+ /**
3389
+ * Resolve the host plugin's own version from the installed package.json.
3390
+ * Sourced at boot so the wire / UI always agrees with what npm published.
3391
+ * `createRequire(import.meta.url)` is the tsdown-bundled ESM equivalent of
3392
+ * CommonJS's `require`; the package.json sits next to lib/index.js after
3393
+ * the build, so `../package.json` resolves to the published manifest.
3394
+ */
3395
+ function readOwnPackageVersion() {
3396
+ try {
3397
+ const pkg = createRequire(import.meta.url)("../package.json");
3398
+ if (typeof pkg.version === "string" && pkg.version.length > 0) return pkg.version;
3399
+ } catch {}
3400
+ const envVersion = process.env.npm_package_version;
3401
+ if (typeof envVersion === "string" && envVersion.length > 0) return envVersion;
3402
+ return "0.0.0+unknown";
3403
+ }
2944
3404
  function rejectUpgrade(socket, status, reason) {
2945
3405
  const body = JSON.stringify({ error: reason });
2946
3406
  socket.end("HTTP/1.1 " + status + " Forbidden\r\nContent-Type: application/json\r\nContent-Length: " + Buffer.byteLength(body) + "\r\nConnection: close\r\n\r\n" + body);
@@ -3207,6 +3667,10 @@ function apply(ctx, options) {
3207
3667
  let enrollAttemptFor;
3208
3668
  let enrollLastAttemptAt = 0;
3209
3669
  const ensureRelayEnrolled = async (url) => {
3670
+ if (!/^https:\/\//i.test(url.trim())) {
3671
+ log("push relay enrollment refused: relayUrl must be an https URL");
3672
+ return;
3673
+ }
3210
3674
  if (enrollmentCell.token) return enrollmentCell.token;
3211
3675
  const fingerprint = url + ":" + String(enrollmentCell.enrollKey ?? "");
3212
3676
  if (fingerprint !== enrollAttemptFor) {
@@ -3239,7 +3703,14 @@ function apply(ctx, options) {
3239
3703
  }
3240
3704
  };
3241
3705
  let cachedSender;
3706
+ /**
3707
+ * Last failed APNs-sender build. The config fingerprint cannot see the
3708
+ * filesystem, so remembering a failure forever meant "copy the .p8 into
3709
+ * place later" never recovered without an edit or restart; throttle the
3710
+ * retry by time instead — same pattern as relay enrollment below.
3711
+ */
3242
3712
  let senderFailedFor;
3713
+ const SENDER_FAILURE_RETRY_MS = 6e4;
3243
3714
  /**
3244
3715
  * Lazily build the push sender for the current config. A broken config
3245
3716
  * (unreadable .p8) disables push for that fingerprint with exactly one log
@@ -3248,7 +3719,7 @@ function apply(ctx, options) {
3248
3719
  const senderFor = async (resolved) => {
3249
3720
  const fingerprint = JSON.stringify(resolved);
3250
3721
  if (cachedSender?.fingerprint === fingerprint) return cachedSender.send;
3251
- if (senderFailedFor === fingerprint) return void 0;
3722
+ if (senderFailedFor?.fingerprint === fingerprint && Date.now() - senderFailedFor.at < SENDER_FAILURE_RETRY_MS) return;
3252
3723
  if (cachedSender) {
3253
3724
  await cachedSender.dispose?.().catch(() => {});
3254
3725
  cachedSender = void 0;
@@ -3269,7 +3740,10 @@ function apply(ctx, options) {
3269
3740
  try {
3270
3741
  await readFile(expandHome(resolved.keyPath), "utf8");
3271
3742
  } catch (error) {
3272
- senderFailedFor = fingerprint;
3743
+ senderFailedFor = {
3744
+ fingerprint,
3745
+ at: Date.now()
3746
+ };
3273
3747
  log("apns push unavailable (key unreadable at " + resolved.keyPath + "): " + String(error));
3274
3748
  return;
3275
3749
  }
@@ -3373,6 +3847,12 @@ function apply(ctx, options) {
3373
3847
  phase: currentConfig().remote?.enabled === true ? "stopped" : "disabled",
3374
3848
  updatedAt: Date.now()
3375
3849
  };
3850
+ const updateChecker = new UpdateChecker({
3851
+ log,
3852
+ currentVersion: SERVER_VERSION
3853
+ });
3854
+ updateChecker.scheduleInitial();
3855
+ const updateInfo = () => updateChecker.get();
3376
3856
  applyReportRemote(ctx, async () => {
3377
3857
  let tokenReady = false;
3378
3858
  let devices = [];
@@ -3391,9 +3871,13 @@ function apply(ctx, options) {
3391
3871
  } } : {}
3392
3872
  }));
3393
3873
  } catch {}
3874
+ const update = updateInfo();
3394
3875
  return {
3395
3876
  protocolVersion: 1,
3396
3877
  serverVersion: SERVER_VERSION,
3878
+ pluginVersion: update.currentVersion,
3879
+ ...update.available ? { updateAvailable: true } : {},
3880
+ ...update.releaseUrl !== null ? { releaseUrl: update.releaseUrl } : {},
3397
3881
  enabled: currentConfig().enabled === true,
3398
3882
  tokenPath: expandHome(currentConfig().authTokenPath ?? join(bridgeDataDir(), "auth-token")),
3399
3883
  tokenReady,
@@ -3422,6 +3906,16 @@ function apply(ctx, options) {
3422
3906
  }]
3423
3907
  };
3424
3908
  const url = (push.relayUrl ?? "").trim() || DEFAULT_RELAY_URL;
3909
+ if (!/^https:\/\//i.test(url)) return {
3910
+ url,
3911
+ overall: "failed",
3912
+ tokenIssued: false,
3913
+ steps: [{
3914
+ id: "health",
3915
+ ok: false,
3916
+ message: "relayUrl 必须是 https 地址:注册请求携带共享密钥,明文 HTTP 会把它暴露给链路上的任何节点"
3917
+ }]
3918
+ };
3425
3919
  if (!enrollmentCell.clientId && enrollmentCell.enrollKey) {
3426
3920
  enrollmentCell.clientId = "u_" + randomBytes(16).toString("base64url");
3427
3921
  persistEnrollment();