local-operator-ui 0.25.10 → 0.25.12

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 (74) hide show
  1. package/out/main/index.js +625 -113
  2. package/out/preload/index.js +23 -0
  3. package/out/renderer/assets/{_basePickBy-Dc7CsGGc.js → _basePickBy-BNgST6Rr.js} +1 -1
  4. package/out/renderer/assets/{_baseUniq-BcI4o9r1.js → _baseUniq-CNa-9k-r.js} +1 -1
  5. package/out/renderer/assets/{agent-details-page-Bbzz81h0.js → agent-details-page-DP47DzSl.js} +2 -2
  6. package/out/renderer/assets/{agent-hub-page-BehmTi1m.js → agent-hub-page-D1ZXflSQ.js} +1 -1
  7. package/out/renderer/assets/{agents-page-C-nkjaiF.js → agents-page-B59ShFbk.js} +2 -2
  8. package/out/renderer/assets/arc-BoYjpXBC.js +1 -0
  9. package/out/renderer/assets/{architectureDiagram-IEHRJDOE-C-aO5oDt.js → architectureDiagram-IEHRJDOE-WK5zrdxz.js} +1 -1
  10. package/out/renderer/assets/{blockDiagram-JOT3LUYC-BS3JLZvl.js → blockDiagram-JOT3LUYC-D9UTpO5A.js} +1 -1
  11. package/out/renderer/assets/browser-page-CetmvQYi.js +1 -0
  12. package/out/renderer/assets/{c4Diagram-VJAJSXHY-Beuab6Az.js → c4Diagram-VJAJSXHY-B3h6cXcl.js} +1 -1
  13. package/out/renderer/assets/channel-Ky5T3Z-m.js +1 -0
  14. package/out/renderer/assets/{chunk-4BMEZGHF-nHScUB-6.js → chunk-4BMEZGHF-By24kS-C.js} +1 -1
  15. package/out/renderer/assets/{chunk-A2AXSNBT-CpH0MbZ1.js → chunk-A2AXSNBT-BUJ4Y2e4.js} +1 -1
  16. package/out/renderer/assets/{chunk-AEK57VVT-B8z14xij.js → chunk-AEK57VVT-DTTsjI99.js} +1 -1
  17. package/out/renderer/assets/{chunk-D6G4REZN-X_olriGu.js → chunk-D6G4REZN-DvcTjmZw.js} +1 -1
  18. package/out/renderer/assets/{chunk-RZ5BOZE2-k05aTsX1.js → chunk-RZ5BOZE2-DXscMZJF.js} +1 -1
  19. package/out/renderer/assets/{chunk-XZIHB7SX-CFzOVhMD.js → chunk-XZIHB7SX-BzrqBzfD.js} +1 -1
  20. package/out/renderer/assets/classDiagram-GIVACNV2-g9icdFAZ.js +1 -0
  21. package/out/renderer/assets/classDiagram-v2-COTLJTTW-g9icdFAZ.js +1 -0
  22. package/out/renderer/assets/clone-BqBVBasB.js +1 -0
  23. package/out/renderer/assets/{compact-pagination-DcQd1Yw-.js → compact-pagination-B8_U1T-B.js} +1 -1
  24. package/out/renderer/assets/{dagre-OKDRZEBW-CzfJf6s3.js → dagre-OKDRZEBW-gg03eZxv.js} +1 -1
  25. package/out/renderer/assets/{diagram-SSKATNLV-C52lZrA0.js → diagram-SSKATNLV-DddhoXRo.js} +1 -1
  26. package/out/renderer/assets/{diagram-VNBRO52H-PR22VW_W.js → diagram-VNBRO52H-mKPJ5dH9.js} +1 -1
  27. package/out/renderer/assets/{erDiagram-Q7BY3M3F-C_GPAogs.js → erDiagram-Q7BY3M3F-ByuwRXpd.js} +1 -1
  28. package/out/renderer/assets/{flowDiagram-4HSFHLVR-DZnJIoPr.js → flowDiagram-4HSFHLVR-CTX-QN_S.js} +1 -1
  29. package/out/renderer/assets/{ganttDiagram-APWFNJXF-DQBsrrpK.js → ganttDiagram-APWFNJXF-B7oQhCsh.js} +5 -5
  30. package/out/renderer/assets/{gitGraphDiagram-7IBYFJ6S-AfFACac2.js → gitGraphDiagram-7IBYFJ6S-DtG8gjjj.js} +1 -1
  31. package/out/renderer/assets/{graph-Bjxe6B9O.js → graph-DiOqbHad.js} +1 -1
  32. package/out/renderer/assets/{icon-giwXJbjk.js → icon-CSpXTlbM.js} +38 -33
  33. package/out/renderer/assets/icon-D2QOBUv8.css +1 -0
  34. package/out/renderer/assets/index-BD5IRbfX.js +7 -0
  35. package/out/renderer/assets/{index-DOR1dMWN.js → index-T22NbVLg.js} +390 -385
  36. package/out/renderer/assets/{index-BHDf26BA.js → index-z8If9Hsx.js} +1 -1
  37. package/out/renderer/assets/{infoDiagram-PH2N3AL5-g5maBrjp.js → infoDiagram-PH2N3AL5-DzYo0O21.js} +1 -1
  38. package/out/renderer/assets/{installer-BQl_2uJr.js → installer-CJQ6lCyv.js} +2 -2
  39. package/out/renderer/assets/{journeyDiagram-U35MCT3I-BlXoyGIt.js → journeyDiagram-U35MCT3I-DFXUunVq.js} +1 -1
  40. package/out/renderer/assets/{kanban-definition-NDS4AKOZ-BSPXz__X.js → kanban-definition-NDS4AKOZ-Cuxd0jpB.js} +1 -1
  41. package/out/renderer/assets/{layout-R71oDIMJ.js → layout-jJpq7v2W.js} +1 -1
  42. package/out/renderer/assets/{legacy-agents-page-Dl_OPtYj.js → legacy-agents-page-BiYETwqx.js} +6 -6
  43. package/out/renderer/assets/{mermaid.core-CjJqe_d-.js → mermaid.core-7c_Muji_.js} +5 -5
  44. package/out/renderer/assets/{mindmap-definition-ALO5MXBD-BujOxX7c.js → mindmap-definition-ALO5MXBD-CFGcKAtd.js} +1 -1
  45. package/out/renderer/assets/{page-header-B_TbLiZC.js → page-header-mDu8ASp6.js} +1 -1
  46. package/out/renderer/assets/{parseISO-WLamiqnN.js → parseISO-CpkRc9R5.js} +1 -1
  47. package/out/renderer/assets/{pieDiagram-IB7DONF6-CHZYRhVG.js → pieDiagram-IB7DONF6-CB5F2Scj.js} +1 -1
  48. package/out/renderer/assets/{quadrantDiagram-7GDLP6J5-mOsQ00cE.js → quadrantDiagram-7GDLP6J5-T2ChvnfN.js} +1 -1
  49. package/out/renderer/assets/{radar-MK3ICKWK-C0G40Mrt.js → radar-MK3ICKWK-Dnt4pIEP.js} +1 -1
  50. package/out/renderer/assets/{radient-auth-buttons-BvMD0K4b.js → radient-auth-buttons-9z_hytPL.js} +1 -1
  51. package/out/renderer/assets/{requirementDiagram-KVF5MWMF-BYjlnn4c.js → requirementDiagram-KVF5MWMF-2LwxU0xD.js} +1 -1
  52. package/out/renderer/assets/{sankeyDiagram-QLVOVGJD-Bd-AbvK-.js → sankeyDiagram-QLVOVGJD-D2hSv1wN.js} +1 -1
  53. package/out/renderer/assets/{schedules-page-DieuF5Xp.js → schedules-page-Bzys1Cgl.js} +8 -8
  54. package/out/renderer/assets/{sequenceDiagram-X6HHIX6F-BcmQ0lXI.js → sequenceDiagram-X6HHIX6F-DtEsIqsS.js} +1 -1
  55. package/out/renderer/assets/{settings-page-DIb8gPLb.js → settings-page-3xbYrUog.js} +9 -9
  56. package/out/renderer/assets/{stateDiagram-DGXRK772-DDC41wRc.js → stateDiagram-DGXRK772-x2r7beBJ.js} +1 -1
  57. package/out/renderer/assets/stateDiagram-v2-YXO3MK2T-D2e263sO.js +1 -0
  58. package/out/renderer/assets/{timeline-definition-BDJGKUSR-BvNMSpcF.js → timeline-definition-BDJGKUSR-B9JtqCfm.js} +1 -1
  59. package/out/renderer/assets/{use-agent-like-mutation-AG1SEqKe.js → use-agent-like-mutation-D0ncsaM7.js} +1 -1
  60. package/out/renderer/assets/{xychartDiagram-VJFVF3MP-CrFd9Hqj.js → xychartDiagram-VJFVF3MP-CCw8vLRW.js} +1 -1
  61. package/out/renderer/index.html +3 -3
  62. package/out/renderer/installer.html +3 -4
  63. package/package.json +2 -2
  64. package/out/renderer/assets/arc-DRqvxu6i.js +0 -1
  65. package/out/renderer/assets/arrow-left-CiJceI8e.js +0 -6
  66. package/out/renderer/assets/browser-page-BNjbdOsx.js +0 -1
  67. package/out/renderer/assets/channel-BW4mzN_3.js +0 -1
  68. package/out/renderer/assets/classDiagram-GIVACNV2-I4mBQb8Y.js +0 -1
  69. package/out/renderer/assets/classDiagram-v2-COTLJTTW-I4mBQb8Y.js +0 -1
  70. package/out/renderer/assets/clone-CFuPc3b2.js +0 -1
  71. package/out/renderer/assets/icon-BDu7a_-6.css +0 -1
  72. package/out/renderer/assets/index-C8lN9NIR.js +0 -7
  73. package/out/renderer/assets/shield-check-BJ9wbi-7.js +0 -6
  74. package/out/renderer/assets/stateDiagram-v2-YXO3MK2T-tZkQuuhw.js +0 -1
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);
@@ -3351,7 +3431,12 @@ function resolveGlobalInstallPlan(input) {
3351
3431
  sourceBuild: false
3352
3432
  };
