local-operator-ui 0.25.10 → 0.25.11

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.
Files changed (70) hide show
  1. package/out/main/index.js +531 -57
  2. package/out/renderer/assets/{_basePickBy-Dc7CsGGc.js → _basePickBy--Yue-XLQ.js} +1 -1
  3. package/out/renderer/assets/{_baseUniq-BcI4o9r1.js → _baseUniq-CX84XXMp.js} +1 -1
  4. package/out/renderer/assets/{agent-details-page-Bbzz81h0.js → agent-details-page-BjoWEolx.js} +1 -1
  5. package/out/renderer/assets/{agent-hub-page-BehmTi1m.js → agent-hub-page-BuMeC5YA.js} +1 -1
  6. package/out/renderer/assets/{agents-page-C-nkjaiF.js → agents-page-DMGAWpXp.js} +2 -2
  7. package/out/renderer/assets/{arc-DRqvxu6i.js → arc-CR3HNaPp.js} +1 -1
  8. package/out/renderer/assets/{architectureDiagram-IEHRJDOE-C-aO5oDt.js → architectureDiagram-IEHRJDOE-DLZPKw96.js} +1 -1
  9. package/out/renderer/assets/{arrow-left-CiJceI8e.js → arrow-left-CIBQH5Fe.js} +1 -1
  10. package/out/renderer/assets/{blockDiagram-JOT3LUYC-BS3JLZvl.js → blockDiagram-JOT3LUYC-BGnNcmsO.js} +1 -1
  11. package/out/renderer/assets/{browser-page-BNjbdOsx.js → browser-page-B02RKtfN.js} +1 -1
  12. package/out/renderer/assets/{c4Diagram-VJAJSXHY-Beuab6Az.js → c4Diagram-VJAJSXHY-Bu96GUil.js} +1 -1
  13. package/out/renderer/assets/channel-Dekw979G.js +1 -0
  14. package/out/renderer/assets/{chunk-4BMEZGHF-nHScUB-6.js → chunk-4BMEZGHF-Bc2IBqRl.js} +1 -1
  15. package/out/renderer/assets/{chunk-A2AXSNBT-CpH0MbZ1.js → chunk-A2AXSNBT-C7PMbmgd.js} +1 -1
  16. package/out/renderer/assets/{chunk-AEK57VVT-B8z14xij.js → chunk-AEK57VVT-Dy6-mgEo.js} +1 -1
  17. package/out/renderer/assets/{chunk-D6G4REZN-X_olriGu.js → chunk-D6G4REZN-cBOMihDM.js} +1 -1
  18. package/out/renderer/assets/{chunk-RZ5BOZE2-k05aTsX1.js → chunk-RZ5BOZE2-DtZPVT7y.js} +1 -1
  19. package/out/renderer/assets/{chunk-XZIHB7SX-CFzOVhMD.js → chunk-XZIHB7SX-BxlbgWo7.js} +1 -1
  20. package/out/renderer/assets/classDiagram-GIVACNV2-Bl3vsW8S.js +1 -0
  21. package/out/renderer/assets/classDiagram-v2-COTLJTTW-Bl3vsW8S.js +1 -0
  22. package/out/renderer/assets/clone-B0Lw9tfa.js +1 -0
  23. package/out/renderer/assets/{compact-pagination-DcQd1Yw-.js → compact-pagination-F8MDF-Kl.js} +1 -1
  24. package/out/renderer/assets/{dagre-OKDRZEBW-CzfJf6s3.js → dagre-OKDRZEBW-NnFztN4b.js} +1 -1
  25. package/out/renderer/assets/{diagram-SSKATNLV-C52lZrA0.js → diagram-SSKATNLV-2BWtLWap.js} +1 -1
  26. package/out/renderer/assets/{diagram-VNBRO52H-PR22VW_W.js → diagram-VNBRO52H-CENgJuWn.js} +1 -1
  27. package/out/renderer/assets/{erDiagram-Q7BY3M3F-C_GPAogs.js → erDiagram-Q7BY3M3F-CCc0_w5h.js} +1 -1
  28. package/out/renderer/assets/{flowDiagram-4HSFHLVR-DZnJIoPr.js → flowDiagram-4HSFHLVR-DBpVSzRM.js} +1 -1
  29. package/out/renderer/assets/{ganttDiagram-APWFNJXF-DQBsrrpK.js → ganttDiagram-APWFNJXF-mf6DXttE.js} +1 -1
  30. package/out/renderer/assets/{gitGraphDiagram-7IBYFJ6S-AfFACac2.js → gitGraphDiagram-7IBYFJ6S-Bq8O1rxL.js} +1 -1
  31. package/out/renderer/assets/{graph-Bjxe6B9O.js → graph-qxmVeyvg.js} +1 -1
  32. package/out/renderer/assets/icon-BF__WH76.css +1 -0
  33. package/out/renderer/assets/{index-C8lN9NIR.js → index-4byN7Q2L.js} +1 -1
  34. package/out/renderer/assets/{index-BHDf26BA.js → index-CT_UJzBT.js} +1 -1
  35. package/out/renderer/assets/{index-DOR1dMWN.js → index-DLktHc2h.js} +340 -340
  36. package/out/renderer/assets/{infoDiagram-PH2N3AL5-g5maBrjp.js → infoDiagram-PH2N3AL5-BCOEX5M1.js} +1 -1
  37. package/out/renderer/assets/{installer-BQl_2uJr.js → installer-BsQIORMk.js} +1 -1
  38. package/out/renderer/assets/{journeyDiagram-U35MCT3I-BlXoyGIt.js → journeyDiagram-U35MCT3I-BeoAemLm.js} +1 -1
  39. package/out/renderer/assets/{kanban-definition-NDS4AKOZ-BSPXz__X.js → kanban-definition-NDS4AKOZ-g2cpPHPh.js} +1 -1
  40. package/out/renderer/assets/{layout-R71oDIMJ.js → layout-BtO_FG0y.js} +1 -1
  41. package/out/renderer/assets/{legacy-agents-page-Dl_OPtYj.js → legacy-agents-page-CIz8TVKP.js} +1 -1
  42. package/out/renderer/assets/{mermaid.core-CjJqe_d-.js → mermaid.core-CdVpQ79S.js} +5 -5
  43. package/out/renderer/assets/{mindmap-definition-ALO5MXBD-BujOxX7c.js → mindmap-definition-ALO5MXBD-CHVbB4ku.js} +1 -1
  44. package/out/renderer/assets/{page-header-B_TbLiZC.js → page-header-BGvY3vKz.js} +1 -1
  45. package/out/renderer/assets/{parseISO-WLamiqnN.js → parseISO-BejL_jy7.js} +1 -1
  46. package/out/renderer/assets/{pieDiagram-IB7DONF6-CHZYRhVG.js → pieDiagram-IB7DONF6-BBOrJKFD.js} +1 -1
  47. package/out/renderer/assets/{quadrantDiagram-7GDLP6J5-mOsQ00cE.js → quadrantDiagram-7GDLP6J5-DSBlTbPc.js} +1 -1
  48. package/out/renderer/assets/{radar-MK3ICKWK-C0G40Mrt.js → radar-MK3ICKWK-pZgkhoLd.js} +1 -1
  49. package/out/renderer/assets/{radient-auth-buttons-BvMD0K4b.js → radient-auth-buttons-BnXJwZfD.js} +1 -1
  50. package/out/renderer/assets/{requirementDiagram-KVF5MWMF-BYjlnn4c.js → requirementDiagram-KVF5MWMF-BUV7_UZP.js} +1 -1
  51. package/out/renderer/assets/{sankeyDiagram-QLVOVGJD-Bd-AbvK-.js → sankeyDiagram-QLVOVGJD-DfvIpv82.js} +1 -1
  52. package/out/renderer/assets/{schedules-page-DieuF5Xp.js → schedules-page-D0aF6oGQ.js} +1 -1
  53. package/out/renderer/assets/{sequenceDiagram-X6HHIX6F-BcmQ0lXI.js → sequenceDiagram-X6HHIX6F-DFuvLBd0.js} +1 -1
  54. package/out/renderer/assets/{settings-page-DIb8gPLb.js → settings-page-DrjsU9En.js} +1 -1
  55. package/out/renderer/assets/{shield-check-BJ9wbi-7.js → shield-check-DusR2dvL.js} +1 -1
  56. package/out/renderer/assets/{stateDiagram-DGXRK772-DDC41wRc.js → stateDiagram-DGXRK772-CmDy9j2_.js} +1 -1
  57. package/out/renderer/assets/stateDiagram-v2-YXO3MK2T-Bv3iQPCj.js +1 -0
  58. package/out/renderer/assets/{timeline-definition-BDJGKUSR-BvNMSpcF.js → timeline-definition-BDJGKUSR-De6scz66.js} +1 -1
  59. package/out/renderer/assets/{use-agent-like-mutation-AG1SEqKe.js → use-agent-like-mutation-CxJR8287.js} +1 -1
  60. package/out/renderer/assets/{xychartDiagram-VJFVF3MP-CrFd9Hqj.js → xychartDiagram-VJFVF3MP-Cz0S_Epm.js} +1 -1
  61. package/out/renderer/index.html +3 -3
  62. package/out/renderer/installer.html +4 -4
  63. package/package.json +2 -2
  64. package/out/renderer/assets/channel-BW4mzN_3.js +0 -1
  65. package/out/renderer/assets/classDiagram-GIVACNV2-I4mBQb8Y.js +0 -1
  66. package/out/renderer/assets/classDiagram-v2-COTLJTTW-I4mBQb8Y.js +0 -1
  67. package/out/renderer/assets/clone-CFuPc3b2.js +0 -1
  68. package/out/renderer/assets/icon-BDu7a_-6.css +0 -1
  69. package/out/renderer/assets/stateDiagram-v2-YXO3MK2T-tZkQuuhw.js +0 -1
  70. /package/out/renderer/assets/{icon-giwXJbjk.js → icon-CEd0ml4Y.js} +0 -0
