@hermespilot/link 0.2.6 → 0.2.8

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.6";
3727
+ var LINK_VERSION = "0.2.8";
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";
@@ -7938,6 +7972,7 @@ function upsertAgentEventProjection(events, next) {
7938
7972
  ...next,
7939
7973
  id: previous.id,
7940
7974
  title: isGenericToolTitle(next.title) ? previous.title : next.title,
7975
+ created_at: earliestTimestamp(previous.created_at, next.created_at),
7941
7976
  subtitle: nextSubtitleIsFallback ? previous.subtitle ?? next.subtitle : next.subtitle ?? previous.subtitle,
7942
7977
  detail: next.detail ?? previous.detail,
7943
7978
  completed_at: next.completed_at ?? previous.completed_at
@@ -7946,6 +7981,17 @@ function upsertAgentEventProjection(events, next) {
7946
7981
  copy[index] = merged;
7947
7982
  return copy;
7948
7983
  }
7984
+ function earliestTimestamp(left, right) {
7985
+ const leftTime = Date.parse(left);
7986
+ const rightTime = Date.parse(right);
7987
+ if (Number.isNaN(leftTime)) {
7988
+ return right;
7989
+ }
7990
+ if (Number.isNaN(rightTime)) {
7991
+ return left;
7992
+ }
7993
+ return leftTime <= rightTime ? left : right;
7994
+ }
7949
7995
  function isGenericToolTitle(value) {
7950
7996
  const normalized = value.trim().toLowerCase().replace(/[\s_-]+/gu, "");
7951
7997
  return normalized === "tool" || normalized === "toolcall" || value === "\u5DE5\u5177\u8C03\u7528";
@@ -11697,7 +11743,10 @@ async function saveAssignedLinkId(linkId, paths = resolveRuntimePaths()) {
11697
11743
  return next;
11698
11744
  }
11699
11745
  function signRelayNonce(identity, nonce) {
11700
- 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);
11701
11750
  return signature.toString("base64url");
11702
11751
  }
11703
11752
  function getIdentityStatus(identity) {
@@ -12435,6 +12484,8 @@ function registerConversationRoutes(router, options) {
12435
12484
  });
12436
12485
  router.get("/api/v1/conversations/events", async (ctx) => {
12437
12486
  await authenticateRequest(ctx, paths);
12487
+ const mode = readQueryString(ctx.query.mode);
12488
+ const notificationOnly = mode === "notifications";
12438
12489
  ctx.respond = false;
12439
12490
  const response = ctx.res;
12440
12491
  response.statusCode = 200;
@@ -12442,6 +12493,9 @@ function registerConversationRoutes(router, options) {
12442
12493
  response.setHeader("cache-control", "no-store");
12443
12494
  response.setHeader("connection", "keep-alive");
12444
12495
  const unsubscribe = conversations.subscribeAll((event) => {
12496
+ if (notificationOnly && !isConversationNotificationEvent(event)) {
12497
+ return;
12498
+ }
12445
12499
  writeSseEvent(response, event);
12446
12500
  });
12447
12501
  const cleanup = () => {
@@ -12679,6 +12733,16 @@ function registerConversationRoutes(router, options) {
12679
12733
  }
12680
12734
  );
12681
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
+ }
12682
12746
 
12683
12747
  // src/http/middleware/error-handler.ts
12684
12748
  function createHttpErrorMiddleware(logger) {
@@ -17561,218 +17625,807 @@ async function handleFrame(socket, raw, localPort, abortControllers) {
17561
17625
  }
17562
17626
  }
17563
17627
 
17564
- // src/daemon/scheduler.ts
17565
- function startCronDeliveryScheduler(options) {
17566
- let running = false;
17567
- const syncCronDeliveries = async () => {
17568
- if (running) {
17569
- return;
17570
- }
17571
- running = true;
17572
- try {
17573
- await syncHermesLinkCronDeliveries(
17574
- options.paths,
17575
- options.conversations,
17576
- options.logger
17577
- );
17578
- } catch (error) {
17579
- void options.logger.warn("cron_link_delivery_sync_failed", {
17580
- error: error instanceof Error ? error.message : String(error)
17581
- });
17582
- } finally {
17583
- running = false;
17584
- }
17585
- };
17586
- const timer = setInterval(() => {
17587
- void syncCronDeliveries();
17588
- }, options.intervalMs ?? 3e4);
17589
- 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 });
17590
17637
  return {
17591
- close() {
17592
- clearInterval(timer);
17593
- }
17638
+ platform,
17639
+ hostname,
17640
+ osLabel,
17641
+ defaultDisplayName
17594
17642
  };
17595
17643
  }
17596
-
17597
- // src/daemon/service.ts
17598
- async function startLinkService(options = {}) {
17599
- const paths = options.paths ?? resolveRuntimePaths();
17600
- const logger = createFileLogger({ paths });
17601
- const [identity, config] = await Promise.all([loadIdentity(paths), loadConfig(paths)]);
17602
- await logger.info("service_starting", {
17603
- port: config.port,
17604
- mode: identity?.link_id ? "paired" : "local-only"
17605
- });
17606
- const migration = await migrateLinkDatabase(paths);
17607
- if (migration.appliedVersions.length > 0) {
17608
- await logger.info("database_migrated", {
17609
- database_file: migration.databaseFile,
17610
- applied_versions: migration.appliedVersions,
17611
- current_version: migration.currentVersion
17612
- });
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);
17613
17649
  }
17614
- const conversations = new ConversationService(paths, logger);
17615
- await conversations.rebuildStatisticsIndex();
17616
- const app = await createApp({
17617
- paths,
17618
- logger,
17619
- conversations,
17620
- onPairingClaimed: options.onPairingClaimed
17621
- });
17622
- const server = createServer(app.callback());
17623
- try {
17624
- await listenServer(server, config.port);
17625
- } catch (error) {
17626
- await logger.error("service_start_failed", {
17627
- port: config.port,
17628
- error: error instanceof Error ? error.message : String(error)
17629
- });
17630
- await logger.flush();
17631
- 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]));
17632
17660
  }