3353
3433
  }
3354
- function buildPipUpgradeCommand(pythonPath2) {
3434
+ const PINNABLE_VERSION_REGEX = /^(?:\d+!)?\d+(?:\.\d+)*(?:(?:a|b|rc)\d+)?(?:\.post\d+)?(?:\.dev\d+)?(?:[-+][0-9A-Za-z.]+)?$/;
3435
+ function isPinnableVersion(target2) {
3436
+ return typeof target2 === "string" && PINNABLE_VERSION_REGEX.test(target2.trim());
3437
+ }
3438
+ function buildPipUpgradeCommand(pythonPath2, targetVersion) {
3439
+ const requirement = isPinnableVersion(targetVersion) ? `local-operator==${targetVersion.trim()}` : "local-operator";
3355
3440
  const args = [
3356
3441
  "-m",
3357
3442
  "pip",
@@ -3359,7 +3444,8 @@ function buildPipUpgradeCommand(pythonPath2) {
3359
3444
  "--upgrade",
3360
3445
  "--no-input",
3361
3446
  "--disable-pip-version-check",
3362
- "local-operator"
3447
+ "--no-cache-dir",
3448
+ requirement
3363
3449
  ];
3364
3450
  return {
3365
3451
  command: pythonPath2,
@@ -3622,6 +3708,8 @@ const apiConfig = {
3622
3708
  baseUrl: backendConfig.VITE_LOCAL_OPERATOR_API_URL
3623
3709
  };
3624
3710
  const DEGRADED_AFTER_FAILURES = 3;
3711
+ const UNANSWERED_BEFORE_DETACHED = 9;
3712
+ const TRANSPORT_EVIDENCE_MS = 3e4;
3625
3713
  const PROBE_INTERVAL_MS = 1e4;
3626
3714
  const PROBE_TIMEOUT_MS$1 = 2e3;
3627
3715
  const DETACHED_AFTER_MS = 9e4;
@@ -3642,11 +3730,16 @@ class DaemonStateMachine {
3642
3730
  updatedAt;
3643
3731
  detachedSince = null;
3644
3732
  backoffMs = REATTACH_BACKOFF_MS;
3733
+ /** Consecutive probes that ran out of budget with no answer at all. */
3734
+ unanswered = 0;
3735
+ /** When a desktop request was last answered, or null. */
3736
+ lastTransportAt = null;
3645
3737
  /** Adopt a validated daemon (`owned` says whether this app spawned it). */
3646
3738
  attach(identity, options) {
3647
3739
  this.identity = identity;
3648
3740
  this.owned = options.owned;
3649
3741
  this.failures = 0;
3742
+ this.unanswered = 0;
3650
3743
  this.detachedSince = null;
3651
3744
  this.backoffMs = REATTACH_BACKOFF_MS;
3652
3745
  this.state = "attached";
@@ -3676,6 +3769,7 @@ class DaemonStateMachine {
3676
3769
  switch (observation.kind) {
3677
3770
  case "identified":
3678
3771
  this.failures = 0;
3772
+ this.unanswered = 0;
3679
3773
  this.capabilityStatus = null;
3680
3774
  this.detachedSince = null;
3681
3775
  this.backoffMs = REATTACH_BACKOFF_MS;
@@ -3701,11 +3795,20 @@ class DaemonStateMachine {
3701
3795
  this.enterDetached(observation.detail);
3702
3796
  } else {
3703
3797
  this.state = "degraded";
3704
- this.detail = `${observation.detail} (probe ${this.failures} of ${DEGRADED_AFTER_FAILURES})`;
3798
+ this.detail = `${observation.detail} (probe ${this.failures} of ${DEGRADED_AFTER_FAILURES}).${this.transportEvidenceSuffix("so it is serving")}`;
3799
+ }
3800
+ break;
3801
+ case "unanswered":
3802
+ this.observeUnanswered(observation.detail);
3803
+ break;
3804
+ case "unattachable":
3805
+ if (this.state !== "attached" && this.state !== "replaced") {
3806
+ this.state = "wedged";
3705
3807
  }
3808
+ this.detail = observation.detail;
3706
3809
  break;
3707
3810
  case "pid-dead":
3708
- this.enterDetached("The daemon's process is gone.");
3811
+ this.enterDetached("The daemon's process is gone.", true);
3709
3812
  break;
3710
3813
  case "no-candidate":
3711
3814
  this.enterDetached(observation.detail);
@@ -3714,7 +3817,96 @@ class DaemonStateMachine {
3714
3817
  this.updatedAt = this.now();
3715
3818
  return this.state;
3716
3819
  }
3717
- enterDetached(detail) {
3820
+ /**
3821
+ * Fold a probe that ran out of budget with no answer.
3822
+ *
3823
+ * Three rules, in order, and each one exists because the alternative was
3824
+ * measured on the operator's machine:
3825
+ *
3826
+ * 1. A successful desktop request inside the evidence window means the
3827
+ * connection is ALIVE and the probe is the thing that is wrong. The state
3828
+ * does not move at all - not to `degraded`, because the app is talking to
3829
+ * its daemon right now - and the detail records both facts. This is the
3830
+ * rule that makes "no surface may say offline while the app is still
3831
+ * reading from it" true by construction rather than by each surface
3832
+ * remembering to check a second flag.
3833
+ * 2. Without that evidence, misses accumulate as `degraded` (usable), which
3834
+ * is all a 2 s budget on a busy box justifies.
3835
+ * 3. Only `UNANSWERED_BEFORE_DETACHED` consecutive misses detach, and the
3836
+ * detail says how many were counted, so the log line names the evidence.
3837
+ */
3838
+ observeUnanswered(detail) {
3839
+ this.unanswered += 1;
3840
+ if (this.transportEvidenceFresh()) {
3841
+ this.detail = `${detail}${this.transportEvidenceSuffix("so it is serving and the probe's budget is what expired")} (no answer to probe ${this.unanswered}).`;
3842
+ return;
3843
+ }
3844
+ if (this.unanswered >= UNANSWERED_BEFORE_DETACHED) {
3845
+ this.enterDetached(detail);
3846
+ return;
3847
+ }
3848
+ this.state = "degraded";
3849
+ this.detail = `${detail} (no answer to probe ${this.unanswered} of ${UNANSWERED_BEFORE_DETACHED})`;
3850
+ }
3851
+ /**
3852
+ * Record that a real request against the attached daemon was answered.
3853
+ *
3854
+ * WHY this is the state machine's business and not a caller's: the probe and
3855
+ * the transport disagree regularly on a box running long turns - the probe
3856
+ * has a 2 s budget, a session list has a 30 s one - and the one carrying the
3857
+ * user's data is the transport. Recording it here is what lets
3858
+ * {@link observeUnanswered} prefer it without every surface re-deriving the
3859
+ * same fact.
3860
+ *
3861
+ * It revives `degraded` (the daemon just answered, so the missed probes are
3862
+ * explained) and never a `detached` connection: coming back from those is
3863
+ * `recoverFromDetachment`'s job, because that path also has to re-prove the
3864
+ * daemon's IDENTITY before anything may use it.
3865
+ *
3866
+ * @returns whether the state actually moved, so the caller only pushes a
3867
+ * snapshot when there is something new to push.
3868
+ */
3869
+ recordTransportSuccess() {
3870
+ const moved = this.state === "degraded";
3871
+ this.lastTransportAt = this.now();
3872
+ this.unanswered = 0;
3873
+ if (moved) {
3874
+ this.failures = 0;
3875
+ this.state = this.identity ? "attached" : "connecting";
3876
+ 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.";
3877
+ }
3878
+ this.updatedAt = this.now();
3879
+ return moved;
3880
+ }
3881
+ /** Whether a successful request is recent enough to outrank a probe. */
3882
+ transportEvidenceFresh() {
3883
+ return this.lastTransportAt !== null && this.now() - this.lastTransportAt <= TRANSPORT_EVIDENCE_MS;
3884
+ }
3885
+ /**
3886
+ * The clause a sentence owes when THIS app's own request is the evidence
3887
+ * behind a connection the app has not detached from, or the empty string when
3888
+ * there is no such evidence.
3889
+ *
3890
+ * Shared rather than copied: three arms carry it (a probe budget that expired,
3891
+ * a refusal past the detach threshold, and a refusal short of it), and a third
3892
+ * copy is how one of them came to say "refused the connection" while the app
3893
+ * was still holding the connection open on the strength of its own answered
3894
+ * request.
3895
+ */
3896
+ transportEvidenceSuffix(tail) {
3897
+ if (!this.transportEvidenceFresh()) return "";
3898
+ const silentFor = Math.round(
3899
+ (this.now() - this.lastTransportAt) / 1e3
3900
+ );
3901
+ return ` The daemon answered a request ${silentFor}s ago, ${tail}.`;
3902
+ }
3903
+ enterDetached(detail, corroborated = false) {
3904
+ if (!corroborated && this.transportEvidenceFresh()) {
3905
+ this.failures = 0;
3906
+ this.state = "degraded";
3907
+ this.detail = `${detail}${this.transportEvidenceSuffix("so this app is not reporting it as gone")}`;
3908
+ return;
3909
+ }
3718
3910
  this.failures = DEGRADED_AFTER_FAILURES;
3719
3911
  this.state = "detached";
3720
3912
  if (this.detachedSince === null) this.detachedSince = this.now();
@@ -3770,6 +3962,13 @@ class DaemonStateMachine {
3770
3962
  desktopAvailable: this.desktopAvailable,
3771
3963
  failures: this.failures,
3772
3964
  capabilityStatus: this.capabilityStatus,
3965
+ /**
3966
+ * Carried so a surface can explain a `degraded` connection in the same
3967
+ * terms main used, rather than inventing a second reason for it. Both are
3968
+ * also what a reader needs to tell a busy daemon from a gone one.
3969
+ */
3970
+ unanswered: this.unanswered,
3971
+ lastTransportAt: this.lastTransportAt,
3773
3972
  detail: this.detail,
3774
3973
  updatedAt: this.updatedAt
3775
3974
  };
@@ -3902,6 +4101,24 @@ function recordAddress(record) {
3902
4101
  const host = record.host.includes(":") ? `[${record.host}]` : record.host;
3903
4102
  return `http://${host}:${record.port}`;
3904
4103
  }
4104
+ function classifyUnreachable(error) {
4105
+ const name = error?.name;
4106
+ if (name === "TimeoutError" || name === "AbortError") return "timeout";
4107
+ const code = error?.code ?? error?.cause?.code;
4108
+ switch (code) {
4109
+ case "ECONNREFUSED":
4110
+ case "EHOSTUNREACH":
4111
+ case "ENETUNREACH":
4112
+ case "ENETDOWN":
4113
+ case "EPIPE":
4114
+ return "refused";
4115
+ case "ENOTFOUND":
4116
+ case "EAI_AGAIN":
4117
+ return "unresolved";
4118
+ default:
4119
+ return "other";
4120
+ }
4121
+ }
3905
4122
  function readIdentity(payload) {
3906
4123
  if (typeof payload !== "object" || payload === null) return null;
3907
4124
  const result = payload.result;
@@ -3958,6 +4175,7 @@ async function probeIdentity(address, expectedInstanceId, options = {}) {
3958
4175
  } catch (error) {
3959
4176
  return {
3960
4177
  outcome: "unreachable",
4178
+ cause: classifyUnreachable(error),
3961
4179
  detail: error instanceof Error ? error.message : String(error)
3962
4180
  };
3963
4181
  }
@@ -4143,14 +4361,16 @@ async function probeUnidentified(address, options = {}) {
4143
4361
  });
4144
4362
  if (response.status !== 200) {
4145
4363
  return {
4146
- reason: "not-a-daemon",
4364
+ reason: "unready-answer",
4147
4365
  detail: `${HEALTH_PATH$1} answered ${response.status} and no record describes this address`
4148
4366
  };
4149
4367
  }
4150
4368
  const payload = await response.json().catch(() => null);
4151
- if (readIdentity(payload)) {
4369
+ const identity = readIdentity(payload);
4370
+ if (identity) {
4152
4371
  return {
4153
4372
  reason: "identity-mismatch",
4373
+ identity,
4154
4374
  detail: `a daemon answered but no serve record describes ${address}, so it cannot be proven to be the one this app found`
4155
4375
  };
4156
4376
  }
@@ -4161,6 +4381,7 @@ async function probeUnidentified(address, options = {}) {
4161
4381
  } catch (error) {
4162
4382
  return {
4163
4383
  reason: "unreachable",
4384
+ cause: classifyUnreachable(error),
4164
4385
  detail: error instanceof Error ? error.message : String(error)
4165
4386
  };
4166
4387
  }
@@ -5189,9 +5410,15 @@ const SHUTDOWN_TIMEOUT_DEFAULTS = {
5189
5410
  };
5190
5411
  const OWNED_STOP_WORST_MS = SHUTDOWN_TIMEOUT_DEFAULTS.normal + SHUTDOWN_TIMEOUT_DEFAULTS.force;
5191
5412
  const READINESS_POLL_INTERVAL_MS = 1e3;
5413
+ const DESKTOP_TOKEN_FILENAME = "desktop-token";
5192
5414
  const execPromise = node_util.promisify(node_child_process.exec);
5193
5415
  const ENV_VAR_REGEX = /^([^=]+)=(.*)$/;
5194
5416
  const LINE_BREAK = /\r?\n/;
5417
+ function classifyDesktopAnswer(status2) {
5418
+ if (status2 >= 200 && status2 < 300) return "accepted";
5419
+ if (status2 === 401 || status2 === 403) return "refused";
5420
+ return "unusable";
5421
+ }
5195
5422
  class BackendServiceManager {
5196
5423
  process = null;
5197
5424
  isRunning = false;
@@ -5215,6 +5442,19 @@ class BackendServiceManager {
5215
5442
  remoteConfigured = false;
5216
5443
  /** A failed probe or unreadable record is not evidence that spawning is safe. */
5217
5444
  discoveryBlocksSpawn = false;
5445
+ /**
5446
+ * The address of a daemon that ANSWERED this app's desktop read without
5447
+ * refusing its credential, and the status it answered, from the last
5448
+ * discovery sweep.
5449
+ *
5450
+ * Non-null means the address is OCCUPIED by a daemon this app may not attach
5451
+ * to *yet*: `start()`'s no-spawn report has to publish that as a running
5452
+ * daemon, because the alternative it would otherwise reach - `no-candidate` -
5453
+ * renders as offline, which is the one sentence this whole change exists to
5454
+ * stop saying about a server that answers (#1170: an unreadable session store
5455
+ * answers 503 instead of an empty 200).
5456
+ */
5457
+ answeredButUnusable = null;
5218
5458
  /**
5219
5459
  * Records discovery found alive but unresponsive (pid alive, heartbeat
5220
5460
  * stopped). They are why no candidate exists AND why spawning is forbidden,
@@ -5226,6 +5466,14 @@ class BackendServiceManager {
5226
5466
  nextRecoveryAt = 0;
5227
5467
  appDataPath = electron.app.getPath("userData");
5228
5468
  venvPath;
5469
+ /**
5470
+ * Why the last `/health` read did not return 200, for the callers that must
5471
+ * tell a refusal from an expired budget: `null` when it was not a transport
5472
+ * failure at all (the address answered, with a status this path does not
5473
+ * accept).
5474
+ */
5475
+ lastHealthFailure = null;
5476
+ /** The one probe loop's interval handle. */
5229
5477
  healthCheckInterval = null;
5230
5478
  /** The only process this manager may terminate. Null means it owns nothing,
5231
5479
  * which is a reason to report "nothing to stop" - never to go looking. */
@@ -5255,6 +5503,29 @@ class BackendServiceManager {
5255
5503
  shellEnv = {};
5256
5504
  // External/dev backends may be explicitly paired through main's environment.
5257
5505
  // Managed starts always rotate this; it is never exposed by preload or logs.
5506
+ /**
5507
+ * The credential this app holds for its OWN daemon.
5508
+ *
5509
+ * Read from disk at construction, and REPLACED by a freshly minted, persisted
5510
+ * one on every managed start. Those are two halves of one rule:
5511
+ *
5512
+ * - the read is what makes a daemon the previous run left running
5513
+ * attachable. The app deliberately leaves its daemon serving when it
5514
+ * attached to an external backend, and a daemon it spawned is
5515
+ * env-governed from the spawn (its record publishes `claim_key: ""`), so
5516
+ * the ONLY credential that can ever open it is the token its spawner held.
5517
+ * A launch that starts with no token there declines the daemon it owns and
5518
+ * starts a second one onto its port - measured on the operator's machine as
5519
+ * the 07:47 -> 09:17 sequence, ending in `[Errno 48] Address already in
5520
+ * use`;
5521
+ * - the write is why the token still changes when the app REPLACES a daemon
5522
+ * in place: the relay's cache key is this value (see `getStreamRelay`), and
5523
+ * a token that survived a restart would leave a subscription pointed at a
5524
+ * daemon this app had already stopped.
5525
+ *
5526
+ * The environment still wins, because an explicitly paired app is a deliberate
5527
+ * configuration and not a cache to be second-guessed.
5528
+ */
5258
5529
  desktopToken = process.env.LOCAL_OPERATOR_DESKTOP_TOKEN || null;
5259
5530
  /** Authenticated SSE relay for canonical session events. Recreated when the
5260
5531
  * backend URL rotates (external-backend discovery) or the desktop token does
@@ -5447,15 +5718,51 @@ class BackendServiceManager {
5447
5718
  this.feedRelay?.watchState(stateObserver);
5448
5719
  }
5449
5720
  requestDesktop(input) {
5450
- return requestDesktop(input, this.backendUrl, this.desktopToken);
5721
+ return requestDesktopOutcome(
5722
+ input,
5723
+ this.backendUrl,
5724
+ this.desktopToken
5725
+ ).then(({ response, answered }) => {
5726
+ if (answered) this.noteTransportAnswer();
5727
+ return response;
5728
+ });
5451
5729
  }
5452
5730
  requestDesktopMedia(input, bytes) {
5453
- return requestDesktopMedia(
5731
+ return requestDesktopMediaOutcome(
5454
5732
  input,
5455
5733
  bytes,
5456
5734
  this.backendUrl,
5457
5735
  this.desktopToken
5458
- );
5736
+ ).then(({ response, answered }) => {
5737
+ if (answered) this.noteTransportAnswer();
5738
+ return response;
5739
+ });
5740
+ }
5741
+ /**
5742
+ * Tell the state machine that this app's own request reached the daemon.
5743
+ *
5744
+ * `answered` is the TRANSPORT's verdict, not the promise's: every failure the
5745
+ * transport can produce resolves rather than throws, so `.then()` alone fires
5746
+ * for a refused socket, for a request this app never sent (no token, oversize,
5747
+ * an invalid op), and for one whose budget expired. Gating on it here is the
5748
+ * fix for review round 1's F-1, where an unearned stamp held the state at
5749
+ * `degraded` - which is not a state `checkBackendHealth` recovers from, so a
5750
+ * genuinely gone daemon could never be reported as gone.
5751
+ *
5752
+ * WHY this still counts EVERY answer, including a gated route's `401`/`403`:
5753
+ * the capability rules elsewhere in this file say a refusal is not a LIVENESS
5754
+ * signal, and that is about not calling it "connected". Turned around, a
5755
+ * refusal is the strongest liveness evidence this app has - a process read the
5756
+ * request and wrote a status line - which is exactly what a probe that ran out
5757
+ * of its 2 s budget failed to establish. Without this, one long agent turn was
5758
+ * enough to detach the connection and disable every read the daemon was still
5759
+ * answering.
5760
+ *
5761
+ * Only a state that actually moves pushes a snapshot: this runs on every
5762
+ * desktop call, and one IPC wake-up per request would be worse than the bug.
5763
+ */
5764
+ noteTransportAnswer() {
5765
+ if (this.daemonState.recordTransportSuccess()) this.notifyStatus();
5459
5766
  }
5460
5767
  isAppClosing = false;
5461
5768
  // Flag to track when the app is being closed
@@ -5494,6 +5801,7 @@ class BackendServiceManager {
5494
5801
  packaged: electron.app.isPackaged
5495
5802
  });
5496
5803
  this.loadShellEnvironment();
5804
+ this.desktopToken = this.desktopToken || this.persistedDesktopToken();
5497
5805
  logger.info(
5498
5806
  `Backend Service Manager initialized. May spawn or kill: ${this.managerMaySpawn}`,
5499
5807
  LogFileType.BACKEND
@@ -5768,6 +6076,11 @@ class BackendServiceManager {
5768
6076
  kind: "heartbeat-stale",
5769
6077
  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
6078
  });
6079
+ } else if (this.answeredButUnusable) {
6080
+ this.daemonState.observe({
6081
+ kind: "unattachable",
6082
+ 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.`
6083
+ });
5771
6084
  } else {
5772
6085
  this.daemonState.observe({
5773
6086
  kind: "no-candidate",
@@ -5914,6 +6227,7 @@ class BackendServiceManager {
5914
6227
  async adoptFirstUsableDaemon() {
5915
6228
  if (this.isAppClosing) return false;
5916
6229
  this.discoveryWedged = [];
6230
+ this.answeredButUnusable = null;
5917
6231
  if (this.remoteConfigured) {
5918
6232
  this.discoveryBlocksSpawn = true;
5919
6233
  return await this.legacyFixedPortAdoption();
@@ -5996,19 +6310,40 @@ class BackendServiceManager {
5996
6310
  return false;
5997
6311
  }
5998
6312
  }
5999
- if (!await this.authenticatesAgainst(candidate.address, token)) {
6313
+ const probe = await this.probeCandidate(candidate.address, token);
6314
+ if (probe.verdict === "refused") {
6000
6315
  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).`,
6316
+ `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
6317
  LogFileType.BACKEND
6003
6318
  );
6004
6319
  this.daemonState.observe({
6005
6320
  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.`
6321
+ status: probe.status,
6322
+ detail: `A daemon is running at ${candidate.address}, but it refused this app's credential for its desktop plane (HTTP ${probe.status}).`
6008
6323
  });
6009
6324
  this.notifyStatus();
6010
6325
  return false;
6011
6326
  }
6327
+ if (probe.verdict === "unusable") {
6328
+ logger.info(
6329
+ `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.`,
6330
+ LogFileType.BACKEND
6331
+ );
6332
+ this.discoveryBlocksSpawn = true;
6333
+ this.answeredButUnusable = {
6334
+ address: candidate.address,
6335
+ status: probe.status
6336
+ };
6337
+ this.daemonState.observe({
6338
+ kind: "unattachable",
6339
+ 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.`
6340
+ });
6341
+ this.notifyStatus();
6342
+ return false;
6343
+ }
6344
+ if (probe.verdict === "unreachable") {
6345
+ return false;
6346
+ }
6012
6347
  await this.attachTo(candidate, token);
6013
6348
  return true;
6014
6349
  }
@@ -6024,16 +6359,17 @@ class BackendServiceManager {
6024
6359
  * `this.desktopToken` because this runs BEFORE adoption: the candidate must
6025
6360
  * be proved usable without the manager having committed to it.
6026
6361
  */
6027
- async authenticatesAgainst(address, token) {
6362
+ async probeCandidate(address, token) {
6028
6363
  try {
6029
6364
  const result = await requestDesktop(
6030
6365
  { op: "sessions.list", limit: 1 },
6031
6366
  address,
6032
6367
  token
6033
6368
  );
6034
- return result.status >= 200 && result.status < 300;
6369
+ const verdict = classifyDesktopAnswer(result.status);
6370
+ return verdict === "accepted" ? { verdict } : { verdict, status: result.status };
6035
6371
  } catch {
6036
- return false;
6372
+ return { verdict: "unreachable" };
6037
6373
  }
6038
6374
  }
6039
6375
  /**
@@ -6139,7 +6475,7 @@ class BackendServiceManager {
6139
6475
  const payload = await response.json().catch(() => null);
6140
6476
  if (!await this.authenticatesAgainstBackend()) {
6141
6477
  logger.info(
6142
- "A backend answered health but refused this app's desktop token; not adopting it.",
6478
+ "A backend answered health but did not accept this app's desktop credential; not adopting it.",
6143
6479
  LogFileType.BACKEND
6144
6480
  );
6145
6481
  return false;
@@ -6187,7 +6523,7 @@ class BackendServiceManager {
6187
6523
  op: "sessions.list",
6188
6524
  limit: 1
6189
6525
  });
6190
- return result.status >= 200 && result.status < 300;
6526
+ return classifyDesktopAnswer(result.status) === "accepted";
6191
6527
  } catch {
6192
6528
  return false;
6193
6529
  }
@@ -6268,7 +6604,13 @@ class BackendServiceManager {
6268
6604
  this.startHealthCheck();
6269
6605
  return false;
6270
6606
  }
6271
- this.desktopToken = node_crypto.randomBytes(32).toString("hex");
6607
+ const occupancy = await this.configuredOriginOccupancy();
6608
+ if (occupancy) {
6609
+ this.observeOriginOccupancy(occupancy);
6610
+ this.startHealthCheck();
6611
+ return false;
6612
+ }
6613
+ this.desktopToken = this.mintDesktopToken();
6272
6614
  let captured = null;
6273
6615
  try {
6274
6616
  this.venvPath = managedVenvPath({
@@ -6700,11 +7042,142 @@ class BackendServiceManager {
6700
7042
  `Backend health check response status: ${response.status}`,
6701
7043
  LogFileType.BACKEND
6702
7044
  );
7045
+ this.lastHealthFailure = null;
6703
7046
  return response.ok;
6704
- } catch (_error) {
7047
+ } catch (error) {
7048
+ this.lastHealthFailure = classifyUnreachable(error);
6705
7049
  return false;
6706
7050
  }
6707
7051
  }
7052
+ /**
7053
+ * What is answering the address this app is configured to serve on, if
7054
+ * anything that stops it starting a daemon there.
7055
+ *
7056
+ * `null` means "start it": either the socket was refused (the one answer that
7057
+ * proves a port free), or something answered 200 without identifying as a Local
7058
+ * Operator daemon. The second arm is deliberate and narrow - see the spawn gate
7059
+ * in `startOwned()` - and the observation is logged either way. An answer whose
7060
+ * STATUS is not 200 is not in that arm: it is an occupant (review round 1,
7061
+ * F-2), and it returns a `silent` occupancy below rather than a licence to
7062
+ * spawn.
7063
+ */
7064
+ async configuredOriginOccupancy() {
7065
+ const probe = await probeUnidentified(this.backendUrl, {
7066
+ timeoutMs: PROBE_TIMEOUT_MS$1
7067
+ });
7068
+ switch (probe.reason) {
7069
+ case "identity-mismatch":
7070
+ return {
7071
+ kind: "daemon",
7072
+ pid: probe.identity?.pid ?? null,
7073
+ version: probe.identity?.version ?? "",
7074
+ detail: probe.detail
7075
+ };
7076
+ /*
7077
+ * A status that is not 200 is an OCCUPANT, not a licence to spawn, and it
7078
+ * is handled here rather than by the `default` arm below so the decision is
7079
+ * the one a reader finds rather than the one the switch happens to fall
7080
+ * into (review round 1, F-2). It covers a Local Operator daemon starting up,
7081
+ * unhealthy or shutting down, and a proxy that fronts one and answers 5xx:
7082
+ * in every one of them the socket is bound, so a child spawned onto it dies
7083
+ * on `[Errno 48]` - after `mintDesktopToken()` has already overwritten the
7084
+ * credential for the daemon actually serving there. The answer does not
7085
+ * prove a daemon this app may attach to, so the gate declines to start one
7086
+ * and keeps probing; that is the safe direction, because the failure mode of
7087
+ * the other one is unrecoverable loss of the pairing token.
7088
+ */
7089
+ case "unready-answer":
7090
+ return {
7091
+ kind: "silent",
7092
+ cause: "other",
7093
+ detail: probe.detail
7094
+ };
7095
+ case "not-a-daemon":
7096
+ logger.info(
7097
+ `${this.backendUrl}${HEALTH_PATH$1} answered and is not a Local Operator daemon (${probe.detail}); starting a daemon anyway, as this app always has.`,
7098
+ LogFileType.BACKEND
7099
+ );
7100
+ return null;
7101
+ case "unreachable":
7102
+ return probe.cause === "refused" ? null : {
7103
+ kind: "silent",
7104
+ cause: probe.cause ?? "other",
7105
+ detail: probe.detail
7106
+ };
7107
+ default:
7108
+ return {
7109
+ kind: "silent",
7110
+ cause: "other",
7111
+ detail: probe.detail
7112
+ };
7113
+ }
7114
+ }
7115
+ /**
7116
+ * Publish what the spawn gate saw, in the vocabulary the copy already uses.
7117
+ *
7118
+ * Two outcomes reach here and each gets the state that names it: a Local
7119
+ * Operator daemon this app may not drive is `unattachable` (state `wedged` - a
7120
+ * server IS running and this app did not attach to it, no banner claiming it is
7121
+ * offline), and an address that did not answer in time is `unanswered` -
7122
+ * `degraded`, usable, no banner, and still counted, because a budget that
7123
+ * expired is not evidence of absence either way.
7124
+ */
7125
+ observeOriginOccupancy(occupancy) {
7126
+ const daemonLine = occupancy.kind === "daemon";
7127
+ 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`;
7128
+ const detail = `${what}. It keeps probing for a server it can open.`;
7129
+ this.daemonState.observe(
7130
+ occupancy.kind === "silent" ? { kind: "unanswered", cause: occupancy.cause, detail } : { kind: "unattachable", detail }
7131
+ );
7132
+ logger.info(`Not spawning a daemon: ${detail}`, LogFileType.BACKEND);
7133
+ this.notifyStatus();
7134
+ }
7135
+ /**
7136
+ * The token governing the daemon this app spawned in a PREVIOUS run, read from
7137
+ * the 0600 file beside the app's other per-user state, or null.
7138
+ *
7139
+ * Read-only on purpose: a launch that has not spawned anything yet must not
7140
+ * create a credential, or "this app holds no token" (a first run, and the
7141
+ * honest reason to decline a daemon it cannot open) would become unreachable.
7142
+ */
7143
+ persistedDesktopToken() {
7144
+ try {
7145
+ const stored = fs.readFileSync(this.desktopTokenFile(), "utf8").trim();
7146
+ return stored || null;
7147
+ } catch {
7148
+ return null;
7149
+ }
7150
+ }
7151
+ /**
7152
+ * Mint the token for a daemon this app is about to spawn, and persist it so the
7153
+ * next launch can re-attach to that daemon instead of starting a second one.
7154
+ *
7155
+ * A token that cannot be written still governs this run's child - failing to
7156
+ * start over a cache file would be the worse trade - and the log line names
7157
+ * the cost, which is the stranded daemon next launch.
7158
+ */
7159
+ mintDesktopToken() {
7160
+ const token = node_crypto.randomBytes(32).toString("hex");
7161
+ try {
7162
+ const file = this.desktopTokenFile();
7163
+ fs.mkdirSync(path$1.dirname(file), { recursive: true, mode: 448 });
7164
+ fs.writeFileSync(file, token, { mode: 384 });
7165
+ } catch (error) {
7166
+ logger.warn(
7167
+ "Could not persist the desktop token; a daemon spawned now will not be re-attachable after a restart.",
7168
+ LogFileType.BACKEND,
7169
+ error
7170
+ );
7171
+ }
7172
+ return token;
7173
+ }
7174
+ desktopTokenPath = null;
7175
+ desktopTokenFile() {
7176
+ if (this.desktopTokenPath === null) {
7177
+ this.desktopTokenPath = path$1.join(this.appDataPath, DESKTOP_TOKEN_FILENAME);
7178
+ }
7179
+ return this.desktopTokenPath;
7180
+ }
6708
7181
  /**
6709
7182
  * One probe of the daemon this app is attached to.
6710
7183
  *
@@ -6722,9 +7195,12 @@ class BackendServiceManager {
6722
7195
  const expected = this.daemonState.expectedInstanceId();
6723
7196
  if (!expected) {
6724
7197
  const ok = await this.checkHealth();
6725
- return ok ? { kind: "identified" } : {
6726
- kind: "failed",
6727
- detail: `No answer from ${this.backendUrl}${HEALTH_PATH$1}`
7198
+ if (ok) return { kind: "identified" };
7199
+ const failure = this.lastHealthFailure;
7200
+ return {
7201
+ kind: failure === null || failure === "refused" ? "failed" : "unanswered",
7202
+ cause: failure ?? "other",
7203
+ 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
7204
  };
6729
7205
  }
6730
7206
  const probe = await probeIdentity(this.backendUrl, expected, {
@@ -6747,8 +7223,12 @@ class BackendServiceManager {
6747
7223
  detail: `${this.backendUrl} answered but is not a Local Operator daemon (${probe.detail})`
6748
7224
  };
6749
7225
  case "unreachable":
6750
- return {
7226
+ return probe.cause === "refused" ? {
6751
7227
  kind: "failed",
7228
+ detail: `${this.backendUrl} refused the connection (${probe.detail})`
7229
+ } : {
7230
+ kind: "unanswered",
7231
+ cause: probe.cause,
6752
7232
  detail: `${this.backendUrl} did not answer (${probe.detail})`
6753
7233
  };
6754
7234
  }
@@ -10720,11 +11200,6 @@ class BrowserHost {
10720
11200
  return AWAIT_SLICE_MS;
10721
11201
  }
10722
11202
  // ---- operations the app's own chrome uses (design 11.2, 6.1) -------------
10723
- /**
10724
- * The projection the renderer renders: the tab strip, the URL bar and the
10725
- * per-tab ownership markers. It never carries a nonce — the renderer is not an
10726
- * agent and has no reason to hold a capability (design 11.7).
10727
- */
10728
11203
  /**
10729
11204
  * Record a main-frame load failure for a tab.
10730
11205
  *
@@ -10778,6 +11253,32 @@ class BrowserHost {
10778
11253
  if (!record || record.view.webContents.isDestroyed()) return false;
10779
11254
  return record.view.webContents.isLoading();
10780
11255
  }
11256
+ /**
11257
+ * The projection the renderer renders: the tab strip, the URL bar and the
11258
+ * per-tab ownership markers. It never carries a nonce — the renderer is not an
11259
+ * agent and has no reason to hold a capability (design 11.7).
11260
+ *
11261
+ * `sessionId` IS projected, and the nonce is not, and the difference is the
11262
+ * whole of the rule rather than an inconsistency to iron out. A conversation's
11263
+ * pane has to answer "which of these tabs belong to THIS conversation"
11264
+ * (docs/design/browser-approval-ux.md 7.2), and the registry already answers it
11265
+ * with one field: an agent tab carries the session that created it
11266
+ * (`registry.ts:231`) and a handed-over user tab carries the session it was
11267
+ * handed to (`:371`), so one field answers both questions. That field is a
11268
+ * NAME — the same id the app already puts in its own chat routes, and the same
11269
+ * value `handOver` matches against — while the NONCE is the capability that
11270
+ * lets its holder DRIVE the tab. Projecting the name tells the user which
11271
+ * conversation a tab belongs to; projecting the nonce would hand the renderer
11272
+ * (and anything that can read the renderer's IPC) the ability to act as an
11273
+ * agent, which is exactly what `mayDrive` is the gate for. So the name
11274
+ * travels and the capability does not — `registry.snapshot()` carries it for the
11275
+ * same reason, and THIS projection is the one the UI reads.
11276
+ *
11277
+ * It sits on this member rather than where it was written: it was left as a
11278
+ * detached block after this class's section rule, with `recordLoadFailure`'s own
11279
+ * doc directly beneath it, so the most load-bearing prose in the main-process
11280
+ * change documented nothing (review round 1, NIT B).
11281
+ */
10781
11282
  chromeState() {
10782
11283
  const activeRecord = this.registry.activeTab;
10783
11284
  const active = activeRecord && !activeRecord.view.webContents.isDestroyed() ? activeRecord : null;
@@ -10788,6 +11289,13 @@ class BrowserHost {
10788
11289
  title: this.titleForChrome(entry.title) || "New tab",
10789
11290
  url: entry.url,
10790
11291
  owner: entry.owner,
11292
+ // WHICH CONVERSATION THIS TAB BELONGS TO, and the ONLY new field this
11293
+ // change adds to the wire. `null` is a real and common value — a restored
11294
+ // tab is nobody's (`registry.ts:231`), a user tab that was never handed
11295
+ // over is nobody's, and a tab handed back goes back to `null` (`:386`) —
11296
+ // and it means "not any conversation's", which is why a conversation's
11297
+ // scope shows it under no scope but `"all"` (spec 7.2).
11298
+ sessionId: entry.sessionId,
10791
11299
  active: entry.active,
10792
11300
  restored: entry.restored,
10793
11301
  handedOver: entry.handedTo !== null,
@@ -16752,10 +17260,10 @@ ${heal.removed.join("\n")}`,
16752
17260
  LogFileType.UPDATE_SERVICE
16753
17261
  );
16754
17262
  if (!silent) {
16755
- this.sendToRenderer(
16756
- "backend-update-error",
16757
- "Unable to determine backend version."
16758
- );
17263
+ this.sendToRenderer("backend-update-error", {
17264
+ message: "Unable to determine backend version.",
17265
+ phase: "check"
17266
+ });
16759
17267
  }
16760
17268
  return { status: "unavailable", info: null };
16761
17269
  }
@@ -16765,10 +17273,10 @@ ${heal.removed.join("\n")}`,
16765
17273
  LogFileType.UPDATE_SERVICE
16766
17274
  );
16767
17275
  if (!silent) {
16768
- this.sendToRenderer(
16769
- "backend-update-error",
16770
- "The installed server version could not be determined, so no update was offered. Restart the app to try again."
16771
- );
17276
+ this.sendToRenderer("backend-update-error", {
17277
+ message: "The installed server version could not be determined, so no update was offered. Restart the app to try again.",
17278
+ phase: "check"
17279
+ });
16772
17280
  }
16773
17281
  return { status: "unavailable", info: null };
16774
17282
  }
@@ -16784,10 +17292,10 @@ ${heal.removed.join("\n")}`,
16784
17292
  LogFileType.UPDATE_SERVICE
16785
17293
  );
16786
17294
  if (!silent) {
16787
- this.sendToRenderer(
16788
- "backend-update-error",
16789
- "Unable to determine backend version."
16790
- );
17295
+ this.sendToRenderer("backend-update-error", {
17296
+ message: "Unable to determine backend version.",
17297
+ phase: "check"
17298
+ });
16791
17299
  }
16792
17300
  return { status: "unavailable", info: null };
16793
17301
  }
@@ -16843,10 +17351,10 @@ ${heal.removed.join("\n")}`,
16843
17351
  error
16844
17352
  );
16845
17353
  if (!silent && this.mainWindow && !this.mainWindow.isDestroyed() && this.mainWindow.webContents && !this.mainWindow.webContents.isDestroyed()) {
16846
- this.mainWindow.webContents.send(
16847
- "backend-update-error",
16848
- error.message
16849
- );
17354
+ this.mainWindow.webContents.send("backend-update-error", {
17355
+ message: error.message,
17356
+ phase: "check"
17357
+ });
16850
17358
  }
16851
17359
  return { status: "unavailable", info: null };
16852
17360
  }
@@ -16933,10 +17441,10 @@ ${heal.removed.join("\n")}`,
16933
17441
  LogFileType.UPDATE_SERVICE
16934
17442
  );
16935
17443
  if (this.mainWindow && !this.mainWindow.isDestroyed() && this.mainWindow.webContents && !this.mainWindow.webContents.isDestroyed()) {
16936
- this.mainWindow.webContents.send(
16937
- "backend-update-error",
16938
- "No backend service reference available, cannot update."
16939
- );
17444
+ this.mainWindow.webContents.send("backend-update-error", {
17445
+ message: "No backend service reference available, cannot update.",
17446
+ phase: "update"
17447
+ });
16940
17448
  }
16941
17449
  return false;
16942
17450
  }
@@ -16948,10 +17456,10 @@ ${heal.removed.join("\n")}`,
16948
17456
  LogFileType.UPDATE_SERVICE
16949
17457
  );
16950
17458
  if (this.mainWindow && !this.mainWindow.isDestroyed() && this.mainWindow.webContents && !this.mainWindow.webContents.isDestroyed()) {
16951
- this.mainWindow.webContents.send(
16952
- "backend-update-error",
16953
- "No python server is available, nothing to update."
16954
- );
17459
+ this.mainWindow.webContents.send("backend-update-error", {
17460
+ message: "No python server is available, nothing to update.",
17461
+ phase: "update"
17462
+ });
16955
17463
  }
16956
17464
  return false;
16957
17465
  case LocalOperatorStartupMode.EXISTING_SERVER:
@@ -16984,6 +17492,10 @@ ${heal.removed.join("\n")}`,
16984
17492
  `Unknown startup mode: ${startupMode}`,
16985
17493
  LogFileType.UPDATE_SERVICE
16986
17494
  );
17495
+ this.sendToRenderer("backend-update-error", {
17496
+ message: "The app could not tell how this server was started, so it did not update it. See the update service log.",
17497
+ phase: "update"
17498
+ });
16987
17499
  return false;
16988
17500
  }
16989
17501
  if (!this.backendService.isUsingExternalBackend()) {
@@ -17014,13 +17526,13 @@ ${heal.removed.join("\n")}`,
17014
17526
  LogFileType.UPDATE_SERVICE
17015
17527
  );
17016
17528
  await this.restartBackendAfterFailedUpgrade();
17017
- this.sendToRenderer(
17018
- "backend-update-error",
17019
- "The bundled server's Python environment could not be found, so the update did not run. Please reinstall the application."
17020
- );
17529
+ this.sendToRenderer("backend-update-error", {
17530
+ message: "The bundled server's Python environment could not be found, so the update did not run. Please reinstall the application.",
17531
+ phase: "update"
17532
+ });
17021
17533
  return false;
17022
17534
  }
17023
- const pip = buildPipUpgradeCommand(pythonPath2);
17535
+ const pip = buildPipUpgradeCommand(pythonPath2, targetVersion ?? null);
17024
17536
  const versionBefore = await this.readBundledBackendVersion(pythonPath2);
17025
17537
  logger.info(
17026
17538
  `Executing pip command: ${pip.display}`,
@@ -17054,10 +17566,10 @@ ${pipRun.stderr.trim()}`,
17054
17566
  LogFileType.UPDATE_SERVICE
17055
17567
  );
17056
17568
  const serverIsBack = await this.restartBackendAfterFailedUpgrade();
17057
- this.sendToRenderer(
17058
- "backend-update-error",
17059
- pipRun.exitCode !== 0 ? serverIsBack ? "The server update failed to install. The previously installed server is running again; see the update service log for pip's output." : "The server update failed to install and the server did not come back up. Restart Local Operator, and see the update service log for pip's output." : `The server update ran but the installed version did not change (still ${versionAfter ?? "unknown"}), so it is reported as failed.`
17060
- );
17569
+ this.sendToRenderer("backend-update-error", {
17570
+ message: pipRun.exitCode !== 0 ? serverIsBack ? "The server update failed to install. The previously installed server is running again; see the update service log for pip's output." : "The server update failed to install and the server did not come back up. Restart Local Operator, and see the update service log for pip's output." : `The server update to ${targetVersion ?? "the new release"} did not take effect: the server is still on ${versionAfter ?? "its previous version"}. See the update service log for pip's output, then try again.`,
17571
+ phase: "update"
17572
+ });
17061
17573
  return false;
17062
17574
  }
17063
17575
  logger.info(
@@ -17093,10 +17605,10 @@ ${pipRun.stderr.trim()}`,
17093
17605
  LogFileType.UPDATE_SERVICE
17094
17606
  );
17095
17607
  if (this.mainWindow) {
17096
- this.mainWindow.webContents.send(
17097
- "backend-update-error",
17098
- "Backend was updated but failed to restart properly. Please restart the application."
17099
- );
17608
+ this.mainWindow.webContents.send("backend-update-error", {
17609
+ message: "Backend was updated but failed to restart properly. Please restart the application.",
17610
+ phase: "update"
17611
+ });
17100
17612
  }
17101
17613
  return false;
17102
17614
  }
@@ -17108,10 +17620,10 @@ ${pipRun.stderr.trim()}`,
17108
17620
  );
17109
17621
  this.backendService.setAutoUpdating(false);
17110
17622
  if (this.mainWindow) {
17111
- this.mainWindow.webContents.send(
17112
- "backend-update-error",
17113
- "Backend was updated but failed to restart. Please restart the application."
17114
- );
17623
+ this.mainWindow.webContents.send("backend-update-error", {
17624
+ message: "Backend was updated but failed to restart. Please restart the application.",
17625
+ phase: "update"
17626
+ });
17115
17627
  }
17116
17628
  return false;
17117
17629
  }
@@ -17123,10 +17635,10 @@ ${pipRun.stderr.trim()}`,
17123
17635
  `Backend did not report version ${targetVersion ?? "the new one"} after the update`,
17124
17636
  LogFileType.UPDATE_SERVICE
17125
17637
  );
17126
- this.sendToRenderer(
17127
- "backend-update-error",
17128
- `The server restarted but did not report ${targetVersion ? `version ${targetVersion}` : "the updated version"}. Check the update service log, then try again.`
17129
- );
17638
+ this.sendToRenderer("backend-update-error", {
17639
+ message: `The server restarted but did not report ${targetVersion ? `version ${targetVersion}` : "the updated version"}. Check the update service log, then try again.`,
17640
+ phase: "update"
17641
+ });
17130
17642
  return false;
17131
17643
  }
17132
17644
  logger.info(
@@ -17163,10 +17675,10 @@ ${pipRun.stderr.trim()}`,
17163
17675
  }
17164
17676
  }
17165
17677
  if (this.mainWindow && !this.mainWindow.isDestroyed() && this.mainWindow.webContents && !this.mainWindow.webContents.isDestroyed()) {
17166
- this.mainWindow.webContents.send(
17167
- "backend-update-error",
17168
- error.message
17169
- );
17678
+ this.mainWindow.webContents.send("backend-update-error", {
17679
+ message: error.message,
17680
+ phase: "update"
17681
+ });
17170
17682
  }
17171
17683
  return false;
17172
17684
  }