package/out/main/index.js CHANGED
@@ -1750,21 +1750,38 @@ function endpoint(request) {
1750
1750
  };
1751
1751
  }
1752
1752
  }
1753
- async function requestDesktopMedia(input, bytes, backendUrl, token) {
1753
+ async function requestDesktopMediaOutcome(input, bytes, backendUrl, token) {
1754
1754
  const parsed = mediaRequestSchema.safeParse(input);
1755
1755
  if (!parsed.success) {
1756
- return { status: 422, kind: "error", detail: "Invalid media operation." };
1756
+ return {
1757
+ response: {
1758
+ status: 422,
1759
+ kind: "error",
1760
+ detail: "Invalid media operation."
1761
+ },
1762
+ answered: false
1763
+ };
1757
1764
  }
1758
1765
  if (!token) {
1759
1766
  return {
1760
- status: 503,
1761
- kind: "error",
1762
- detail: "Restart with a desktop-managed backend to use these controls."
1767
+ response: {
1768
+ status: 503,
1769
+ kind: "error",
1770
+ detail: "Restart with a desktop-managed backend to use these controls."
1771
+ },
1772
+ answered: false
1763
1773
  };
1764
1774
  }
1765
1775
  const request = parsed.data;
1766
1776
  if (bytes && bytes.byteLength > MAX_UPLOAD_BYTES) {
1767
- return { status: 413, kind: "error", detail: "This file is too large." };
1777
+ return {
1778
+ response: {
1779
+ status: 413,
1780
+ kind: "error",
1781
+ detail: "This file is too large."
1782
+ },
1783
+ answered: false
1784
+ };
1768
1785
  }
1769
1786
  const target2 = endpoint(request);
1770
1787
  let body;
@@ -1776,7 +1793,10 @@ async function requestDesktopMedia(input, bytes, backendUrl, token) {
1776
1793
  contentType = "application/json";
1777
1794
  } else {
1778
1795
  if (!bytes) {
1779
- return { status: 422, kind: "error", detail: "A file is required." };
1796
+ return {
1797
+ response: { status: 422, kind: "error", detail: "A file is required." },
1798
+ answered: false
1799
+ };
1780
1800
  }
1781
1801
  const form = new FormData();
1782
1802
  const blob = new Blob([bytes], {
@@ -1790,6 +1810,7 @@ async function requestDesktopMedia(input, bytes, backendUrl, token) {
1790
1810
  }
1791
1811
  body = form;
1792
1812
  }
1813
+ let answered = false;
1793
1814
  try {
1794
1815
  const response = await fetch(new URL(target2.path, backendUrl), {
1795
1816
  method: target2.method,
@@ -1802,6 +1823,7 @@ async function requestDesktopMedia(input, bytes, backendUrl, token) {
1802
1823
  redirect: "error",
1803
1824
  signal: AbortSignal.timeout(12e4)
1804
1825
  });
1826
+ answered = true;
1805
1827
  const responseType = response.headers.get("content-type") ?? "";
1806
1828
  if (!response.ok) {
1807
1829
  let detail = "The media request failed.";
@@ -1813,34 +1835,57 @@ async function requestDesktopMedia(input, bytes, backendUrl, token) {
1813
1835
  } catch {
1814
1836
  }
1815
1837
  }
1816
- return { status: response.status, kind: "error", detail };
1838
+ return {
1839
+ response: { status: response.status, kind: "error", detail },
1840
+ answered: true
1841
+ };
1817
1842
  }
1818
1843
  if (responseType.includes("application/json")) {
1819
1844
  return {
1820
- status: response.status,
1821
- kind: "json",
1822
- body: await response.json()
1845
+ response: {
1846
+ status: response.status,
1847
+ kind: "json",
1848
+ body: await response.json()
1849
+ },
1850
+ answered: true
1823
1851
  };
1824
1852
  }
1825
1853
  const buffer = new Uint8Array(await response.arrayBuffer());
1826
1854
  if (buffer.byteLength > MAX_DOWNLOAD_BYTES) {
1827
1855
  return {
1828
- status: 502,
1829
- kind: "error",
1830
- detail: "The backend returned too much data."
1856
+ response: {
1857
+ status: 502,
1858
+ kind: "error",
1859
+ detail: "The backend returned too much data."
1860
+ },
1861
+ // The daemon answered; what it sent was over this relay's ceiling.
1862
+ answered: true
1831
1863
  };
1832
1864
  }
1833
1865
  return {
1834
- status: response.status,
1835
- kind: "bytes",
1836
- mimeType: responseType || "application/octet-stream",
1837
- data: buffer
1866
+ response: {
1867
+ status: response.status,
1868
+ kind: "bytes",
1869
+ mimeType: responseType || "application/octet-stream",
1870
+ data: buffer
1871
+ },
1872
+ answered: true
1838
1873
  };
1839
1874
  } catch {
1840
1875
  return {
1841
- status: 503,
1842
- kind: "error",
1843
- detail: "The backend could not complete this request. Check its connection and try again."
1876
+ response: {
1877
+ status: 503,
1878
+ kind: "error",
1879
+ detail: "The backend could not complete this request. Check its connection and try again."
1880
+ },
1881
+ /*
1882
+ * Survives the catch for the same reason as its JSON twin, and with the same
1883
+ * two cases: a body that failed to parse was already `answered: true`, and so
1884
+ * is a failure during the body read. A timeout abort that never got a
1885
+ * response throws out of `fetch` first and returns `false` (review round 2
1886
+ * MINOR-1; review round 3 MINOR-1 found the second case).
1887
+ */
1888
+ answered
1844
1889
  };
1845
1890
  }
1846
1891
  }
@@ -2037,27 +2082,40 @@ class DesktopStreamRelay {
2037
2082
  this.streams.clear();
2038
2083
  }
2039
2084
  }
2040
- async function requestDesktop(input, backendUrl, token) {
2085
+ async function requestDesktopOutcome(input, backendUrl, token) {
2041
2086
  const parsed = desktopRequestSchema.safeParse(input);
2042
2087
  if (!parsed.success) {
2043
- return { status: 422, body: { detail: "Invalid desktop operation." } };
2088
+ return {
2089
+ response: {
2090
+ status: 422,
2091
+ body: { detail: "Invalid desktop operation." }
2092
+ },
2093
+ answered: false
2094
+ };
2044
2095
  }
2045
2096
  const request = parsed.data;
2046
2097
  if (request.op !== "capabilities" && !token) {
2047
2098
  return {
2048
- status: 503,
2049
- body: {
2050
- detail: "Restart with a desktop-managed backend to use these controls."
2051
- }
2099
+ response: {
2100
+ status: 503,
2101
+ body: {
2102
+ detail: "Restart with a desktop-managed backend to use these controls."
2103
+ }
2104
+ },
2105
+ answered: false
2052
2106
  };
2053
2107
  }
2054
2108
  const target2 = desktopEndpoint(request);
2109
+ let answered = false;
2055
2110
  try {
2056
2111
  const body = target2.body === void 0 ? void 0 : JSON.stringify(target2.body);
2057
2112
  if (body && Buffer.byteLength(body) > desktopRequestByteBudget(request.op)) {
2058
2113
  return {
2059
- status: 413,
2060
- body: { detail: desktopRequestTooLargeDetail(request.op) }
2114
+ response: {
2115
+ status: 413,
2116
+ body: { detail: desktopRequestTooLargeDetail(request.op) }
2117
+ },
2118
+ answered: false
2061
2119
  };
2062
2120
  }
2063
2121
  const response = await fetch(new URL(target2.path, backendUrl), {
@@ -2071,20 +2129,42 @@ async function requestDesktop(input, backendUrl, token) {
2071
2129
  redirect: "error",
2072
2130
  signal: AbortSignal.timeout(2e4)
2073
2131
  });
2132
+ answered = true;
2074
2133
  const result = response.status === 204 ? null : await response.json();
2075
2134
  if (request.op === "capabilities" && !token && result?.result) {
2076
2135
  result.result.desktop_available = false;
2077
2136
  }
2078
- return { status: response.status, body: result };
2137
+ return {
2138
+ response: { status: response.status, body: result },
2139
+ answered: true
2140
+ };
2079
2141
  } catch {
2080
2142
  return {
2081
- status: 503,
2082
- body: {
2083
- detail: "The backend could not complete this request. Check its connection and try again."
2084
- }
2143
+ response: {
2144
+ status: 503,
2145
+ body: {
2146
+ detail: "The backend could not complete this request. Check its connection and try again."
2147
+ }
2148
+ },
2149
+ /*
2150
+ * `answered` survives the catch, and it is true here in exactly TWO cases,
2151
+ * both of them a listener that answered: a body this process could not
2152
+ * parse (the flag was set when `fetch` resolved, and a malformed answer is
2153
+ * still an answer), and a failure DURING the body read - an abort that
2154
+ * fired while the bytes were being pulled in lands here with `answered`
2155
+ * already set. A timeout abort that never got a response does NOT:
2156
+ * it throws out of `fetch` before the assignment, so it returns
2157
+ * `answered: false`, like a refused socket (review round 2 MINOR-1, and
2158
+ * review round 3 MINOR-1 which found the second case - the wording said
2159
+ * "the ONE case" and the invariant it named was narrower than the code).
2160
+ */
2161
+ answered
2085
2162
  };
2086
2163
  }
2087
2164
  }
2165
+ async function requestDesktop(input, backendUrl, token) {
2166
+ return (await requestDesktopOutcome(input, backendUrl, token)).response;
2167
+ }
2088
2168
  function trustedDesktopFrame(actual, expected) {
2089
2169
  try {
2090
2170
  const current = new URL(actual);
@@ -3622,6 +3702,8 @@ const apiConfig = {
3622
3702
  baseUrl: backendConfig.VITE_LOCAL_OPERATOR_API_URL
3623
3703
  };
3624
3704
  const DEGRADED_AFTER_FAILURES = 3;
3705
+ const UNANSWERED_BEFORE_DETACHED = 9;
3706
+ const TRANSPORT_EVIDENCE_MS = 3e4;
3625
3707
  const PROBE_INTERVAL_MS = 1e4;
3626
3708
  const PROBE_TIMEOUT_MS$1 = 2e3;
3627
3709
  const DETACHED_AFTER_MS = 9e4;
@@ -3642,11 +3724,16 @@ class DaemonStateMachine {
3642
3724
  updatedAt;
3643
3725
  detachedSince = null;
3644
3726
  backoffMs = REATTACH_BACKOFF_MS;
3727
+ /** Consecutive probes that ran out of budget with no answer at all. */
3728
+ unanswered = 0;
3729
+ /** When a desktop request was last answered, or null. */
3730
+ lastTransportAt = null;
3645
3731
  /** Adopt a validated daemon (`owned` says whether this app spawned it). */
3646
3732
  attach(identity, options) {
3647
3733
  this.identity = identity;
3648
3734
  this.owned = options.owned;
3649
3735
  this.failures = 0;
3736
+ this.unanswered = 0;
3650
3737
  this.detachedSince = null;
3651
3738
  this.backoffMs = REATTACH_BACKOFF_MS;
3652
3739
  this.state = "attached";
@@ -3676,6 +3763,7 @@ class DaemonStateMachine {
3676
3763
  switch (observation.kind) {
3677
3764
  case "identified":
3678
3765
  this.failures = 0;
3766
+ this.unanswered = 0;
3679
3767
  this.capabilityStatus = null;
3680
3768
  this.detachedSince = null;
3681
3769
  this.backoffMs = REATTACH_BACKOFF_MS;
@@ -3701,11 +3789,20 @@ class DaemonStateMachine {
3701
3789
  this.enterDetached(observation.detail);
3702
3790
  } else {
3703
3791
  this.state = "degraded";
3704
- this.detail = `${observation.detail} (probe ${this.failures} of ${DEGRADED_AFTER_FAILURES})`;
3792
+ this.detail = `${observation.detail} (probe ${this.failures} of ${DEGRADED_AFTER_FAILURES}).${this.transportEvidenceSuffix("so it is serving")}`;
3793
+ }
3794
+ break;
3795
+ case "unanswered":
3796
+ this.observeUnanswered(observation.detail);
3797
+ break;
3798
+ case "unattachable":
3799
+ if (this.state !== "attached" && this.state !== "replaced") {
3800
+ this.state = "wedged";
3705
3801
  }
3802
+ this.detail = observation.detail;
3706
3803
  break;
3707
3804
  case "pid-dead":
3708
- this.enterDetached("The daemon's process is gone.");
3805
+ this.enterDetached("The daemon's process is gone.", true);
3709
3806
  break;
3710
3807
  case "no-candidate":
3711
3808
  this.enterDetached(observation.detail);
@@ -3714,7 +3811,96 @@ class DaemonStateMachine {
3714
3811
  this.updatedAt = this.now();
3715
3812
  return this.state;
3716
3813
  }
3717
- enterDetached(detail) {
3814
+ /**
3815
+ * Fold a probe that ran out of budget with no answer.
3816
+ *
3817
+ * Three rules, in order, and each one exists because the alternative was
3818
+ * measured on the operator's machine:
3819
+ *
3820
+ * 1. A successful desktop request inside the evidence window means the
3821
+ * connection is ALIVE and the probe is the thing that is wrong. The state
3822
+ * does not move at all - not to `degraded`, because the app is talking to
3823
+ * its daemon right now - and the detail records both facts. This is the
3824
+ * rule that makes "no surface may say offline while the app is still
3825
+ * reading from it" true by construction rather than by each surface
3826
+ * remembering to check a second flag.
3827
+ * 2. Without that evidence, misses accumulate as `degraded` (usable), which
3828
+ * is all a 2 s budget on a busy box justifies.
3829
+ * 3. Only `UNANSWERED_BEFORE_DETACHED` consecutive misses detach, and the
3830
+ * detail says how many were counted, so the log line names the evidence.
3831
+ */
3832
+ observeUnanswered(detail) {
3833
+ this.unanswered += 1;
3834
+ if (this.transportEvidenceFresh()) {
3835
+ this.detail = `${detail}${this.transportEvidenceSuffix("so it is serving and the probe's budget is what expired")} (no answer to probe ${this.unanswered}).`;
3836
+ return;
3837
+ }
3838
+ if (this.unanswered >= UNANSWERED_BEFORE_DETACHED) {
3839
+ this.enterDetached(detail);
3840
+ return;
3841
+ }
3842
+ this.state = "degraded";
3843
+ this.detail = `${detail} (no answer to probe ${this.unanswered} of ${UNANSWERED_BEFORE_DETACHED})`;
3844
+ }
3845
+ /**
3846
+ * Record that a real request against the attached daemon was answered.
3847
+ *
3848
+ * WHY this is the state machine's business and not a caller's: the probe and
3849
+ * the transport disagree regularly on a box running long turns - the probe
3850
+ * has a 2 s budget, a session list has a 30 s one - and the one carrying the
3851
+ * user's data is the transport. Recording it here is what lets
3852
+ * {@link observeUnanswered} prefer it without every surface re-deriving the
3853
+ * same fact.
3854
+ *
3855
+ * It revives `degraded` (the daemon just answered, so the missed probes are
3856
+ * explained) and never a `detached` connection: coming back from those is
3857
+ * `recoverFromDetachment`'s job, because that path also has to re-prove the
3858
+ * daemon's IDENTITY before anything may use it.
3859
+ *
3860
+ * @returns whether the state actually moved, so the caller only pushes a
3861
+ * snapshot when there is something new to push.
3862
+ */
3863
+ recordTransportSuccess() {
3864
+ const moved = this.state === "degraded";
3865
+ this.lastTransportAt = this.now();
3866
+ this.unanswered = 0;
3867
+ if (moved) {
3868
+ this.failures = 0;
3869
+ this.state = this.identity ? "attached" : "connecting";
3870
+ this.detail = this.identity ? `Connected to the daemon on ${this.identity.url} (pid ${this.identity.pid}, v${this.identity.version}).` : "Connected to the Local Operator daemon.";
3871
+ }
3872
+ this.updatedAt = this.now();
3873
+ return moved;
3874
+ }
3875
+ /** Whether a successful request is recent enough to outrank a probe. */
3876
+ transportEvidenceFresh() {
3877
+ return this.lastTransportAt !== null && this.now() - this.lastTransportAt <= TRANSPORT_EVIDENCE_MS;
3878
+ }
3879
+ /**
3880
+ * The clause a sentence owes when THIS app's own request is the evidence
3881
+ * behind a connection the app has not detached from, or the empty string when
3882
+ * there is no such evidence.
3883
+ *
3884
+ * Shared rather than copied: three arms carry it (a probe budget that expired,
3885
+ * a refusal past the detach threshold, and a refusal short of it), and a third
3886
+ * copy is how one of them came to say "refused the connection" while the app
3887
+ * was still holding the connection open on the strength of its own answered
3888
+ * request.
3889
+ */
3890
+ transportEvidenceSuffix(tail) {
3891
+ if (!this.transportEvidenceFresh()) return "";
3892
+ const silentFor = Math.round(
3893
+ (this.now() - this.lastTransportAt) / 1e3
3894
+ );
3895
+ return ` The daemon answered a request ${silentFor}s ago, ${tail}.`;
3896
+ }
3897
+ enterDetached(detail, corroborated = false) {
3898
+ if (!corroborated && this.transportEvidenceFresh()) {
3899
+ this.failures = 0;
3900
+ this.state = "degraded";
3901
+ this.detail = `${detail}${this.transportEvidenceSuffix("so this app is not reporting it as gone")}`;
3902
+ return;
3903
+ }
3718
3904
  this.failures = DEGRADED_AFTER_FAILURES;
3719
3905
  this.state = "detached";
3720
3906
  if (this.detachedSince === null) this.detachedSince = this.now();
@@ -3770,6 +3956,13 @@ class DaemonStateMachine {
3770
3956
  desktopAvailable: this.desktopAvailable,
3771
3957
  failures: this.failures,
3772
3958
  capabilityStatus: this.capabilityStatus,
3959
+ /**
3960
+ * Carried so a surface can explain a `degraded` connection in the same
3961
+ * terms main used, rather than inventing a second reason for it. Both are
3962
+ * also what a reader needs to tell a busy daemon from a gone one.
3963
+ */
3964
+ unanswered: this.unanswered,
3965
+ lastTransportAt: this.lastTransportAt,
3773
3966
  detail: this.detail,
3774
3967
  updatedAt: this.updatedAt
3775
3968
  };
@@ -3902,6 +4095,24 @@ function recordAddress(record) {
3902
4095
  const host = record.host.includes(":") ? `[${record.host}]` : record.host;
3903
4096
  return `http://${host}:${record.port}`;
3904
4097
  }
4098
+ function classifyUnreachable(error) {
4099
+ const name = error?.name;
4100
+ if (name === "TimeoutError" || name === "AbortError") return "timeout";
4101
+ const code = error?.code ?? error?.cause?.code;
4102
+ switch (code) {
4103
+ case "ECONNREFUSED":
4104
+ case "EHOSTUNREACH":
4105
+ case "ENETUNREACH":
4106
+ case "ENETDOWN":
4107
+ case "EPIPE":
4108
+ return "refused";
4109
+ case "ENOTFOUND":
4110
+ case "EAI_AGAIN":
4111
+ return "unresolved";
4112
+ default:
4113
+ return "other";
4114
+ }
4115
+ }
3905
4116
  function readIdentity(payload) {
3906
4117
  if (typeof payload !== "object" || payload === null) return null;
3907
4118
  const result = payload.result;
@@ -3958,6 +4169,7 @@ async function probeIdentity(address, expectedInstanceId, options = {}) {
3958
4169
  } catch (error) {
3959
4170
  return {
3960
4171
  outcome: "unreachable",
4172
+ cause: classifyUnreachable(error),
3961
4173
  detail: error instanceof Error ? error.message : String(error)
3962
4174
  };
3963
4175
  }
@@ -4143,14 +4355,16 @@ async function probeUnidentified(address, options = {}) {
4143
4355
  });
4144
4356
  if (response.status !== 200) {
4145
4357
  return {
4146
- reason: "not-a-daemon",
4358
+ reason: "unready-answer",
4147
4359
  detail: `${HEALTH_PATH$1} answered ${response.status} and no record describes this address`
4148
4360
  };
4149
4361
  }
4150
4362
  const payload = await response.json().catch(() => null);
4151
- if (readIdentity(payload)) {
4363
+ const identity = readIdentity(payload);
4364
+ if (identity) {
4152
4365
  return {
4153
4366
  reason: "identity-mismatch",
4367
+ identity,
4154
4368
  detail: `a daemon answered but no serve record describes ${address}, so it cannot be proven to be the one this app found`
4155
4369
  };
4156
4370
  }
@@ -4161,6 +4375,7 @@ async function probeUnidentified(address, options = {}) {
4161
4375
  } catch (error) {
4162
4376
  return {
4163
4377
  reason: "unreachable",
4378
+ cause: classifyUnreachable(error),
4164
4379
  detail: error instanceof Error ? error.message : String(error)
4165
4380
  };
4166
4381
  }
@@ -5189,9 +5404,15 @@ const SHUTDOWN_TIMEOUT_DEFAULTS = {
5189
5404
  };
5190
5405
  const OWNED_STOP_WORST_MS = SHUTDOWN_TIMEOUT_DEFAULTS.normal + SHUTDOWN_TIMEOUT_DEFAULTS.force;
5191
5406
  const READINESS_POLL_INTERVAL_MS = 1e3;
5407
+ const DESKTOP_TOKEN_FILENAME = "desktop-token";
5192
5408
  const execPromise = node_util.promisify(node_child_process.exec);
5193
5409
  const ENV_VAR_REGEX = /^([^=]+)=(.*)$/;
5194
5410
  const LINE_BREAK = /\r?\n/;
5411
+ function classifyDesktopAnswer(status2) {
5412
+ if (status2 >= 200 && status2 < 300) return "accepted";
5413
+ if (status2 === 401 || status2 === 403) return "refused";
5414
+ return "unusable";
5415
+ }
5195
5416
  class BackendServiceManager {
5196
5417
  process = null;
5197
5418
  isRunning = false;
@@ -5215,6 +5436,19 @@ class BackendServiceManager {
5215
5436
  remoteConfigured = false;
5216
5437
  /** A failed probe or unreadable record is not evidence that spawning is safe. */
5217
5438
  discoveryBlocksSpawn = false;
5439
+ /**
5440
+ * The address of a daemon that ANSWERED this app's desktop read without
5441
+ * refusing its credential, and the status it answered, from the last
5442
+ * discovery sweep.
5443
+ *
5444
+ * Non-null means the address is OCCUPIED by a daemon this app may not attach
5445
+ * to *yet*: `start()`'s no-spawn report has to publish that as a running
5446
+ * daemon, because the alternative it would otherwise reach - `no-candidate` -
5447
+ * renders as offline, which is the one sentence this whole change exists to
5448
+ * stop saying about a server that answers (#1170: an unreadable session store
5449
+ * answers 503 instead of an empty 200).
5450
+ */
5451
+ answeredButUnusable = null;
5218
5452
  /**
5219
5453
  * Records discovery found alive but unresponsive (pid alive, heartbeat
5220
5454
  * stopped). They are why no candidate exists AND why spawning is forbidden,
@@ -5226,6 +5460,14 @@ class BackendServiceManager {
5226
5460
  nextRecoveryAt = 0;
5227
5461
  appDataPath = electron.app.getPath("userData");
5228
5462
  venvPath;
5463
+ /**
5464
+ * Why the last `/health` read did not return 200, for the callers that must
5465
+ * tell a refusal from an expired budget: `null` when it was not a transport
5466
+ * failure at all (the address answered, with a status this path does not
5467
+ * accept).
5468
+ */
5469
+ lastHealthFailure = null;
5470
+ /** The one probe loop's interval handle. */
5229
5471
  healthCheckInterval = null;
5230
5472
  /** The only process this manager may terminate. Null means it owns nothing,
5231
5473
  * which is a reason to report "nothing to stop" - never to go looking. */
@@ -5255,6 +5497,29 @@ class BackendServiceManager {
5255
5497
  shellEnv = {};
5256
5498
  // External/dev backends may be explicitly paired through main's environment.
5257
5499
  // Managed starts always rotate this; it is never exposed by preload or logs.
5500
+ /**
5501
+ * The credential this app holds for its OWN daemon.
5502
+ *
5503
+ * Read from disk at construction, and REPLACED by a freshly minted, persisted
5504
+ * one on every managed start. Those are two halves of one rule:
5505
+ *
5506
+ * - the read is what makes a daemon the previous run left running
5507
+ * attachable. The app deliberately leaves its daemon serving when it
5508
+ * attached to an external backend, and a daemon it spawned is
5509
+ * env-governed from the spawn (its record publishes `claim_key: ""`), so
5510
+ * the ONLY credential that can ever open it is the token its spawner held.
5511
+ * A launch that starts with no token there declines the daemon it owns and
5512
+ * starts a second one onto its port - measured on the operator's machine as
5513
+ * the 07:47 -> 09:17 sequence, ending in `[Errno 48] Address already in
5514
+ * use`;
5515
+ * - the write is why the token still changes when the app REPLACES a daemon
5516
+ * in place: the relay's cache key is this value (see `getStreamRelay`), and
5517
+ * a token that survived a restart would leave a subscription pointed at a
5518
+ * daemon this app had already stopped.
5519
+ *
5520
+ * The environment still wins, because an explicitly paired app is a deliberate
5521
+ * configuration and not a cache to be second-guessed.
5522
+ */
5258
5523
  desktopToken = process.env.LOCAL_OPERATOR_DESKTOP_TOKEN || null;
5259
5524
  /** Authenticated SSE relay for canonical session events. Recreated when the
5260
5525
  * backend URL rotates (external-backend discovery) or the desktop token does
@@ -5447,15 +5712,51 @@ class BackendServiceManager {
5447
5712
  this.feedRelay?.watchState(stateObserver);
5448
5713
  }
5449
5714
  requestDesktop(input) {
5450
- return requestDesktop(input, this.backendUrl, this.desktopToken);
5715
+ return requestDesktopOutcome(
5716
+ input,
5717
+ this.backendUrl,
5718
+ this.desktopToken
5719
+ ).then(({ response, answered }) => {
5720
+ if (answered) this.noteTransportAnswer();
5721
+ return response;
5722
+ });
5451
5723
  }
5452
5724
  requestDesktopMedia(input, bytes) {
5453
- return requestDesktopMedia(
5725
+ return requestDesktopMediaOutcome(
5454
5726
  input,
5455
5727
  bytes,
5456
5728
  this.backendUrl,
5457
5729
  this.desktopToken
5458
- );
5730
+ ).then(({ response, answered }) => {
5731
+ if (answered) this.noteTransportAnswer();
5732
+ return response;
5733
+ });
5734
+ }
5735
+ /**
5736
+ * Tell the state machine that this app's own request reached the daemon.
5737
+ *
5738
+ * `answered` is the TRANSPORT's verdict, not the promise's: every failure the
5739
+ * transport can produce resolves rather than throws, so `.then()` alone fires
5740
+ * for a refused socket, for a request this app never sent (no token, oversize,
5741
+ * an invalid op), and for one whose budget expired. Gating on it here is the
5742
+ * fix for review round 1's F-1, where an unearned stamp held the state at
5743
+ * `degraded` - which is not a state `checkBackendHealth` recovers from, so a
5744
+ * genuinely gone daemon could never be reported as gone.
5745
+ *
5746
+ * WHY this still counts EVERY answer, including a gated route's `401`/`403`:
5747
+ * the capability rules elsewhere in this file say a refusal is not a LIVENESS
5748
+ * signal, and that is about not calling it "connected". Turned around, a
5749
+ * refusal is the strongest liveness evidence this app has - a process read the
5750
+ * request and wrote a status line - which is exactly what a probe that ran out
5751
+ * of its 2 s budget failed to establish. Without this, one long agent turn was
5752
+ * enough to detach the connection and disable every read the daemon was still
5753
+ * answering.
5754
+ *
5755
+ * Only a state that actually moves pushes a snapshot: this runs on every
5756
+ * desktop call, and one IPC wake-up per request would be worse than the bug.
5757
+ */
5758
+ noteTransportAnswer() {
5759
+ if (this.daemonState.recordTransportSuccess()) this.notifyStatus();
5459
5760
  }
5460
5761
  isAppClosing = false;
5461
5762
  // Flag to track when the app is being closed
@@ -5494,6 +5795,7 @@ class BackendServiceManager {
5494
5795
  packaged: electron.app.isPackaged
5495
5796
  });
5496
5797
  this.loadShellEnvironment();
5798
+ this.desktopToken = this.desktopToken || this.persistedDesktopToken();
5497
5799
  logger.info(
5498
5800
  `Backend Service Manager initialized. May spawn or kill: ${this.managerMaySpawn}`,
5499
5801
  LogFileType.BACKEND
@@ -5768,6 +6070,11 @@ class BackendServiceManager {
5768
6070
  kind: "heartbeat-stale",
5769
6071
  detail: `A Local Operator daemon is running (pid ${wedged.pid}), but it stopped publishing its heartbeat, so this app did not attach to it. Waiting without starting a second one.`
5770
6072
  });
6073
+ } else if (this.answeredButUnusable) {
6074
+ this.daemonState.observe({
6075
+ kind: "unattachable",
6076
+ detail: `A Local Operator daemon is running at ${this.answeredButUnusable.address} and answered this app's read with HTTP ${this.answeredButUnusable.status}, so this app is not attached to it. Nothing is being started over it; it keeps probing.`
6077
+ });
5771
6078
  } else {
5772
6079
  this.daemonState.observe({
5773
6080
  kind: "no-candidate",
@@ -5914,6 +6221,7 @@ class BackendServiceManager {
5914
6221
  async adoptFirstUsableDaemon() {
5915
6222
  if (this.isAppClosing) return false;
5916
6223
  this.discoveryWedged = [];
6224
+ this.answeredButUnusable = null;
5917
6225
  if (this.remoteConfigured) {
5918
6226
  this.discoveryBlocksSpawn = true;
5919
6227
  return await this.legacyFixedPortAdoption();
@@ -5996,19 +6304,40 @@ class BackendServiceManager {
5996
6304
  return false;
5997
6305
  }
5998
6306
  }
5999
- if (!await this.authenticatesAgainst(candidate.address, token)) {
6307
+ const probe = await this.probeCandidate(candidate.address, token);
6308
+ if (probe.verdict === "refused") {
6000
6309
  logger.info(
6001
- `Daemon ${candidate.address} refuses this app's bearer for its desktop plane; not attaching (it would refuse every session list and every stream).`,
6310
+ `Daemon ${candidate.address} refused this app's bearer for its desktop plane (HTTP ${probe.status}); not attaching (it would refuse every session list and every stream).`,
6002
6311
  LogFileType.BACKEND
6003
6312
  );
6004
6313
  this.daemonState.observe({
6005
6314
  kind: "capability",
6006
- status: 403,
6007
- detail: `A daemon is running at ${candidate.address}, but it refused this app's credential for its desktop plane.`
6315
+ status: probe.status,
6316
+ detail: `A daemon is running at ${candidate.address}, but it refused this app's credential for its desktop plane (HTTP ${probe.status}).`
6317
+ });
6318
+ this.notifyStatus();
6319
+ return false;
6320
+ }
6321
+ if (probe.verdict === "unusable") {
6322
+ logger.info(
6323
+ `Daemon ${candidate.address} answered HTTP ${probe.status} to this app's desktop read without refusing its credential; not attaching this tick, and not starting a daemon over it.`,
6324
+ LogFileType.BACKEND
6325
+ );
6326
+ this.discoveryBlocksSpawn = true;
6327
+ this.answeredButUnusable = {
6328
+ address: candidate.address,
6329
+ status: probe.status
6330
+ };
6331
+ this.daemonState.observe({
6332
+ kind: "unattachable",
6333
+ detail: `A daemon is running at ${candidate.address} and answered this app's read with HTTP ${probe.status}, so this app is not attached to it. Nothing is being started over it.`
6008
6334
  });
6009
6335
  this.notifyStatus();
6010
6336
  return false;
6011
6337
  }
6338
+ if (probe.verdict === "unreachable") {
6339
+ return false;
6340
+ }
6012
6341
  await this.attachTo(candidate, token);
6013
6342
  return true;
6014
6343
  }
@@ -6024,16 +6353,17 @@ class BackendServiceManager {
6024
6353
  * `this.desktopToken` because this runs BEFORE adoption: the candidate must
6025
6354
  * be proved usable without the manager having committed to it.
6026
6355
  */
6027
- async authenticatesAgainst(address, token) {
6356
+ async probeCandidate(address, token) {
6028
6357
  try {
6029
6358
  const result = await requestDesktop(
6030
6359
  { op: "sessions.list", limit: 1 },
6031
6360
  address,
6032
6361
  token
6033
6362
  );
6034
- return result.status >= 200 && result.status < 300;
6363
+ const verdict = classifyDesktopAnswer(result.status);
6364
+ return verdict === "accepted" ? { verdict } : { verdict, status: result.status };
6035
6365
  } catch {
6036
- return false;
6366
+ return { verdict: "unreachable" };
6037
6367
  }
6038
6368
  }
6039
6369
  /**
@@ -6139,7 +6469,7 @@ class BackendServiceManager {
6139
6469
  const payload = await response.json().catch(() => null);
6140
6470
  if (!await this.authenticatesAgainstBackend()) {
6141
6471
  logger.info(
6142
- "A backend answered health but refused this app's desktop token; not adopting it.",
6472
+ "A backend answered health but did not accept this app's desktop credential; not adopting it.",
6143
6473
  LogFileType.BACKEND
6144
6474
  );
6145
6475
  return false;
@@ -6187,7 +6517,7 @@ class BackendServiceManager {
6187
6517
  op: "sessions.list",
6188
6518
  limit: 1
6189
6519
  });
6190
- return result.status >= 200 && result.status < 300;
6520
+ return classifyDesktopAnswer(result.status) === "accepted";
6191
6521
  } catch {
6192
6522
  return false;
6193
6523
  }
@@ -6268,7 +6598,13 @@ class BackendServiceManager {
6268
6598
  this.startHealthCheck();
6269
6599
  return false;
6270
6600
  }
6271
- this.desktopToken = node_crypto.randomBytes(32).toString("hex");
6601
+ const occupancy = await this.configuredOriginOccupancy();
6602
+ if (occupancy) {
6603
+ this.observeOriginOccupancy(occupancy);
6604
+ this.startHealthCheck();
6605
+ return false;
6606
+ }
6607
+ this.desktopToken = this.mintDesktopToken();
6272
6608
  let captured = null;
6273
6609
  try {
6274
6610
  this.venvPath = managedVenvPath({
@@ -6700,11 +7036,142 @@ class BackendServiceManager {
6700
7036
  `Backend health check response status: ${response.status}`,
6701
7037
  LogFileType.BACKEND
6702
7038
  );
7039
+ this.lastHealthFailure = null;
6703
7040
  return response.ok;
6704
- } catch (_error) {
7041
+ } catch (error) {
7042
+ this.lastHealthFailure = classifyUnreachable(error);
6705
7043
  return false;
6706
7044
  }
6707
7045
  }
7046
+ /**
7047
+ * What is answering the address this app is configured to serve on, if
7048
+ * anything that stops it starting a daemon there.
7049
+ *
7050
+ * `null` means "start it": either the socket was refused (the one answer that
7051
+ * proves a port free), or something answered 200 without identifying as a Local
7052
+ * Operator daemon. The second arm is deliberate and narrow - see the spawn gate
7053
+ * in `startOwned()` - and the observation is logged either way. An answer whose
7054
+ * STATUS is not 200 is not in that arm: it is an occupant (review round 1,
7055
+ * F-2), and it returns a `silent` occupancy below rather than a licence to
7056
+ * spawn.
7057
+ */
7058
+ async configuredOriginOccupancy() {
7059
+ const probe = await probeUnidentified(this.backendUrl, {
7060
+ timeoutMs: PROBE_TIMEOUT_MS$1
7061
+ });
7062
+ switch (probe.reason) {
7063
+ case "identity-mismatch":
7064
+ return {
7065
+ kind: "daemon",
7066
+ pid: probe.identity?.pid ?? null,
7067
+ version: probe.identity?.version ?? "",
7068
+ detail: probe.detail
7069
+ };
7070
+ /*
7071
+ * A status that is not 200 is an OCCUPANT, not a licence to spawn, and it
7072
+ * is handled here rather than by the `default` arm below so the decision is
7073
+ * the one a reader finds rather than the one the switch happens to fall
7074
+ * into (review round 1, F-2). It covers a Local Operator daemon starting up,
7075
+ * unhealthy or shutting down, and a proxy that fronts one and answers 5xx:
7076
+ * in every one of them the socket is bound, so a child spawned onto it dies
7077
+ * on `[Errno 48]` - after `mintDesktopToken()` has already overwritten the
7078
+ * credential for the daemon actually serving there. The answer does not
7079
+ * prove a daemon this app may attach to, so the gate declines to start one
7080
+ * and keeps probing; that is the safe direction, because the failure mode of
7081
+ * the other one is unrecoverable loss of the pairing token.
7082
+ */
7083
+ case "unready-answer":
7084
+ return {
7085
+ kind: "silent",
7086
+ cause: "other",
7087
+ detail: probe.detail
7088
+ };
7089
+ case "not-a-daemon":
7090
+ logger.info(
7091
+ `${this.backendUrl}${HEALTH_PATH$1} answered and is not a Local Operator daemon (${probe.detail}); starting a daemon anyway, as this app always has.`,
7092
+ LogFileType.BACKEND
7093
+ );
7094
+ return null;
7095
+ case "unreachable":
7096
+ return probe.cause === "refused" ? null : {
7097
+ kind: "silent",
7098
+ cause: probe.cause ?? "other",
7099
+ detail: probe.detail
7100
+ };
7101
+ default:
7102
+ return {
7103
+ kind: "silent",
7104
+ cause: "other",
7105
+ detail: probe.detail
7106
+ };
7107
+ }
7108
+ }
7109
+ /**
7110
+ * Publish what the spawn gate saw, in the vocabulary the copy already uses.
7111
+ *
7112
+ * Two outcomes reach here and each gets the state that names it: a Local
7113
+ * Operator daemon this app may not drive is `unattachable` (state `wedged` - a
7114
+ * server IS running and this app did not attach to it, no banner claiming it is
7115
+ * offline), and an address that did not answer in time is `unanswered` -
7116
+ * `degraded`, usable, no banner, and still counted, because a budget that
7117
+ * expired is not evidence of absence either way.
7118
+ */
7119
+ observeOriginOccupancy(occupancy) {
7120
+ const daemonLine = occupancy.kind === "daemon";
7121
+ const what = daemonLine ? "This app was not given the key to that server, so it did not start a second one" : `${this.backendUrl} answered without proving it is a Local Operator daemon, so this app did not start one there`;
7122
+ const detail = `${what}. It keeps probing for a server it can open.`;
7123
+ this.daemonState.observe(
7124
+ occupancy.kind === "silent" ? { kind: "unanswered", cause: occupancy.cause, detail } : { kind: "unattachable", detail }
7125
+ );
7126
+ logger.info(`Not spawning a daemon: ${detail}`, LogFileType.BACKEND);
7127
+ this.notifyStatus();
7128
+ }
7129
+ /**
7130
+ * The token governing the daemon this app spawned in a PREVIOUS run, read from
7131
+ * the 0600 file beside the app's other per-user state, or null.
7132
+ *
7133
+ * Read-only on purpose: a launch that has not spawned anything yet must not
7134
+ * create a credential, or "this app holds no token" (a first run, and the
7135
+ * honest reason to decline a daemon it cannot open) would become unreachable.
7136
+ */
7137
+ persistedDesktopToken() {
7138
+ try {
7139
+ const stored = fs.readFileSync(this.desktopTokenFile(), "utf8").trim();
7140
+ return stored || null;
7141
+ } catch {
7142
+ return null;
7143
+ }
7144
+ }
7145
+ /**
7146
+ * Mint the token for a daemon this app is about to spawn, and persist it so the
7147
+ * next launch can re-attach to that daemon instead of starting a second one.
7148
+ *
7149
+ * A token that cannot be written still governs this run's child - failing to
7150
+ * start over a cache file would be the worse trade - and the log line names
7151
+ * the cost, which is the stranded daemon next launch.
7152
+ */
7153
+ mintDesktopToken() {
7154
+ const token = node_crypto.randomBytes(32).toString("hex");
7155
+ try {
7156
+ const file = this.desktopTokenFile();
7157
+ fs.mkdirSync(path$1.dirname(file), { recursive: true, mode: 448 });
7158
+ fs.writeFileSync(file, token, { mode: 384 });
7159
+ } catch (error) {
7160
+ logger.warn(
7161
+ "Could not persist the desktop token; a daemon spawned now will not be re-attachable after a restart.",
7162
+ LogFileType.BACKEND,
7163
+ error
7164
+ );
7165
+ }
7166
+ return token;
7167
+ }
7168
+ desktopTokenPath = null;
7169
+ desktopTokenFile() {
7170
+ if (this.desktopTokenPath === null) {
7171
+ this.desktopTokenPath = path$1.join(this.appDataPath, DESKTOP_TOKEN_FILENAME);
7172
+ }
7173
+ return this.desktopTokenPath;
7174
+ }
6708
7175
  /**
6709
7176
  * One probe of the daemon this app is attached to.
6710
7177
  *
@@ -6722,9 +7189,12 @@ class BackendServiceManager {
6722
7189
  const expected = this.daemonState.expectedInstanceId();
6723
7190
  if (!expected) {
6724
7191
  const ok = await this.checkHealth();
6725
- return ok ? { kind: "identified" } : {
6726
- kind: "failed",
6727
- detail: `No answer from ${this.backendUrl}${HEALTH_PATH$1}`
7192
+ if (ok) return { kind: "identified" };
7193
+ const failure = this.lastHealthFailure;
7194
+ return {
7195
+ kind: failure === null || failure === "refused" ? "failed" : "unanswered",
7196
+ cause: failure ?? "other",
7197
+ detail: failure === null ? `${this.backendUrl}${HEALTH_PATH$1} answered a status this app does not accept` : `No answer from ${this.backendUrl}${HEALTH_PATH$1}`
6728
7198
  };
6729
7199
  }
6730
7200
  const probe = await probeIdentity(this.backendUrl, expected, {
@@ -6747,8 +7217,12 @@ class BackendServiceManager {
6747
7217
  detail: `${this.backendUrl} answered but is not a Local Operator daemon (${probe.detail})`
6748
7218
  };
6749
7219
  case "unreachable":
6750
- return {
7220
+ return probe.cause === "refused" ? {
6751
7221
  kind: "failed",
7222
+ detail: `${this.backendUrl} refused the connection (${probe.detail})`
7223
+ } : {
7224
+ kind: "unanswered",
7225
+ cause: probe.cause,
6752
7226
  detail: `${this.backendUrl} did not answer (${probe.detail})`
6753
7227
  };
6754
7228
  }