17633
- server.on("error", (error) => {
17634
- void logger.error("service_error", { error: error.message });
17635
- });
17636
- void logger.info("service_started", {
17637
- port: config.port,
17638
- link_id: identity?.link_id ?? null
17639
- });
17640
- const scheduler = startCronDeliveryScheduler({
17641
- paths,
17642
- conversations,
17643
- logger
17644
- });
17645
- let relay = null;
17646
- if (identity?.link_id) {
17647
- relay = connectRelayControl({
17648
- relayBaseUrl: config.relayBaseUrl,
17649
- linkId: identity.link_id,
17650
- localPort: config.port,
17651
- maxReconnectAttempts: options.relayMaxReconnectAttempts ?? 5,
17652
- backoffBaseMs: 1e3,
17653
- backoffMaxMs: 3e4,
17654
- onStatus: (status) => {
17655
- void logger.info("relay_status", status);
17656
- }
17657
- });
17658
- } else {
17659
- 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
+ }
17660
17669
  }
17661
- if (options.writePidFile) {
17662
- 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";
17663
17676
  }
17664
- return {
17665
- async close() {
17666
- scheduler.close();
17667
- relay?.close();
17668
- await closeServer(server);
17669
- await logger.info("service_stopped");
17670
- await logger.flush();
17671
- if (options.writePidFile) {
17672
- await rm9(pidFilePath(paths), { force: true }).catch(() => void 0);
17673
- }
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 {
17674
17690
  }
17675
- };
17691
+ }
17692
+ return null;
17676
17693
  }
17677
- function pidFilePath(paths = resolveRuntimePaths()) {
17678
- 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
+ }
17679
17705
  }
