@hermespilot/link 0.2.7 → 0.2.9

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.
@@ -3724,7 +3724,7 @@ import os2 from "os";
3724
3724
  import path5 from "path";
3725
3725
 
3726
3726
  // src/constants.ts
3727
- var LINK_VERSION = "0.2.7";
3727
+ var LINK_VERSION = "0.2.9";
3728
3728
  var LINK_COMMAND = "hermeslink";
3729
3729
  var LINK_DEFAULT_PORT = 52379;
3730
3730
  var LINK_RUNTIME_DIR_NAME = ".hermeslink";
@@ -6541,6 +6541,7 @@ function readAttachmentWaveform(attachment) {
6541
6541
  // src/config/config.ts
6542
6542
  var defaultLinkConfig = {
6543
6543
  port: LINK_DEFAULT_PORT,
6544
+ lanHost: null,
6544
6545
  serverBaseUrl: "https://hermes-server.clawpilot.me",
6545
6546
  relayBaseUrl: "https://hermes-relay.clawpilot.me",
6546
6547
  appConnectTokenIssuer: "https://hermes-server.clawpilot.me",
@@ -6550,18 +6551,51 @@ var defaultLinkConfig = {
6550
6551
  async function loadConfig(paths = resolveRuntimePaths()) {
6551
6552
  const existing = await readJsonFile(paths.configFile);
6552
6553
  const language = normalizeConfiguredLanguage(existing?.language);
6554
+ const lanHost = normalizeLanHost(existing?.lanHost);
6553
6555
  return {
6554
6556
  ...defaultLinkConfig,
6555
6557
  ...existing ?? {},
6556
- language
6558
+ language,
6559
+ lanHost
6557
6560
  };
6558
6561
  }
6562
+ async function saveConfig(patch, paths = resolveRuntimePaths()) {
6563
+ const current = await loadConfig(paths);
6564
+ const next = { ...current, ...patch };
6565
+ await writeJsonFile(paths.configFile, next);
6566
+ return next;
6567
+ }
6559
6568
  function normalizeConfiguredLanguage(language) {
6560
6569
  if (language === "zh-CN" || language === "en" || language === "auto") {
6561
6570
  return language;
6562
6571
  }
6563
6572
  return defaultLinkConfig.language;
6564
6573
  }
6574
+ function normalizeLanHost(value) {
6575
+ if (value === null || value === void 0) {
6576
+ return null;
6577
+ }
6578
+ if (typeof value !== "string") {
6579
+ return null;
6580
+ }
6581
+ const host = value.trim().replace(/^\[/u, "").replace(/\]$/u, "");
6582
+ if (!host) {
6583
+ return null;
6584
+ }
6585
+ if (!isUsableLanIpv4(host)) {
6586
+ return null;
6587
+ }
6588
+ return host;
6589
+ }
6590
+ function isUsableLanIpv4(value) {
6591
+ const parts = value.split(".").map((part) => Number.parseInt(part, 10));
6592
+ if (parts.length !== 4 || parts.some((part) => !Number.isInteger(part) || part < 0 || part > 255)) {
6593
+ return false;
6594
+ }
6595
+ const [first, second, , fourth] = parts;
6596
+ const privateRange = first === 10 || first === 172 && second >= 16 && second <= 31 || first === 192 && second === 168;
6597
+ return privateRange && fourth !== 0 && fourth !== 255;
6598
+ }
6565
6599
 
6566
6600
  // src/hermes/session-title.ts
6567
6601
  import { stat as stat6 } from "fs/promises";
@@ -11709,7 +11743,10 @@ async function saveAssignedLinkId(linkId, paths = resolveRuntimePaths()) {
11709
11743
  return next;
11710
11744
  }
11711
11745
  function signRelayNonce(identity, nonce) {
11712
- const signature = sign(null, Buffer.from(nonce, "utf8"), identity.private_key_pem);
11746
+ return signIdentityPayload(identity, nonce);
11747
+ }
11748
+ function signIdentityPayload(identity, payload) {
11749
+ const signature = sign(null, Buffer.from(payload, "utf8"), identity.private_key_pem);
11713
11750
  return signature.toString("base64url");
11714
11751
  }
11715
11752
  function getIdentityStatus(identity) {
@@ -12447,6 +12484,8 @@ function registerConversationRoutes(router, options) {
12447
12484
  });
12448
12485
  router.get("/api/v1/conversations/events", async (ctx) => {
12449
12486
  await authenticateRequest(ctx, paths);
12487
+ const mode = readQueryString(ctx.query.mode);
12488
+ const notificationOnly = mode === "notifications";
12450
12489
  ctx.respond = false;
12451
12490
  const response = ctx.res;
12452
12491
  response.statusCode = 200;
@@ -12454,6 +12493,9 @@ function registerConversationRoutes(router, options) {
12454
12493
  response.setHeader("cache-control", "no-store");
12455
12494
  response.setHeader("connection", "keep-alive");
12456
12495
  const unsubscribe = conversations.subscribeAll((event) => {
12496
+ if (notificationOnly && !isConversationNotificationEvent(event)) {
12497
+ return;
12498
+ }
12457
12499
  writeSseEvent(response, event);
12458
12500
  });
12459
12501
  const cleanup = () => {
@@ -12691,6 +12733,16 @@ function registerConversationRoutes(router, options) {
12691
12733
  }
12692
12734
  );
12693
12735
  }
12736
+ function isConversationNotificationEvent(event) {
12737
+ const type = event.type.toLowerCase();
12738
+ return type === "conversation.created" || type === "conversation.updated" || type === "conversation.deleted" || type === "message.created" || type === "message.completed" || type === "message.failed" || type === "run.completed" || type === "run.failed" || type === "run.cancelled" || type === "run.canceled" || type === "approval.requested" || readPayloadBool(event.payload, "requires_action") || readPayloadBool(event.payload, "requires_user_action") || readPayloadBool(event.payload, "requires_approval");
12739
+ }
12740
+ function readPayloadBool(payload, key) {
12741
+ if (!payload || typeof payload !== "object") {
12742
+ return false;
12743
+ }
12744
+ return payload[key] === true;
12745
+ }
12694
12746
 
12695
12747
  // src/http/middleware/error-handler.ts
12696
12748
  function createHttpErrorMiddleware(logger) {
@@ -17573,218 +17625,807 @@ async function handleFrame(socket, raw, localPort, abortControllers) {
17573
17625
  }
17574
17626
  }
17575
17627
 
17576
- // src/daemon/scheduler.ts
17577
- function startCronDeliveryScheduler(options) {
17578
- let running = false;
17579
- const syncCronDeliveries = async () => {
17580
- if (running) {
17581
- return;
17582
- }
17583
- running = true;
17584
- try {
17585
- await syncHermesLinkCronDeliveries(
17586
- options.paths,
17587
- options.conversations,
17588
- options.logger
17589
- );
17590
- } catch (error) {
17591
- void options.logger.warn("cron_link_delivery_sync_failed", {
17592
- error: error instanceof Error ? error.message : String(error)
17593
- });
17594
- } finally {
17595
- running = false;
17596
- }
17597
- };
17598
- const timer = setInterval(() => {
17599
- void syncCronDeliveries();
17600
- }, options.intervalMs ?? 3e4);
17601
- timer.unref?.();
17628
+ // src/runtime/system-info.ts
17629
+ import { execFileSync } from "child_process";
17630
+ import { readFileSync } from "fs";
17631
+ import os5 from "os";
17632
+ function readLinkSystemInfo() {
17633
+ const platform = process.platform;
17634
+ const hostname = readHostname(platform);
17635
+ const osLabel = readOsLabel(platform);
17636
+ const defaultDisplayName = buildDefaultDisplayName({ hostname, osLabel, platform });
17602
17637
  return {
17603
- close() {
17604
- clearInterval(timer);
17605
- }
17638
+ platform,
17639
+ hostname,
17640
+ osLabel,
17641
+ defaultDisplayName
17606
17642
  };
17607
17643
  }
17608
-
17609
- // src/daemon/service.ts
17610
- async function startLinkService(options = {}) {
17611
- const paths = options.paths ?? resolveRuntimePaths();
17612
- const logger = createFileLogger({ paths });
17613
- const [identity, config] = await Promise.all([loadIdentity(paths), loadConfig(paths)]);
17614
- await logger.info("service_starting", {
17615
- port: config.port,
17616
- mode: identity?.link_id ? "paired" : "local-only"
17617
- });
17618
- const migration = await migrateLinkDatabase(paths);
17619
- if (migration.appliedVersions.length > 0) {
17620
- await logger.info("database_migrated", {
17621
- database_file: migration.databaseFile,
17622
- applied_versions: migration.appliedVersions,
17623
- current_version: migration.currentVersion
17624
- });
17644
+ function buildDefaultDisplayName(input) {
17645
+ const hostname = normalizeText(input.hostname);
17646
+ const osLabel = normalizeText(input.osLabel);
17647
+ if (hostname && osLabel && hostname.toLowerCase() !== osLabel.toLowerCase()) {
17648
+ return truncateText(`${hostname} - ${osLabel}`, 128);
17625
17649
  }
17626
- const conversations = new ConversationService(paths, logger);
17627
- await conversations.rebuildStatisticsIndex();
17628
- const app = await createApp({
17629
- paths,
17630
- logger,
17631
- conversations,
17632
- onPairingClaimed: options.onPairingClaimed
17633
- });
17634
- const server = createServer(app.callback());
17635
- try {
17636
- await listenServer(server, config.port);
17637
- } catch (error) {
17638
- await logger.error("service_start_failed", {
17639
- port: config.port,
17640
- error: error instanceof Error ? error.message : String(error)
17641
- });
17642
- await logger.flush();
17643
- throw error;
17650
+ return truncateText(hostname ?? osLabel ?? `Hermes Link ${input.platform}`, 128);
17651
+ }
17652
+ function parseLinuxOsRelease(content) {
17653
+ const values = /* @__PURE__ */ new Map();
17654
+ for (const line of content.split(/\r?\n/u)) {
17655
+ const match = /^([A-Z0-9_]+)=(.*)$/u.exec(line.trim());
17656
+ if (!match) {
17657
+ continue;
17658
+ }
17659
+ values.set(match[1], unquoteOsReleaseValue(match[2]));
17644
17660
  }
17645
- server.on("error", (error) => {
17646
- void logger.error("service_error", { error: error.message });
17647
- });
17648
- void logger.info("service_started", {
17649
- port: config.port,
17650
- link_id: identity?.link_id ?? null
17651
- });
17652
- const scheduler = startCronDeliveryScheduler({
17653
- paths,
17654
- conversations,
17655
- logger
17656
- });
17657
- let relay = null;
17658
- if (identity?.link_id) {
17659
- relay = connectRelayControl({
17660
- relayBaseUrl: config.relayBaseUrl,
17661
- linkId: identity.link_id,
17662
- localPort: config.port,
17663
- maxReconnectAttempts: options.relayMaxReconnectAttempts ?? 5,
17664
- backoffBaseMs: 1e3,
17665
- backoffMaxMs: 3e4,
17666
- onStatus: (status) => {
17667
- void logger.info("relay_status", status);
17668
- }
17669
- });
17670
- } else {
17671
- void logger.info("relay_skipped", { reason: "link_not_paired" });
17661
+ return normalizeText(values.get("PRETTY_NAME")) ?? buildLinuxName(values);
17662
+ }
17663
+ function readHostname(platform) {
17664
+ if (platform === "darwin") {
17665
+ const computerName = readCommandOutput("scutil", ["--get", "ComputerName"]);
17666
+ if (computerName) {
17667
+ return computerName;
17668
+ }
17672
17669
  }
17673
- if (options.writePidFile) {
17674
- await writePidFile(paths);
17670
+ return normalizeText(os5.hostname());
17671
+ }
17672
+ function readOsLabel(platform) {
17673
+ if (platform === "darwin") {
17674
+ const version = readCommandOutput("sw_vers", ["-productVersion"]);
17675
+ return version ? `macOS ${version}` : "macOS";
17675
17676
  }
17676
- return {
17677
- async close() {
17678
- scheduler.close();
17679
- relay?.close();
17680
- await closeServer(server);
17681
- await logger.info("service_stopped");
17682
- await logger.flush();
17683
- if (options.writePidFile) {
17684
- await rm9(pidFilePath(paths), { force: true }).catch(() => void 0);
17685
- }
17677
+ if (platform === "linux") {
17678
+ return readLinuxOsRelease() ?? `Linux ${os5.release()}`;
17679
+ }
17680
+ if (platform === "win32") {
17681
+ return `Windows ${os5.release()}`;
17682
+ }
17683
+ return `${os5.type()} ${os5.release()}`.trim();
17684
+ }
17685
+ function readLinuxOsRelease() {
17686
+ for (const file of ["/etc/os-release", "/usr/lib/os-release"]) {
17687
+ try {
17688
+ return parseLinuxOsRelease(readFileSync(file, "utf8"));
17689
+ } catch {
17686
17690
  }
17687
- };
17691
+ }
17692
+ return null;
17688
17693
  }
17689
- function pidFilePath(paths = resolveRuntimePaths()) {
17690
- return `${paths.runDir}/hermeslink.pid`;
17694
+ function readCommandOutput(command, args) {
17695
+ try {
17696
+ const output = execFileSync(command, args, {
17697
+ encoding: "utf8",
17698
+ stdio: ["ignore", "pipe", "ignore"],
17699
+ timeout: 1e3
17700
+ });
17701
+ return normalizeText(output);
17702
+ } catch {
17703
+ return null;
17704
+ }
17691
17705
  }
17692
- async function writePidFile(paths) {
17693
- await mkdir15(paths.runDir, { recursive: true, mode: 448 });
17694
- await writeFile7(pidFilePath(paths), `${process.pid}
17695
- `, { mode: 384 });
17706
+ function buildLinuxName(values) {
17707
+ const name = normalizeText(values.get("NAME"));
17708
+ const version = normalizeText(values.get("VERSION_ID")) ?? normalizeText(values.get("VERSION"));
17709
+ if (name && version) {
17710
+ return `${name} ${version}`;
17711
+ }
17712
+ return name ?? version;
17696
17713
  }
17697
- async function closeServer(server) {
17698
- await new Promise((resolve, reject) => {
17699
- let settled = false;
17700
- let forceCloseTimer;
17701
- let timeoutTimer;
17702
- const settle = (error) => {
17703
- if (settled) {
17704
- return;
17705
- }
17706
- settled = true;
17707
- clearTimeout(forceCloseTimer);
17708
- clearTimeout(timeoutTimer);
17709
- if (error) {
17710
- reject(error);
17711
- return;
17712
- }
17713
- resolve();
17714
- };
17715
- forceCloseTimer = setTimeout(() => {
17716
- server.closeIdleConnections?.();
17717
- server.closeAllConnections?.();
17718
- }, 250);
17719
- timeoutTimer = setTimeout(() => {
17720
- server.closeAllConnections?.();
17721
- settle();
17722
- }, 5e3);
17723
- server.close((error) => {
17724
- if (error) {
17725
- settle(error);
17726
- return;
17727
- }
17728
- settle();
17729
- });
17730
- server.closeIdleConnections?.();
17731
- });
17714
+ function unquoteOsReleaseValue(value) {
17715
+ const trimmed = value.trim();
17716
+ if (trimmed.length >= 2 && (trimmed.startsWith('"') && trimmed.endsWith('"') || trimmed.startsWith("'") && trimmed.endsWith("'"))) {
17717
+ return trimmed.slice(1, -1).replace(/\\(["'`$\\])/gu, "$1");
17718
+ }
17719
+ return trimmed;
17732
17720
  }
17733
- async function listenServer(server, port) {
17734
- await new Promise((resolve, reject) => {
17735
- const cleanup = () => {
17736
- server.off("error", onError);
17737
- server.off("listening", onListening);
17738
- };
17739
- const onError = (error) => {
17740
- cleanup();
17741
- reject(error);
17721
+ function normalizeText(value) {
17722
+ const normalized = value?.replace(/\s+/gu, " ").trim();
17723
+ return normalized ? normalized : null;
17724
+ }
17725
+ function truncateText(value, maxLength) {
17726
+ return value.length > maxLength ? value.slice(0, maxLength).trimEnd() : value;
17727
+ }
17728
+
17729
+ // src/topology/network.ts
17730
+ import os7 from "os";
17731
+
17732
+ // src/topology/environment.ts
17733
+ import { existsSync, readFileSync as readFileSync2 } from "fs";
17734
+ import os6 from "os";
17735
+ function detectRuntimeEnvironment(env = process.env) {
17736
+ if (isWsl(env)) {
17737
+ return {
17738
+ kind: "wsl",
17739
+ lanAutoDiscoveryUsable: false,
17740
+ warning: "Detected WSL. The LAN IP found inside WSL is usually a private VM address and is not reachable from your phone. Use Relay or set `hermeslink config set lan-host <Windows LAN IP>`."
17742
17741
  };
17743
- const onListening = () => {
17744
- cleanup();
17745
- resolve();
17742
+ }
17743
+ if (isContainer(env)) {
17744
+ return {
17745
+ kind: "container",
17746
+ lanAutoDiscoveryUsable: false,
17747
+ warning: "Detected a container environment. Container LAN IPs are usually not reachable from your phone. Use Relay or set `hermeslink config set lan-host <host LAN IP>`."
17746
17748
  };
17747
- server.once("error", onError);
17748
- server.once("listening", onListening);
17749
- server.listen(port);
17750
- });
17749
+ }
17750
+ return {
17751
+ kind: "native",
17752
+ lanAutoDiscoveryUsable: true,
17753
+ warning: null
17754
+ };
17751
17755
  }
17752
-
17753
- // src/daemon/process.ts
17754
- async function startDaemonProcess(paths = resolveRuntimePaths()) {
17755
- const config = await loadConfig(paths);
17756
- let status = await getDaemonStatus(paths);
17757
- if (status.running) {
17758
- const probe = await probeLocalLinkService({ port: config.port, timeoutMs: 500 });
17759
- if (probe.reachable) {
17760
- return status;
17761
- }
17762
- await stopDaemonProcess(paths);
17763
- status = await getDaemonStatus(paths);
17764
- if (status.running) {
17765
- return status;
17766
- }
17756
+ function isWsl(env) {
17757
+ if (process.platform !== "linux") {
17758
+ return false;
17767
17759
  }
17768
- await mkdir16(paths.logsDir, { recursive: true, mode: 448 });
17769
- await mkdir16(paths.runDir, { recursive: true, mode: 448 });
17770
- const scriptPath = currentCliScriptPath();
17771
- const child = spawn4(process.execPath, [scriptPath, "daemon-supervisor"], {
17772
- detached: true,
17773
- stdio: "ignore",
17774
- env: process.env
17775
- });
17776
- child.unref();
17777
- for (let index = 0; index < 12; index += 1) {
17778
- await wait(250);
17779
- const next = await getDaemonStatus(paths);
17780
- if (next.running && (await probeLocalLinkService({ port: config.port, timeoutMs: 500 })).reachable) {
17781
- return next;
17782
- }
17760
+ if (env.WSL_DISTRO_NAME || env.WSL_INTEROP) {
17761
+ return true;
17783
17762
  }
17784
- return await getDaemonStatus(paths);
17763
+ const release = os6.release().toLowerCase();
17764
+ return release.includes("microsoft") || release.includes("wsl");
17785
17765
  }
17786
- async function runDaemonSupervisor(paths = resolveRuntimePaths()) {
17787
- await mkdir16(paths.logsDir, { recursive: true, mode: 448 });
17766
+ function isContainer(env) {
17767
+ if (env.container || env.CONTAINER || env.KUBERNETES_SERVICE_HOST) {
17768
+ return true;
17769
+ }
17770
+ if (existsSync("/.dockerenv")) {
17771
+ return true;
17772
+ }
17773
+ try {
17774
+ const cgroup = readFileSync2("/proc/1/cgroup", "utf8").toLowerCase();
17775
+ return /docker|containerd|kubepods|libpod|podman/u.test(cgroup);
17776
+ } catch {
17777
+ return false;
17778
+ }
17779
+ }
17780
+
17781
+ // src/topology/network.ts
17782
+ var VIRTUAL_INTERFACE_NAME_PATTERN = /(docker|veth|vmnet|vmenet|vbox|virtualbox|vmware|tailscale|zerotier|wireguard|utun|virbr|hyper-v|vethernet|loopback|\blo\b|^lo\d*$|^br-|^bridge\d+$|^zt|^tun|^tap|awdl|llw|anpi|gif|stf|ipsec|ppp)/iu;
17783
+ var MAX_LAN_IPS = 4;
17784
+ var MAX_PUBLIC_IPV4S = 2;
17785
+ var MAX_PUBLIC_IPV6S = 2;
17786
+ async function discoverRouteCandidates(options) {
17787
+ const environment = detectRuntimeEnvironment();
17788
+ const configuredLanHost = normalizeLanHost(options.configuredLanHost);
17789
+ const lanIps = configuredLanHost ? [configuredLanHost] : environment.lanAutoDiscoveryUsable ? discoverLanIps() : [];
17790
+ const publicIps = options.relayBootstrapToken ? await observePublicRoute(options).catch(() => ({ publicIpv4s: [], publicIpv6s: [] })) : { publicIpv4s: [], publicIpv6s: [] };
17791
+ const publicIpv4s = unique(publicIps.publicIpv4s.filter(isUsablePublicIpv4)).slice(0, MAX_PUBLIC_IPV4S);
17792
+ const publicIpv6s = unique(publicIps.publicIpv6s.filter(isUsablePublicIpv6)).slice(0, MAX_PUBLIC_IPV6S);
17793
+ const preferredUrls = [
17794
+ ...lanIps.map((ip) => buildDirectUrl(ip, options.port)),
17795
+ ...publicIpv4s.map((ip) => buildDirectUrl(ip, options.port)),
17796
+ ...publicIpv6s.map((ip) => buildDirectUrl(ip, options.port)),
17797
+ `${options.relayBaseUrl.replace(/\/+$/u, "")}/api/v1/relay/links/${options.linkId}`
17798
+ ];
17799
+ return {
17800
+ lanIps,
17801
+ publicIpv4s,
17802
+ publicIpv6s,
17803
+ preferredUrls,
17804
+ environment
17805
+ };
17806
+ }
17807
+ function discoverLanIps() {
17808
+ return discoverLanIpsFromInterfaces(os7.networkInterfaces());
17809
+ }
17810
+ function discoverLanIpsFromInterfaces(interfaces) {
17811
+ const result = /* @__PURE__ */ new Set();
17812
+ const candidates = [];
17813
+ for (const [name, items] of Object.entries(interfaces)) {
17814
+ if (shouldIgnoreInterface(name)) {
17815
+ continue;
17816
+ }
17817
+ for (const item of items ?? []) {
17818
+ if (!item.internal && item.address && item.family === "IPv4" && isUsableLanIpv42(item.address, item.netmask)) {
17819
+ candidates.push({ name, address: item.address });
17820
+ }
17821
+ }
17822
+ }
17823
+ for (const candidate of candidates.sort(compareLanCandidate)) {
17824
+ result.add(candidate.address);
17825
+ }
17826
+ return [...result].slice(0, MAX_LAN_IPS);
17827
+ }
17828
+ async function observePublicRoute(options) {
17829
+ const fetcher = options.fetchImpl ?? fetch;
17830
+ const response = await fetcher(`${options.relayBaseUrl.replace(/\/+$/u, "")}/api/v1/relay/public-route/observe`, {
17831
+ method: "POST",
17832
+ headers: {
17833
+ authorization: `Bearer ${options.relayBootstrapToken}`,
17834
+ "content-type": "application/json"
17835
+ },
17836
+ body: JSON.stringify({
17837
+ install_id: options.installId,
17838
+ link_id: options.linkId,
17839
+ public_key_pem: options.publicKeyPem
17840
+ })
17841
+ });
17842
+ const payload = await response.json().catch(() => null);
17843
+ const record = typeof payload?.record === "object" && payload.record !== null ? payload.record : null;
17844
+ const observed = typeof payload?.observed === "object" && payload.observed !== null ? payload.observed : null;
17845
+ const values = [
17846
+ readIpRecord(record?.ipv4),
17847
+ readIpRecord(record?.ipv6),
17848
+ typeof observed?.ip === "string" ? observed.ip : null
17849
+ ].filter((value) => Boolean(value));
17850
+ return {
17851
+ publicIpv4s: unique(values.filter(isUsablePublicIpv4)),
17852
+ publicIpv6s: unique(values.filter(isUsablePublicIpv6))
17853
+ };
17854
+ }
17855
+ function readIpRecord(value) {
17856
+ if (typeof value !== "object" || value === null) {
17857
+ return null;
17858
+ }
17859
+ const ip = value.ip;
17860
+ return typeof ip === "string" && ip.trim() ? ip.trim() : null;
17861
+ }
17862
+ function buildDirectUrl(ip, port) {
17863
+ return `http://${ip.includes(":") ? `[${ip}]` : ip}:${port}`;
17864
+ }
17865
+ function shouldIgnoreInterface(name) {
17866
+ return !name.trim() || VIRTUAL_INTERFACE_NAME_PATTERN.test(name);
17867
+ }
17868
+ function compareLanCandidate(left, right) {
17869
+ const priority = interfacePriority(left.name) - interfacePriority(right.name);
17870
+ return priority || left.name.localeCompare(right.name) || left.address.localeCompare(right.address);
17871
+ }
17872
+ function interfacePriority(name) {
17873
+ if (/^(en|eth|wlan|wi-fi|wifi)/iu.test(name)) {
17874
+ return 0;
17875
+ }
17876
+ return 1;
17877
+ }
17878
+ function isUsableLanIpv42(address, netmask) {
17879
+ return isPrivateIpv4(address) && !isNetworkOrBroadcastIpv4Address(address, netmask);
17880
+ }
17881
+ function isUsablePublicIpv4(address) {
17882
+ return isValidIpv4(address) && !isSpecialIpv4(address);
17883
+ }
17884
+ function isUsablePublicIpv6(address) {
17885
+ const normalized = address.toLowerCase();
17886
+ return normalized.includes(":") && !normalized.startsWith("fe80:") && !normalized.startsWith("fc") && !normalized.startsWith("fd") && !normalized.startsWith("ff") && normalized !== "::" && normalized !== "::1";
17887
+ }
17888
+ function isPrivateIpv4(address) {
17889
+ const parts = parseIpv4Segments(address);
17890
+ if (!parts) {
17891
+ return false;
17892
+ }
17893
+ const [first, second] = parts;
17894
+ return first === 10 || first === 172 && second >= 16 && second <= 31 || first === 192 && second === 168;
17895
+ }
17896
+ function isSpecialIpv4(address) {
17897
+ const parts = parseIpv4Segments(address);
17898
+ if (!parts) {
17899
+ return true;
17900
+ }
17901
+ const [first, second, third, fourth] = parts;
17902
+ return first === 0 || first === 10 || first === 127 || first >= 224 || first === 100 && second >= 64 && second <= 127 || first === 169 && second === 254 || first === 172 && second >= 16 && second <= 31 || first === 192 && second === 168 || first === 192 && second === 0 && third === 2 || first === 198 && (second === 18 || second === 19) || first === 198 && second === 51 && third === 100 || first === 203 && second === 0 && third === 113 || first === 255 && second === 255 && third === 255 && fourth === 255;
17903
+ }
17904
+ function isNetworkOrBroadcastIpv4Address(address, netmask) {
17905
+ const addressParts = parseIpv4Segments(address);
17906
+ const netmaskParts = netmask ? parseIpv4Segments(netmask) : null;
17907
+ if (!addressParts) {
17908
+ return true;
17909
+ }
17910
+ if (!netmaskParts) {
17911
+ const last = addressParts[3];
17912
+ return last === 0 || last === 255;
17913
+ }
17914
+ const addressInt = ipv4SegmentsToInt(addressParts);
17915
+ const netmaskInt = ipv4SegmentsToInt(netmaskParts);
17916
+ const hostMask = ~netmaskInt >>> 0;
17917
+ if (hostMask === 0) {
17918
+ return false;
17919
+ }
17920
+ const networkInt = addressInt & netmaskInt;
17921
+ const broadcastInt = (networkInt | hostMask) >>> 0;
17922
+ return addressInt === networkInt || addressInt === broadcastInt;
17923
+ }
17924
+ function isValidIpv4(address) {
17925
+ return Boolean(parseIpv4Segments(address));
17926
+ }
17927
+ function parseIpv4Segments(address) {
17928
+ if (!/^\d{1,3}(?:\.\d{1,3}){3}$/u.test(address)) {
17929
+ return null;
17930
+ }
17931
+ const parts = address.split(".").map((part) => Number.parseInt(part, 10));
17932
+ if (parts.length !== 4 || parts.some((part) => !Number.isInteger(part) || part < 0 || part > 255)) {
17933
+ return null;
17934
+ }
17935
+ return parts;
17936
+ }
17937
+ function ipv4SegmentsToInt(parts) {
17938
+ return (parts[0] << 24 >>> 0 | parts[1] << 16 | parts[2] << 8 | parts[3]) >>> 0;
17939
+ }
17940
+ function unique(values) {
17941
+ return [...new Set(values)];
17942
+ }
17943
+
17944
+ // src/link/network-report-state.ts
17945
+ var DEFAULT_AUTO_DAILY_LIMIT = 20;
17946
+ async function markNetworkStatusReported(paths, lanIps, reportedAt = /* @__PURE__ */ new Date()) {
17947
+ await updateNetworkReportState(paths, (current) => ({
17948
+ ...current,
17949
+ lastReportedLanIps: normalizeLanIps(lanIps),
17950
+ lastReportedAt: reportedAt.toISOString(),
17951
+ lastAutoAttempt: current.lastAutoAttempt ? { ...current.lastAutoAttempt, success: true } : null
17952
+ }));
17953
+ }
17954
+ async function reserveAutomaticNetworkReport(paths, lanIps, options = {}) {
17955
+ const snapshot = normalizeLanIps(lanIps);
17956
+ const now = options.now ?? /* @__PURE__ */ new Date();
17957
+ const dailyLimit = Math.max(0, Math.floor(options.dailyLimit ?? DEFAULT_AUTO_DAILY_LIMIT));
17958
+ let reservation = { allowed: false, reason: "unchanged" };
17959
+ await updateNetworkReportState(paths, (current) => {
17960
+ if (sameLanIps(current.lastReportedLanIps, snapshot)) {
17961
+ reservation = { allowed: false, reason: "unchanged" };
17962
+ return current;
17963
+ }
17964
+ if (current.lastAutoAttempt && !current.lastAutoAttempt.success && sameLanIps(current.lastAutoAttempt.lanIps, snapshot)) {
17965
+ reservation = { allowed: false, reason: "failed_snapshot_not_retried" };
17966
+ return current;
17967
+ }
17968
+ const quotaDay = formatUtcDay(now);
17969
+ const reportsToday = current.autoQuotaDay === quotaDay ? current.autoReportsToday : 0;
17970
+ if (reportsToday >= dailyLimit) {
17971
+ reservation = { allowed: false, reason: "daily_limit_reached" };
17972
+ return current;
17973
+ }
17974
+ reservation = { allowed: true };
17975
+ return {
17976
+ ...current,
17977
+ autoQuotaDay: quotaDay,
17978
+ autoReportsToday: reportsToday + 1,
17979
+ lastAutoAttempt: {
17980
+ lanIps: snapshot,
17981
+ attemptedAt: now.toISOString(),
17982
+ success: false
17983
+ }
17984
+ };
17985
+ });
17986
+ return reservation;
17987
+ }
17988
+ async function updateNetworkReportState(paths, update) {
17989
+ const state = await readLinkState(paths);
17990
+ const next = {
17991
+ ...state,
17992
+ networkReport: update(normalizeNetworkReportState(state.networkReport))
17993
+ };
17994
+ await writeJsonFile(paths.stateFile, next);
17995
+ }
17996
+ async function readLinkState(paths) {
17997
+ const state = await readJsonFile(paths.stateFile);
17998
+ return state && typeof state === "object" ? state : {};
17999
+ }
18000
+ function normalizeNetworkReportState(value) {
18001
+ const record = value && typeof value === "object" ? value : {};
18002
+ return {
18003
+ lastReportedLanIps: normalizeLanIps(record.lastReportedLanIps),
18004
+ lastReportedAt: typeof record.lastReportedAt === "string" ? record.lastReportedAt : null,
18005
+ autoQuotaDay: typeof record.autoQuotaDay === "string" ? record.autoQuotaDay : null,
18006
+ autoReportsToday: Number.isFinite(record.autoReportsToday) ? Math.max(0, Math.floor(record.autoReportsToday ?? 0)) : 0,
18007
+ lastAutoAttempt: normalizeAttempt(record.lastAutoAttempt)
18008
+ };
18009
+ }
18010
+ function normalizeAttempt(value) {
18011
+ if (!value || typeof value !== "object") {
18012
+ return null;
18013
+ }
18014
+ const record = value;
18015
+ if (typeof record.attemptedAt !== "string") {
18016
+ return null;
18017
+ }
18018
+ return {
18019
+ lanIps: normalizeLanIps(record.lanIps),
18020
+ attemptedAt: record.attemptedAt,
18021
+ success: record.success === true
18022
+ };
18023
+ }
18024
+ function normalizeLanIps(value) {
18025
+ if (!Array.isArray(value)) {
18026
+ return [];
18027
+ }
18028
+ return [
18029
+ ...new Set(
18030
+ value.filter((item) => typeof item === "string" && item.trim().length > 0).map((item) => item.trim())
18031
+ )
18032
+ ];
18033
+ }
18034
+ function sameLanIps(left, right) {
18035
+ if (left.length !== right.length) {
18036
+ return false;
18037
+ }
18038
+ return left.every((value, index) => value === right[index]);
18039
+ }
18040
+ function formatUtcDay(date) {
18041
+ return date.toISOString().slice(0, 10);
18042
+ }
18043
+
18044
+ // src/link/server-report.ts
18045
+ async function reportLinkStatusToServer(options = {}) {
18046
+ const paths = options.paths ?? resolveRuntimePaths();
18047
+ const [identity, config] = await Promise.all([loadIdentity(paths), loadConfig(paths)]);
18048
+ if (!identity?.link_id) {
18049
+ return null;
18050
+ }
18051
+ const routes = await discoverRouteCandidates({
18052
+ port: config.port,
18053
+ relayBaseUrl: config.relayBaseUrl,
18054
+ linkId: identity.link_id,
18055
+ installId: identity.install_id,
18056
+ publicKeyPem: identity.public_key_pem,
18057
+ configuredLanHost: config.lanHost,
18058
+ fetchImpl: options.fetchImpl
18059
+ });
18060
+ const systemInfo = readLinkSystemInfo();
18061
+ const payload = {
18062
+ type: "hermes_link_status_report",
18063
+ link_id: identity.link_id,
18064
+ install_id: identity.install_id,
18065
+ link_version: LINK_VERSION,
18066
+ display_name: systemInfo.defaultDisplayName,
18067
+ platform: systemInfo.platform,
18068
+ hostname: systemInfo.hostname ?? void 0,
18069
+ lan_ips: routes.lanIps,
18070
+ public_ipv4s: routes.publicIpv4s,
18071
+ public_ipv6s: routes.publicIpv6s,
18072
+ reported_at: (/* @__PURE__ */ new Date()).toISOString()
18073
+ };
18074
+ const signature = signIdentityPayload(identity, canonicalJson(payload));
18075
+ const fetcher = options.fetchImpl ?? fetch;
18076
+ const response = await fetcher(
18077
+ `${config.serverBaseUrl.replace(/\/+$/u, "")}/api/v1/links/${encodeURIComponent(identity.link_id)}/report`,
18078
+ {
18079
+ method: "POST",
18080
+ headers: {
18081
+ accept: "application/json",
18082
+ "content-type": "application/json"
18083
+ },
18084
+ body: JSON.stringify({
18085
+ ...payload,
18086
+ public_key_pem: identity.public_key_pem,
18087
+ signature
18088
+ })
18089
+ }
18090
+ );
18091
+ const body = await response.json().catch(() => null);
18092
+ if (!response.ok || !body) {
18093
+ const message = readErrorMessage3(body) ?? `HermesPilot Server request failed with HTTP ${response.status}`;
18094
+ throw new LinkHttpError(response.status, "server_request_failed", message);
18095
+ }
18096
+ await markNetworkStatusReported(paths, routes.lanIps);
18097
+ return body;
18098
+ }
18099
+ function canonicalJson(value) {
18100
+ return JSON.stringify(sortJsonValue(value));
18101
+ }
18102
+ function sortJsonValue(value) {
18103
+ if (Array.isArray(value)) {
18104
+ return value.map(sortJsonValue);
18105
+ }
18106
+ if (value && typeof value === "object") {
18107
+ const record = value;
18108
+ const sorted = {};
18109
+ for (const key of Object.keys(record).sort()) {
18110
+ sorted[key] = sortJsonValue(record[key]);
18111
+ }
18112
+ return sorted;
18113
+ }
18114
+ return value;
18115
+ }
18116
+ function readErrorMessage3(payload) {
18117
+ if (typeof payload !== "object" || payload === null) {
18118
+ return null;
18119
+ }
18120
+ const error = payload.error;
18121
+ if (typeof error !== "object" || error === null) {
18122
+ return null;
18123
+ }
18124
+ const message = error.message;
18125
+ return typeof message === "string" ? message : null;
18126
+ }
18127
+
18128
+ // src/daemon/lan-ip-monitor.ts
18129
+ var DEFAULT_INTERVAL_MS = 5 * 6e4;
18130
+ var DEFAULT_DAILY_REPORT_LIMIT = 20;
18131
+ function startLanIpMonitor(options) {
18132
+ let running = false;
18133
+ let closed = false;
18134
+ const check = async () => {
18135
+ if (running || closed) {
18136
+ return;
18137
+ }
18138
+ running = true;
18139
+ try {
18140
+ await checkLanIpChange(options);
18141
+ } catch (error) {
18142
+ void options.logger.warn("lan_ip_monitor_failed", {
18143
+ error: error instanceof Error ? error.message : String(error)
18144
+ });
18145
+ } finally {
18146
+ running = false;
18147
+ }
18148
+ };
18149
+ void check();
18150
+ const timer = setInterval(() => {
18151
+ void check();
18152
+ }, options.intervalMs ?? DEFAULT_INTERVAL_MS);
18153
+ timer.unref?.();
18154
+ return {
18155
+ close() {
18156
+ closed = true;
18157
+ clearInterval(timer);
18158
+ }
18159
+ };
18160
+ }
18161
+ async function checkLanIpChange(options) {
18162
+ const [identity, config] = await Promise.all([
18163
+ loadIdentity(options.paths),
18164
+ loadConfig(options.paths)
18165
+ ]);
18166
+ if (!identity?.link_id) {
18167
+ return;
18168
+ }
18169
+ const routes = await discoverRouteCandidates({
18170
+ port: config.port,
18171
+ relayBaseUrl: config.relayBaseUrl,
18172
+ linkId: identity.link_id,
18173
+ installId: identity.install_id,
18174
+ publicKeyPem: identity.public_key_pem,
18175
+ configuredLanHost: config.lanHost,
18176
+ fetchImpl: options.fetchImpl
18177
+ });
18178
+ const reservation = await reserveAutomaticNetworkReport(options.paths, routes.lanIps, {
18179
+ dailyLimit: options.dailyReportLimit ?? DEFAULT_DAILY_REPORT_LIMIT
18180
+ });
18181
+ if (!reservation.allowed) {
18182
+ const logFields = { lan_ips: routes.lanIps, reason: reservation.reason };
18183
+ if (reservation.reason === "daily_limit_reached") {
18184
+ void options.logger.warn("lan_ip_report_skipped", logFields);
18185
+ } else {
18186
+ void options.logger.debug("lan_ip_report_skipped", logFields);
18187
+ }
18188
+ return;
18189
+ }
18190
+ try {
18191
+ const result = await reportLinkStatusToServer({
18192
+ paths: options.paths,
18193
+ fetchImpl: options.fetchImpl
18194
+ });
18195
+ if (result) {
18196
+ void options.logger.info("lan_ip_change_reported", {
18197
+ link_id: result.linkId,
18198
+ lan_ips: routes.lanIps
18199
+ });
18200
+ }
18201
+ } catch (error) {
18202
+ void options.logger.warn("lan_ip_change_report_failed", {
18203
+ lan_ips: routes.lanIps,
18204
+ error: error instanceof Error ? error.message : String(error)
18205
+ });
18206
+ }
18207
+ }
18208
+
18209
+ // src/daemon/scheduler.ts
18210
+ function startCronDeliveryScheduler(options) {
18211
+ let running = false;
18212
+ const syncCronDeliveries = async () => {
18213
+ if (running) {
18214
+ return;
18215
+ }
18216
+ running = true;
18217
+ try {
18218
+ await syncHermesLinkCronDeliveries(
18219
+ options.paths,
18220
+ options.conversations,
18221
+ options.logger
18222
+ );
18223
+ } catch (error) {
18224
+ void options.logger.warn("cron_link_delivery_sync_failed", {
18225
+ error: error instanceof Error ? error.message : String(error)
18226
+ });
18227
+ } finally {
18228
+ running = false;
18229
+ }
18230
+ };
18231
+ const timer = setInterval(() => {
18232
+ void syncCronDeliveries();
18233
+ }, options.intervalMs ?? 3e4);
18234
+ timer.unref?.();
18235
+ return {
18236
+ close() {
18237
+ clearInterval(timer);
18238
+ }
18239
+ };
18240
+ }
18241
+
18242
+ // src/daemon/service.ts
18243
+ async function startLinkService(options = {}) {
18244
+ const paths = options.paths ?? resolveRuntimePaths();
18245
+ const logger = createFileLogger({ paths });
18246
+ const [identity, config] = await Promise.all([loadIdentity(paths), loadConfig(paths)]);
18247
+ await logger.info("service_starting", {
18248
+ port: config.port,
18249
+ mode: identity?.link_id ? "paired" : "local-only"
18250
+ });
18251
+ const migration = await migrateLinkDatabase(paths);
18252
+ if (migration.appliedVersions.length > 0) {
18253
+ await logger.info("database_migrated", {
18254
+ database_file: migration.databaseFile,
18255
+ applied_versions: migration.appliedVersions,
18256
+ current_version: migration.currentVersion
18257
+ });
18258
+ }
18259
+ const conversations = new ConversationService(paths, logger);
18260
+ await conversations.rebuildStatisticsIndex();
18261
+ const app = await createApp({
18262
+ paths,
18263
+ logger,
18264
+ conversations,
18265
+ onPairingClaimed: options.onPairingClaimed
18266
+ });
18267
+ const server = createServer(app.callback());
18268
+ try {
18269
+ await listenServer(server, config.port);
18270
+ } catch (error) {
18271
+ await logger.error("service_start_failed", {
18272
+ port: config.port,
18273
+ error: error instanceof Error ? error.message : String(error)
18274
+ });
18275
+ await logger.flush();
18276
+ throw error;
18277
+ }
18278
+ server.on("error", (error) => {
18279
+ void logger.error("service_error", { error: error.message });
18280
+ });
18281
+ void logger.info("service_started", {
18282
+ port: config.port,
18283
+ link_id: identity?.link_id ?? null
18284
+ });
18285
+ const scheduler = startCronDeliveryScheduler({
18286
+ paths,
18287
+ conversations,
18288
+ logger
18289
+ });
18290
+ const lanIpMonitor = startLanIpMonitor({
18291
+ paths,
18292
+ logger,
18293
+ intervalMs: options.lanIpMonitorIntervalMs,
18294
+ dailyReportLimit: options.lanIpMonitorDailyReportLimit,
18295
+ fetchImpl: options.lanIpMonitorFetchImpl
18296
+ });
18297
+ let relay = null;
18298
+ if (identity?.link_id) {
18299
+ relay = connectRelayControl({
18300
+ relayBaseUrl: config.relayBaseUrl,
18301
+ linkId: identity.link_id,
18302
+ localPort: config.port,
18303
+ maxReconnectAttempts: options.relayMaxReconnectAttempts ?? 5,
18304
+ backoffBaseMs: 1e3,
18305
+ backoffMaxMs: 3e4,
18306
+ onStatus: (status) => {
18307
+ void logger.info("relay_status", status);
18308
+ }
18309
+ });
18310
+ } else {
18311
+ void logger.info("relay_skipped", { reason: "link_not_paired" });
18312
+ }
18313
+ if (options.writePidFile) {
18314
+ await writePidFile(paths);
18315
+ }
18316
+ return {
18317
+ async close() {
18318
+ scheduler.close();
18319
+ lanIpMonitor.close();
18320
+ relay?.close();
18321
+ await closeServer(server);
18322
+ await logger.info("service_stopped");
18323
+ await logger.flush();
18324
+ if (options.writePidFile) {
18325
+ await rm9(pidFilePath(paths), { force: true }).catch(() => void 0);
18326
+ }
18327
+ }
18328
+ };
18329
+ }
18330
+ function pidFilePath(paths = resolveRuntimePaths()) {
18331
+ return `${paths.runDir}/hermeslink.pid`;
18332
+ }
18333
+ async function writePidFile(paths) {
18334
+ await mkdir15(paths.runDir, { recursive: true, mode: 448 });
18335
+ await writeFile7(pidFilePath(paths), `${process.pid}
18336
+ `, { mode: 384 });
18337
+ }
18338
+ async function closeServer(server) {
18339
+ await new Promise((resolve, reject) => {
18340
+ let settled = false;
18341
+ let forceCloseTimer;
18342
+ let timeoutTimer;
18343
+ const settle = (error) => {
18344
+ if (settled) {
18345
+ return;
18346
+ }
18347
+ settled = true;
18348
+ clearTimeout(forceCloseTimer);
18349
+ clearTimeout(timeoutTimer);
18350
+ if (error) {
18351
+ reject(error);
18352
+ return;
18353
+ }
18354
+ resolve();
18355
+ };
18356
+ forceCloseTimer = setTimeout(() => {
18357
+ server.closeIdleConnections?.();
18358
+ server.closeAllConnections?.();
18359
+ }, 250);
18360
+ timeoutTimer = setTimeout(() => {
18361
+ server.closeAllConnections?.();
18362
+ settle();
18363
+ }, 5e3);
18364
+ server.close((error) => {
18365
+ if (error) {
18366
+ settle(error);
18367
+ return;
18368
+ }
18369
+ settle();
18370
+ });
18371
+ server.closeIdleConnections?.();
18372
+ });
18373
+ }
18374
+ async function listenServer(server, port) {
18375
+ await new Promise((resolve, reject) => {
18376
+ const cleanup = () => {
18377
+ server.off("error", onError);
18378
+ server.off("listening", onListening);
18379
+ };
18380
+ const onError = (error) => {
18381
+ cleanup();
18382
+ reject(error);
18383
+ };
18384
+ const onListening = () => {
18385
+ cleanup();
18386
+ resolve();
18387
+ };
18388
+ server.once("error", onError);
18389
+ server.once("listening", onListening);
18390
+ server.listen(port);
18391
+ });
18392
+ }
18393
+
18394
+ // src/daemon/process.ts
18395
+ async function startDaemonProcess(paths = resolveRuntimePaths()) {
18396
+ const config = await loadConfig(paths);
18397
+ let status = await getDaemonStatus(paths);
18398
+ if (status.running) {
18399
+ const probe = await probeLocalLinkService({ port: config.port, timeoutMs: 500 });
18400
+ if (probe.reachable) {
18401
+ return status;
18402
+ }
18403
+ await stopDaemonProcess(paths);
18404
+ status = await getDaemonStatus(paths);
18405
+ if (status.running) {
18406
+ return status;
18407
+ }
18408
+ }
18409
+ await mkdir16(paths.logsDir, { recursive: true, mode: 448 });
18410
+ await mkdir16(paths.runDir, { recursive: true, mode: 448 });
18411
+ const scriptPath = currentCliScriptPath();
18412
+ const child = spawn4(process.execPath, [scriptPath, "daemon-supervisor"], {
18413
+ detached: true,
18414
+ stdio: "ignore",
18415
+ env: process.env
18416
+ });
18417
+ child.unref();
18418
+ for (let index = 0; index < 12; index += 1) {
18419
+ await wait(250);
18420
+ const next = await getDaemonStatus(paths);
18421
+ if (next.running && (await probeLocalLinkService({ port: config.port, timeoutMs: 500 })).reachable) {
18422
+ return next;
18423
+ }
18424
+ }
18425
+ return await getDaemonStatus(paths);
18426
+ }
18427
+ async function runDaemonSupervisor(paths = resolveRuntimePaths()) {
18428
+ await mkdir16(paths.logsDir, { recursive: true, mode: 448 });
17788
18429
  const log = createRotatingTextLogWriter({
17789
18430
  paths,
17790
18431
  fileName: path21.basename(daemonLogFile(paths))
@@ -18447,7 +19088,7 @@ async function postJson(fetcher, url, token, body) {
18447
19088
  });
18448
19089
  const payload = await response.json().catch(() => null);
18449
19090
  if (!response.ok) {
18450
- const message = readErrorMessage3(payload) ?? `Relay request failed with HTTP ${response.status}`;
19091
+ const message = readErrorMessage4(payload) ?? `Relay request failed with HTTP ${response.status}`;
18451
19092
  throw new Error(message);
18452
19093
  }
18453
19094
  if (!payload) {
@@ -18455,7 +19096,7 @@ async function postJson(fetcher, url, token, body) {
18455
19096
  }
18456
19097
  return payload;
18457
19098
  }
18458
- function readErrorMessage3(payload) {
19099
+ function readErrorMessage4(payload) {
18459
19100
  if (typeof payload !== "object" || payload === null) {
18460
19101
  return null;
18461
19102
  }
@@ -18467,175 +19108,17 @@ function readErrorMessage3(payload) {
18467
19108
  return typeof message === "string" ? message : null;
18468
19109
  }
18469
19110
 
18470
- // src/topology/network.ts
18471
- import os5 from "os";
18472
- var VIRTUAL_INTERFACE_NAME_PATTERN = /(docker|veth|vmnet|vmenet|vbox|virtualbox|vmware|tailscale|zerotier|wireguard|utun|virbr|hyper-v|vethernet|loopback|\blo\b|^lo\d*$|^br-|^bridge\d+$|^zt|^tun|^tap|awdl|llw|anpi|gif|stf|ipsec|ppp)/iu;
18473
- var MAX_LAN_IPS = 4;
18474
- var MAX_PUBLIC_IPV4S = 2;
18475
- var MAX_PUBLIC_IPV6S = 2;
18476
- async function discoverRouteCandidates(options) {
18477
- const lanIps = discoverLanIps();
18478
- const publicIps = options.relayBootstrapToken ? await observePublicRoute(options).catch(() => ({ publicIpv4s: [], publicIpv6s: [] })) : { publicIpv4s: [], publicIpv6s: [] };
18479
- const publicIpv4s = unique(publicIps.publicIpv4s.filter(isUsablePublicIpv4)).slice(0, MAX_PUBLIC_IPV4S);
18480
- const publicIpv6s = unique(publicIps.publicIpv6s.filter(isUsablePublicIpv6)).slice(0, MAX_PUBLIC_IPV6S);
18481
- const preferredUrls = [
18482
- ...lanIps.map((ip) => buildDirectUrl(ip, options.port)),
18483
- ...publicIpv4s.map((ip) => buildDirectUrl(ip, options.port)),
18484
- ...publicIpv6s.map((ip) => buildDirectUrl(ip, options.port)),
18485
- `${options.relayBaseUrl.replace(/\/+$/u, "")}/api/v1/relay/links/${options.linkId}`
18486
- ];
18487
- return {
18488
- lanIps,
18489
- publicIpv4s,
18490
- publicIpv6s,
18491
- preferredUrls
18492
- };
18493
- }
18494
- function discoverLanIps() {
18495
- return discoverLanIpsFromInterfaces(os5.networkInterfaces());
18496
- }
18497
- function discoverLanIpsFromInterfaces(interfaces) {
18498
- const result = /* @__PURE__ */ new Set();
18499
- const candidates = [];
18500
- for (const [name, items] of Object.entries(interfaces)) {
18501
- if (shouldIgnoreInterface(name)) {
18502
- continue;
18503
- }
18504
- for (const item of items ?? []) {
18505
- if (!item.internal && item.address && item.family === "IPv4" && isUsableLanIpv4(item.address, item.netmask)) {
18506
- candidates.push({ name, address: item.address });
18507
- }
18508
- }
18509
- }
18510
- for (const candidate of candidates.sort(compareLanCandidate)) {
18511
- result.add(candidate.address);
18512
- }
18513
- return [...result].slice(0, MAX_LAN_IPS);
18514
- }
18515
- async function observePublicRoute(options) {
18516
- const fetcher = options.fetchImpl ?? fetch;
18517
- const response = await fetcher(`${options.relayBaseUrl.replace(/\/+$/u, "")}/api/v1/relay/public-route/observe`, {
18518
- method: "POST",
18519
- headers: {
18520
- authorization: `Bearer ${options.relayBootstrapToken}`,
18521
- "content-type": "application/json"
18522
- },
18523
- body: JSON.stringify({
18524
- install_id: options.installId,
18525
- link_id: options.linkId,
18526
- public_key_pem: options.publicKeyPem
18527
- })
18528
- });
18529
- const payload = await response.json().catch(() => null);
18530
- const record = typeof payload?.record === "object" && payload.record !== null ? payload.record : null;
18531
- const observed = typeof payload?.observed === "object" && payload.observed !== null ? payload.observed : null;
18532
- const values = [
18533
- readIpRecord(record?.ipv4),
18534
- readIpRecord(record?.ipv6),
18535
- typeof observed?.ip === "string" ? observed.ip : null
18536
- ].filter((value) => Boolean(value));
18537
- return {
18538
- publicIpv4s: unique(values.filter(isUsablePublicIpv4)),
18539
- publicIpv6s: unique(values.filter(isUsablePublicIpv6))
18540
- };
18541
- }
18542
- function readIpRecord(value) {
18543
- if (typeof value !== "object" || value === null) {
18544
- return null;
18545
- }
18546
- const ip = value.ip;
18547
- return typeof ip === "string" && ip.trim() ? ip.trim() : null;
18548
- }
18549
- function buildDirectUrl(ip, port) {
18550
- return `http://${ip.includes(":") ? `[${ip}]` : ip}:${port}`;
18551
- }
18552
- function shouldIgnoreInterface(name) {
18553
- return !name.trim() || VIRTUAL_INTERFACE_NAME_PATTERN.test(name);
18554
- }
18555
- function compareLanCandidate(left, right) {
18556
- const priority = interfacePriority(left.name) - interfacePriority(right.name);
18557
- return priority || left.name.localeCompare(right.name) || left.address.localeCompare(right.address);
18558
- }
18559
- function interfacePriority(name) {
18560
- if (/^(en|eth|wlan|wi-fi|wifi)/iu.test(name)) {
18561
- return 0;
18562
- }
18563
- return 1;
18564
- }
18565
- function isUsableLanIpv4(address, netmask) {
18566
- return isPrivateIpv4(address) && !isNetworkOrBroadcastIpv4Address(address, netmask);
18567
- }
18568
- function isUsablePublicIpv4(address) {
18569
- return isValidIpv4(address) && !isSpecialIpv4(address);
18570
- }
18571
- function isUsablePublicIpv6(address) {
18572
- const normalized = address.toLowerCase();
18573
- return normalized.includes(":") && !normalized.startsWith("fe80:") && !normalized.startsWith("fc") && !normalized.startsWith("fd") && !normalized.startsWith("ff") && normalized !== "::" && normalized !== "::1";
18574
- }
18575
- function isPrivateIpv4(address) {
18576
- const parts = parseIpv4Segments(address);
18577
- if (!parts) {
18578
- return false;
18579
- }
18580
- const [first, second] = parts;
18581
- return first === 10 || first === 172 && second >= 16 && second <= 31 || first === 192 && second === 168;
18582
- }
18583
- function isSpecialIpv4(address) {
18584
- const parts = parseIpv4Segments(address);
18585
- if (!parts) {
18586
- return true;
18587
- }
18588
- const [first, second, third, fourth] = parts;
18589
- return first === 0 || first === 10 || first === 127 || first >= 224 || first === 100 && second >= 64 && second <= 127 || first === 169 && second === 254 || first === 172 && second >= 16 && second <= 31 || first === 192 && second === 168 || first === 192 && second === 0 && third === 2 || first === 198 && (second === 18 || second === 19) || first === 198 && second === 51 && third === 100 || first === 203 && second === 0 && third === 113 || first === 255 && second === 255 && third === 255 && fourth === 255;
18590
- }
18591
- function isNetworkOrBroadcastIpv4Address(address, netmask) {
18592
- const addressParts = parseIpv4Segments(address);
18593
- const netmaskParts = netmask ? parseIpv4Segments(netmask) : null;
18594
- if (!addressParts) {
18595
- return true;
18596
- }
18597
- if (!netmaskParts) {
18598
- const last = addressParts[3];
18599
- return last === 0 || last === 255;
18600
- }
18601
- const addressInt = ipv4SegmentsToInt(addressParts);
18602
- const netmaskInt = ipv4SegmentsToInt(netmaskParts);
18603
- const hostMask = ~netmaskInt >>> 0;
18604
- if (hostMask === 0) {
18605
- return false;
18606
- }
18607
- const networkInt = addressInt & netmaskInt;
18608
- const broadcastInt = (networkInt | hostMask) >>> 0;
18609
- return addressInt === networkInt || addressInt === broadcastInt;
18610
- }
18611
- function isValidIpv4(address) {
18612
- return Boolean(parseIpv4Segments(address));
18613
- }
18614
- function parseIpv4Segments(address) {
18615
- if (!/^\d{1,3}(?:\.\d{1,3}){3}$/u.test(address)) {
18616
- return null;
18617
- }
18618
- const parts = address.split(".").map((part) => Number.parseInt(part, 10));
18619
- if (parts.length !== 4 || parts.some((part) => !Number.isInteger(part) || part < 0 || part > 255)) {
18620
- return null;
18621
- }
18622
- return parts;
18623
- }
18624
- function ipv4SegmentsToInt(parts) {
18625
- return (parts[0] << 24 >>> 0 | parts[1] << 16 | parts[2] << 8 | parts[3]) >>> 0;
18626
- }
18627
- function unique(values) {
18628
- return [...new Set(values)];
18629
- }
18630
-
18631
19111
  // src/pairing/pairing.ts
18632
19112
  async function preparePairing(paths = resolveRuntimePaths()) {
18633
19113
  const config = await loadConfig(paths);
18634
19114
  const identity = await ensureIdentity(paths);
19115
+ const systemInfo = readLinkSystemInfo();
18635
19116
  const created = await postServerJson(config.serverBaseUrl, "/api/v1/link-pairings", {
18636
19117
  install_id: identity.install_id,
18637
19118
  link_id: identity.link_id ?? void 0,
18638
- display_name: defaultDisplayName(),
19119
+ display_name: systemInfo.defaultDisplayName,
19120
+ platform: systemInfo.platform,
19121
+ hostname: systemInfo.hostname ?? void 0,
18639
19122
  public_key_pem: identity.public_key_pem
18640
19123
  });
18641
19124
  const relayBaseUrl = created.relayBaseUrl || config.relayBaseUrl;
@@ -18650,6 +19133,7 @@ async function preparePairing(paths = resolveRuntimePaths()) {
18650
19133
  port: config.port,
18651
19134
  relayBaseUrl,
18652
19135
  relayBootstrapToken: created.relayBootstrapToken,
19136
+ configuredLanHost: config.lanHost,
18653
19137
  linkId: assigned.linkId,
18654
19138
  installId: updatedIdentity.install_id,
18655
19139
  publicKeyPem: updatedIdentity.public_key_pem
@@ -18658,17 +19142,20 @@ async function preparePairing(paths = resolveRuntimePaths()) {
18658
19142
  install_id: updatedIdentity.install_id,
18659
19143
  link_id: assigned.linkId,
18660
19144
  link_version: LINK_VERSION,
18661
- display_name: defaultDisplayName(),
19145
+ display_name: systemInfo.defaultDisplayName,
19146
+ platform: systemInfo.platform,
19147
+ hostname: systemInfo.hostname ?? void 0,
18662
19148
  lan_ips: routes.lanIps,
18663
19149
  public_ipv4s: routes.publicIpv4s,
18664
19150
  public_ipv6s: routes.publicIpv6s,
18665
- preferred_urls: routes.preferredUrls
19151
+ preferred_urls: routes.preferredUrls,
19152
+ environment: routes.environment
18666
19153
  });
18667
19154
  const qrPayload = {
18668
19155
  kind: "hermes_link_pairing",
18669
19156
  version: 1,
18670
19157
  link_id: assigned.linkId,
18671
- display_name: defaultDisplayName(),
19158
+ display_name: systemInfo.defaultDisplayName,
18672
19159
  session_id: created.sessionId,
18673
19160
  code: created.code,
18674
19161
  preferred_urls: qrPreferredUrls(routes)
@@ -18679,7 +19166,7 @@ async function preparePairing(paths = resolveRuntimePaths()) {
18679
19166
  session_id: created.sessionId,
18680
19167
  code: created.code,
18681
19168
  link_id: assigned.linkId,
18682
- display_name: defaultDisplayName(),
19169
+ display_name: systemInfo.defaultDisplayName,
18683
19170
  local_api_url: `http://127.0.0.1:${config.port}`,
18684
19171
  server_base_url: config.serverBaseUrl,
18685
19172
  relay_base_url: relayBaseUrl,
@@ -18695,7 +19182,7 @@ async function preparePairing(paths = resolveRuntimePaths()) {
18695
19182
  pairingToken: created.pairingToken,
18696
19183
  relayBootstrapToken: created.relayBootstrapToken,
18697
19184
  relayBaseUrl,
18698
- displayName: defaultDisplayName(),
19185
+ displayName: systemInfo.defaultDisplayName,
18699
19186
  linkId: assigned.linkId,
18700
19187
  routes,
18701
19188
  qrPayload,
@@ -18777,7 +19264,7 @@ async function claimPairing(input) {
18777
19264
  return {
18778
19265
  link: {
18779
19266
  link_id: identity.link_id,
18780
- display_name: defaultDisplayName()
19267
+ display_name: readLinkSystemInfo().defaultDisplayName
18781
19268
  },
18782
19269
  device: formatDevice(session.device),
18783
19270
  access_token: {
@@ -18823,12 +19310,12 @@ async function patchServerJson(serverBaseUrl, path24, token, body) {
18823
19310
  async function readJsonResponse2(response) {
18824
19311
  const payload = await response.json().catch(() => null);
18825
19312
  if (!response.ok || !payload) {
18826
- const message = readErrorMessage4(payload) ?? `HermesPilot Server request failed with HTTP ${response.status}`;
19313
+ const message = readErrorMessage5(payload) ?? `HermesPilot Server request failed with HTTP ${response.status}`;
18827
19314
  throw new LinkHttpError(response.status, "server_request_failed", message);
18828
19315
  }
18829
19316
  return payload;
18830
19317
  }
18831
- function readErrorMessage4(payload) {
19318
+ function readErrorMessage5(payload) {
18832
19319
  if (typeof payload !== "object" || payload === null) {
18833
19320
  return null;
18834
19321
  }
@@ -18839,9 +19326,6 @@ function readErrorMessage4(payload) {
18839
19326
  const message = error.message;
18840
19327
  return typeof message === "string" ? message : null;
18841
19328
  }
18842
- function defaultDisplayName() {
18843
- return `Hermes Link ${process.platform}`;
18844
- }
18845
19329
  function pairingClaimPath(sessionId, paths) {
18846
19330
  return path23.join(paths.pairingDir, `${Buffer.from(sessionId).toString("base64url")}.claimed.json`);
18847
19331
  }
@@ -18876,7 +19360,8 @@ function registerSystemRoutes(router, options) {
18876
19360
  relayBaseUrl: config.relayBaseUrl,
18877
19361
  linkId: identity.link_id,
18878
19362
  installId: identity.install_id,
18879
- publicKeyPem: identity.public_key_pem
19363
+ publicKeyPem: identity.public_key_pem,
19364
+ configuredLanHost: config.lanHost
18880
19365
  }) : null;
18881
19366
  ctx.set("cache-control", "no-store");
18882
19367
  ctx.body = {
@@ -19952,14 +20437,18 @@ export {
19952
20437
  getLinkLogFile,
19953
20438
  ensureHermesApiServerAvailable,
19954
20439
  loadConfig,
20440
+ saveConfig,
20441
+ normalizeLanHost,
19955
20442
  loadIdentity,
19956
20443
  ensureIdentity,
19957
20444
  getIdentityStatus,
19958
20445
  hasActiveDevices,
20446
+ detectRuntimeEnvironment,
19959
20447
  preparePairing,
19960
20448
  readPairingClaim,
19961
20449
  clearPairingClaim,
19962
20450
  createApp,
20451
+ reportLinkStatusToServer,
19963
20452
  startLinkService,
19964
20453
  startDaemonProcess,
19965
20454
  runDaemonSupervisor,