17680
- async function writePidFile(paths) {
17681
- await mkdir15(paths.runDir, { recursive: true, mode: 448 });
17682
- await writeFile7(pidFilePath(paths), `${process.pid}
17683
- `, { 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;
17684
17713
  }
17685
- async function closeServer(server) {
17686
- await new Promise((resolve, reject) => {
17687
- let settled = false;
17688
- let forceCloseTimer;
17689
- let timeoutTimer;
17690
- const settle = (error) => {
17691
- if (settled) {
17692
- return;
17693
- }
17694
- settled = true;
17695
- clearTimeout(forceCloseTimer);
17696
- clearTimeout(timeoutTimer);
17697
- if (error) {
17698
- reject(error);
17699
- return;
17700
- }
17701
- resolve();
17702
- };
17703
- forceCloseTimer = setTimeout(() => {
17704
- server.closeIdleConnections?.();
17705
- server.closeAllConnections?.();
17706
- }, 250);
17707
- timeoutTimer = setTimeout(() => {
17708
- server.closeAllConnections?.();
17709
- settle();
17710
- }, 5e3);
17711
- server.close((error) => {
17712
- if (error) {
17713
- settle(error);
17714
- return;
17715
- }
17716
- settle();
17717
- });
17718
- server.closeIdleConnections?.();
17719
- });
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;
17720
17720
  }
17721
- async function listenServer(server, port) {
17722
- await new Promise((resolve, reject) => {
17723
- const cleanup = () => {
17724
- server.off("error", onError);
17725
- server.off("listening", onListening);
17726
- };
17727
- const onError = (error) => {
17728
- cleanup();
17729
- reject(error);
17730
- };
17731
- const onListening = () => {
17732
- cleanup();
17733
- resolve();
17734
- };
17735
- server.once("error", onError);
17736
- server.once("listening", onListening);
17737
- server.listen(port);
17738
- });
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;
17739
17727
  }
17740
17728
 
17741
- // src/daemon/process.ts
17742
- async function startDaemonProcess(paths = resolveRuntimePaths()) {
17743
- const config = await loadConfig(paths);
17744
- let status = await getDaemonStatus(paths);
17745
- if (status.running) {
17746
- const probe = await probeLocalLinkService({ port: config.port, timeoutMs: 500 });
17747
- if (probe.reachable) {
17748
- return status;
17749
- }
17750
- await stopDaemonProcess(paths);
17751
- status = await getDaemonStatus(paths);
17752
- if (status.running) {
17753
- return status;
17754
- }
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>`."
17741
+ };
17755
17742
  }
17756
- await mkdir16(paths.logsDir, { recursive: true, mode: 448 });
17757
- await mkdir16(paths.runDir, { recursive: true, mode: 448 });
17758
- const scriptPath = currentCliScriptPath();
17759
- const child = spawn4(process.execPath, [scriptPath, "daemon-supervisor"], {
17760
- detached: true,
17761
- stdio: "ignore",
17762
- env: process.env
17763
- });
17764
- child.unref();
17765
- for (let index = 0; index < 12; index += 1) {
17766
- await wait(250);
17767
- const next = await getDaemonStatus(paths);
17768
- if (next.running && (await probeLocalLinkService({ port: config.port, timeoutMs: 500 })).reachable) {
17769
- return next;
17770
- }
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>`."
17748
+ };
17771
17749
  }
17772
- return await getDaemonStatus(paths);
17750
+ return {
17751
+ kind: "native",
17752
+ lanAutoDiscoveryUsable: true,
17753
+ warning: null
17754
+ };
17773
17755
  }
17774
- async function runDaemonSupervisor(paths = resolveRuntimePaths()) {
17775
- await mkdir16(paths.logsDir, { recursive: true, mode: 448 });
17756
+ function isWsl(env) {
17757
+ if (process.platform !== "linux") {
17758
+ return false;
17759
+ }
17760
+ if (env.WSL_DISTRO_NAME || env.WSL_INTEROP) {
17761
+ return true;
17762
+ }
17763
+ const release = os6.release().toLowerCase();
17764
+ return release.includes("microsoft") || release.includes("wsl");
17765
+ }
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 });
17776
18429
  const log = createRotatingTextLogWriter({
17777
18430
  paths,
17778
18431
  fileName: path21.basename(daemonLogFile(paths))
@@ -18435,7 +19088,7 @@ async function postJson(fetcher, url, token, body) {
18435
19088
  });
18436
19089
  const payload = await response.json().catch(() => null);
18437
19090
  if (!response.ok) {
18438
- const message = readErrorMessage3(payload) ?? `Relay request failed with HTTP ${response.status}`;
19091
+ const message = readErrorMessage4(payload) ?? `Relay request failed with HTTP ${response.status}`;
18439
19092
  throw new Error(message);
18440
19093
  }
18441
19094
  if (!payload) {
@@ -18443,7 +19096,7 @@ async function postJson(fetcher, url, token, body) {
18443
19096
  }
18444
19097
  return payload;
18445
19098
  }
18446
- function readErrorMessage3(payload) {
19099
+ function readErrorMessage4(payload) {
18447
19100
  if (typeof payload !== "object" || payload === null) {
18448
19101
  return null;
18449
19102
  }
@@ -18455,175 +19108,17 @@ function readErrorMessage3(payload) {
18455
19108
  return typeof message === "string" ? message : null;
18456
19109
  }
18457
19110
 
18458
- // src/topology/network.ts
18459
- import os5 from "os";
18460
- 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;
18461
- var MAX_LAN_IPS = 4;
18462
- var MAX_PUBLIC_IPV4S = 2;
18463
- var MAX_PUBLIC_IPV6S = 2;
18464
- async function discoverRouteCandidates(options) {
18465
- const lanIps = discoverLanIps();
18466
- const publicIps = options.relayBootstrapToken ? await observePublicRoute(options).catch(() => ({ publicIpv4s: [], publicIpv6s: [] })) : { publicIpv4s: [], publicIpv6s: [] };
18467
- const publicIpv4s = unique(publicIps.publicIpv4s.filter(isUsablePublicIpv4)).slice(0, MAX_PUBLIC_IPV4S);
18468
- const publicIpv6s = unique(publicIps.publicIpv6s.filter(isUsablePublicIpv6)).slice(0, MAX_PUBLIC_IPV6S);
18469
- const preferredUrls = [
18470
- ...lanIps.map((ip) => buildDirectUrl(ip, options.port)),
18471
- ...publicIpv4s.map((ip) => buildDirectUrl(ip, options.port)),
18472
- ...publicIpv6s.map((ip) => buildDirectUrl(ip, options.port)),
18473
- `${options.relayBaseUrl.replace(/\/+$/u, "")}/api/v1/relay/links/${options.linkId}`
18474
- ];
18475
- return {
18476
- lanIps,
18477
- publicIpv4s,
18478
- publicIpv6s,
18479
- preferredUrls
18480
- };
18481
- }
18482
- function discoverLanIps() {
18483
- return discoverLanIpsFromInterfaces(os5.networkInterfaces());
18484
- }
18485
- function discoverLanIpsFromInterfaces(interfaces) {
18486
- const result = /* @__PURE__ */ new Set();
18487
- const candidates = [];
18488
- for (const [name, items] of Object.entries(interfaces)) {
18489
- if (shouldIgnoreInterface(name)) {
18490
- continue;
18491
- }
18492
- for (const item of items ?? []) {
18493
- if (!item.internal && item.address && item.family === "IPv4" && isUsableLanIpv4(item.address, item.netmask)) {
18494
- candidates.push({ name, address: item.address });
18495
- }
18496
- }
18497
- }
18498
- for (const candidate of candidates.sort(compareLanCandidate)) {
18499
- result.add(candidate.address);
18500
- }
18501
- return [...result].slice(0, MAX_LAN_IPS);
18502
- }
18503
- async function observePublicRoute(options) {
18504
- const fetcher = options.fetchImpl ?? fetch;
18505
- const response = await fetcher(`${options.relayBaseUrl.replace(/\/+$/u, "")}/api/v1/relay/public-route/observe`, {
18506
- method: "POST",
18507
- headers: {
18508
- authorization: `Bearer ${options.relayBootstrapToken}`,
18509
- "content-type": "application/json"
18510
- },
18511
- body: JSON.stringify({
18512
- install_id: options.installId,
18513
- link_id: options.linkId,
18514
- public_key_pem: options.publicKeyPem
18515
- })
18516
- });
18517
- const payload = await response.json().catch(() => null);
18518
- const record = typeof payload?.record === "object" && payload.record !== null ? payload.record : null;
18519
- const observed = typeof payload?.observed === "object" && payload.observed !== null ? payload.observed : null;
18520
- const values = [
18521
- readIpRecord(record?.ipv4),
18522
- readIpRecord(record?.ipv6),
18523
- typeof observed?.ip === "string" ? observed.ip : null
18524
- ].filter((value) => Boolean(value));
18525
- return {
18526
- publicIpv4s: unique(values.filter(isUsablePublicIpv4)),
18527
- publicIpv6s: unique(values.filter(isUsablePublicIpv6))
18528
- };
18529
- }
18530
- function readIpRecord(value) {
18531
- if (typeof value !== "object" || value === null) {
18532
- return null;
18533
- }
18534
- const ip = value.ip;
18535
- return typeof ip === "string" && ip.trim() ? ip.trim() : null;
18536
- }
18537
- function buildDirectUrl(ip, port) {
18538
- return `http://${ip.includes(":") ? `[${ip}]` : ip}:${port}`;
18539
- }
18540
- function shouldIgnoreInterface(name) {
18541
- return !name.trim() || VIRTUAL_INTERFACE_NAME_PATTERN.test(name);
18542
- }
18543
- function compareLanCandidate(left, right) {
18544
- const priority = interfacePriority(left.name) - interfacePriority(right.name);
18545
- return priority || left.name.localeCompare(right.name) || left.address.localeCompare(right.address);
18546
- }
18547
- function interfacePriority(name) {
18548
- if (/^(en|eth|wlan|wi-fi|wifi)/iu.test(name)) {
18549
- return 0;
18550
- }
18551
- return 1;
18552
- }
18553
- function isUsableLanIpv4(address, netmask) {
18554
- return isPrivateIpv4(address) && !isNetworkOrBroadcastIpv4Address(address, netmask);
18555
- }
18556
- function isUsablePublicIpv4(address) {
18557
- return isValidIpv4(address) && !isSpecialIpv4(address);
18558
- }
18559
- function isUsablePublicIpv6(address) {
18560
- const normalized = address.toLowerCase();
18561
- return normalized.includes(":") && !normalized.startsWith("fe80:") && !normalized.startsWith("fc") && !normalized.startsWith("fd") && !normalized.startsWith("ff") && normalized !== "::" && normalized !== "::1";
18562
- }
18563
- function isPrivateIpv4(address) {
18564
- const parts = parseIpv4Segments(address);
18565
- if (!parts) {
18566
- return false;
18567
- }
18568
- const [first, second] = parts;
18569
- return first === 10 || first === 172 && second >= 16 && second <= 31 || first === 192 && second === 168;
18570
- }
18571
- function isSpecialIpv4(address) {
18572
- const parts = parseIpv4Segments(address);
18573
- if (!parts) {
18574
- return true;
18575
- }
18576
- const [first, second, third, fourth] = parts;
18577
- 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;
18578
- }
18579
- function isNetworkOrBroadcastIpv4Address(address, netmask) {
18580
- const addressParts = parseIpv4Segments(address);
18581
- const netmaskParts = netmask ? parseIpv4Segments(netmask) : null;
18582
- if (!addressParts) {
18583
- return true;
18584
- }
18585
- if (!netmaskParts) {
18586
- const last = addressParts[3];
18587
- return last === 0 || last === 255;
18588
- }
18589
- const addressInt = ipv4SegmentsToInt(addressParts);
18590
- const netmaskInt = ipv4SegmentsToInt(netmaskParts);
18591
- const hostMask = ~netmaskInt >>> 0;
18592
- if (hostMask === 0) {
18593
- return false;
18594
- }
18595
- const networkInt = addressInt & netmaskInt;
18596
- const broadcastInt = (networkInt | hostMask) >>> 0;
18597
- return addressInt === networkInt || addressInt === broadcastInt;
18598
- }
18599
- function isValidIpv4(address) {
18600
- return Boolean(parseIpv4Segments(address));
18601
- }
18602
- function parseIpv4Segments(address) {
18603
- if (!/^\d{1,3}(?:\.\d{1,3}){3}$/u.test(address)) {
18604
- return null;
18605
- }
18606
- const parts = address.split(".").map((part) => Number.parseInt(part, 10));
18607
- if (parts.length !== 4 || parts.some((part) => !Number.isInteger(part) || part < 0 || part > 255)) {
18608
- return null;
18609
- }
18610
- return parts;
18611
- }
18612
- function ipv4SegmentsToInt(parts) {
18613
- return (parts[0] << 24 >>> 0 | parts[1] << 16 | parts[2] << 8 | parts[3]) >>> 0;
18614
- }
18615
- function unique(values) {
18616
- return [...new Set(values)];
18617
- }
18618
-
18619
19111
  // src/pairing/pairing.ts
18620
19112
  async function preparePairing(paths = resolveRuntimePaths()) {
18621
19113
  const config = await loadConfig(paths);
18622
19114
  const identity = await ensureIdentity(paths);
19115
+ const systemInfo = readLinkSystemInfo();
18623
19116
  const created = await postServerJson(config.serverBaseUrl, "/api/v1/link-pairings", {
18624
19117
  install_id: identity.install_id,
18625
19118
  link_id: identity.link_id ?? void 0,
18626
- display_name: defaultDisplayName(),
19119
+ display_name: systemInfo.defaultDisplayName,
19120
+ platform: systemInfo.platform,
19121
+ hostname: systemInfo.hostname ?? void 0,
18627
19122
  public_key_pem: identity.public_key_pem
18628
19123
  });
18629
19124
  const relayBaseUrl = created.relayBaseUrl || config.relayBaseUrl;
@@ -18638,6 +19133,7 @@ async function preparePairing(paths = resolveRuntimePaths()) {
18638
19133
  port: config.port,
18639
19134
  relayBaseUrl,
18640
19135
  relayBootstrapToken: created.relayBootstrapToken,
19136
+ configuredLanHost: config.lanHost,
18641
19137
  linkId: assigned.linkId,
18642
19138
  installId: updatedIdentity.install_id,
18643
19139
  publicKeyPem: updatedIdentity.public_key_pem
@@ -18646,17 +19142,20 @@ async function preparePairing(paths = resolveRuntimePaths()) {
18646
19142
  install_id: updatedIdentity.install_id,
18647
19143
  link_id: assigned.linkId,
18648
19144
  link_version: LINK_VERSION,
18649
- display_name: defaultDisplayName(),
19145
+ display_name: systemInfo.defaultDisplayName,
19146
+ platform: systemInfo.platform,
19147
+ hostname: systemInfo.hostname ?? void 0,
18650
19148
  lan_ips: routes.lanIps,
18651
19149
  public_ipv4s: routes.publicIpv4s,
18652
19150
  public_ipv6s: routes.publicIpv6s,
18653
- preferred_urls: routes.preferredUrls
19151
+ preferred_urls: routes.preferredUrls,
19152
+ environment: routes.environment
18654
19153
  });
18655
19154
  const qrPayload = {
18656
19155
  kind: "hermes_link_pairing",
18657
19156
  version: 1,
18658
19157
  link_id: assigned.linkId,
18659
- display_name: defaultDisplayName(),
19158
+ display_name: systemInfo.defaultDisplayName,
18660
19159
  session_id: created.sessionId,
18661
19160
  code: created.code,
18662
19161
  preferred_urls: qrPreferredUrls(routes)
@@ -18667,7 +19166,7 @@ async function preparePairing(paths = resolveRuntimePaths()) {
18667
19166
  session_id: created.sessionId,
18668
19167
  code: created.code,
18669
19168
  link_id: assigned.linkId,
18670
- display_name: defaultDisplayName(),
19169
+ display_name: systemInfo.defaultDisplayName,
18671
19170
  local_api_url: `http://127.0.0.1:${config.port}`,
18672
19171
  server_base_url: config.serverBaseUrl,
18673
19172
  relay_base_url: relayBaseUrl,
@@ -18683,7 +19182,7 @@ async function preparePairing(paths = resolveRuntimePaths()) {
18683
19182
  pairingToken: created.pairingToken,
18684
19183
  relayBootstrapToken: created.relayBootstrapToken,
18685
19184
  relayBaseUrl,
18686
- displayName: defaultDisplayName(),
19185
+ displayName: systemInfo.defaultDisplayName,
18687
19186
  linkId: assigned.linkId,
18688
19187
  routes,
18689
19188
  qrPayload,
@@ -18765,7 +19264,7 @@ async function claimPairing(input) {
18765
19264
  return {
18766
19265
  link: {
18767
19266
  link_id: identity.link_id,
18768
- display_name: defaultDisplayName()
19267
+ display_name: readLinkSystemInfo().defaultDisplayName
18769
19268
  },
18770
19269
  device: formatDevice(session.device),
18771
19270
  access_token: {
@@ -18811,12 +19310,12 @@ async function patchServerJson(serverBaseUrl, path24, token, body) {
18811
19310
  async function readJsonResponse2(response) {
18812
19311
  const payload = await response.json().catch(() => null);
18813
19312
  if (!response.ok || !payload) {
18814
- 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}`;
18815
19314
  throw new LinkHttpError(response.status, "server_request_failed", message);
18816
19315
  }
18817
19316
  return payload;
18818
19317
  }
18819
- function readErrorMessage4(payload) {
19318
+ function readErrorMessage5(payload) {
18820
19319
  if (typeof payload !== "object" || payload === null) {
18821
19320
  return null;
18822
19321
  }
@@ -18827,9 +19326,6 @@ function readErrorMessage4(payload) {
18827
19326
  const message = error.message;
18828
19327
  return typeof message === "string" ? message : null;
18829
19328
  }
18830
- function defaultDisplayName() {
18831
- return `Hermes Link ${process.platform}`;
18832
- }
18833
19329
  function pairingClaimPath(sessionId, paths) {
18834
19330
  return path23.join(paths.pairingDir, `${Buffer.from(sessionId).toString("base64url")}.claimed.json`);
18835
19331
  }
@@ -18864,7 +19360,8 @@ function registerSystemRoutes(router, options) {
18864
19360
  relayBaseUrl: config.relayBaseUrl,
18865
19361
  linkId: identity.link_id,
18866
19362
  installId: identity.install_id,
18867
- publicKeyPem: identity.public_key_pem
19363
+ publicKeyPem: identity.public_key_pem,
19364
+ configuredLanHost: config.lanHost
18868
19365
  }) : null;
18869
19366
  ctx.set("cache-control", "no-store");
18870
19367
  ctx.body = {
@@ -19940,14 +20437,18 @@ export {
19940
20437
  getLinkLogFile,
19941
20438
  ensureHermesApiServerAvailable,
19942
20439
  loadConfig,
20440
+ saveConfig,
20441
+ normalizeLanHost,
19943
20442
  loadIdentity,
19944
20443
  ensureIdentity,
19945
20444
  getIdentityStatus,
19946
20445
  hasActiveDevices,
20446
+ detectRuntimeEnvironment,
19947
20447
  preparePairing,
19948
20448
  readPairingClaim,
19949
20449
  clearPairingClaim,
19950
20450
  createApp,
20451
+ reportLinkStatusToServer,
19951
20452
  startLinkService,
19952
20453
  startDaemonProcess,
19953
20454
  runDaemonSupervisor,