local-operator-ui 0.25.9 → 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 (71) hide show
  1. package/out/main/index.js +661 -118
  2. package/out/renderer/assets/{_basePickBy-DkEkh-PB.js → _basePickBy--Yue-XLQ.js} +1 -1
  3. package/out/renderer/assets/{_baseUniq-BoIvPFtq.js → _baseUniq-CX84XXMp.js} +1 -1
  4. package/out/renderer/assets/{agent-details-page-D42MOCs7.js → agent-details-page-BjoWEolx.js} +1 -1
  5. package/out/renderer/assets/{agent-hub-page-CdZeFDad.js → agent-hub-page-BuMeC5YA.js} +1 -1
  6. package/out/renderer/assets/{agents-page-DIxM0s-X.js → agents-page-DMGAWpXp.js} +2 -2
  7. package/out/renderer/assets/{arc-4ngVrwIY.js → arc-CR3HNaPp.js} +1 -1
  8. package/out/renderer/assets/{architectureDiagram-IEHRJDOE-CAnb4M8x.js → architectureDiagram-IEHRJDOE-DLZPKw96.js} +1 -1
  9. package/out/renderer/assets/{arrow-left-CtyOtdQO.js → arrow-left-CIBQH5Fe.js} +1 -1
  10. package/out/renderer/assets/{blockDiagram-JOT3LUYC-kESXFLy1.js → blockDiagram-JOT3LUYC-BGnNcmsO.js} +1 -1
  11. package/out/renderer/assets/browser-page-B02RKtfN.js +1 -0
  12. package/out/renderer/assets/{c4Diagram-VJAJSXHY-BLvRcM3G.js → c4Diagram-VJAJSXHY-Bu96GUil.js} +1 -1
  13. package/out/renderer/assets/channel-Dekw979G.js +1 -0
  14. package/out/renderer/assets/{chunk-4BMEZGHF-BzyGtS7u.js → chunk-4BMEZGHF-Bc2IBqRl.js} +1 -1
  15. package/out/renderer/assets/{chunk-A2AXSNBT-BEzWu4yw.js → chunk-A2AXSNBT-C7PMbmgd.js} +1 -1
  16. package/out/renderer/assets/{chunk-AEK57VVT-CSE7vmLv.js → chunk-AEK57VVT-Dy6-mgEo.js} +1 -1
  17. package/out/renderer/assets/{chunk-D6G4REZN-CDzazU3_.js → chunk-D6G4REZN-cBOMihDM.js} +1 -1
  18. package/out/renderer/assets/{chunk-RZ5BOZE2-C0Bt3Utz.js → chunk-RZ5BOZE2-DtZPVT7y.js} +1 -1
  19. package/out/renderer/assets/{chunk-XZIHB7SX-Bbr1A6Pp.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-Crjt0Ets.js → compact-pagination-F8MDF-Kl.js} +1 -1
  24. package/out/renderer/assets/{dagre-OKDRZEBW-DGp5rNt7.js → dagre-OKDRZEBW-NnFztN4b.js} +1 -1
  25. package/out/renderer/assets/{diagram-SSKATNLV-CZ9h9i9l.js → diagram-SSKATNLV-2BWtLWap.js} +1 -1
  26. package/out/renderer/assets/{diagram-VNBRO52H-9Ea1Q8eQ.js → diagram-VNBRO52H-CENgJuWn.js} +1 -1
  27. package/out/renderer/assets/{erDiagram-Q7BY3M3F-DxqjWHsj.js → erDiagram-Q7BY3M3F-CCc0_w5h.js} +1 -1
  28. package/out/renderer/assets/{flowDiagram-4HSFHLVR-MPXKVzGy.js → flowDiagram-4HSFHLVR-DBpVSzRM.js} +1 -1
  29. package/out/renderer/assets/{ganttDiagram-APWFNJXF-DzVIqVp-.js → ganttDiagram-APWFNJXF-mf6DXttE.js} +1 -1
  30. package/out/renderer/assets/{gitGraphDiagram-7IBYFJ6S-wxrYE11_.js → gitGraphDiagram-7IBYFJ6S-Bq8O1rxL.js} +1 -1
  31. package/out/renderer/assets/{graph-DN4_InFF.js → graph-qxmVeyvg.js} +1 -1
  32. package/out/renderer/assets/icon-BF__WH76.css +1 -0
  33. package/out/renderer/assets/{icon-BOPiVwJU.js → icon-CEd0ml4Y.js} +9 -9
  34. package/out/renderer/assets/{index-D6cm58iQ.js → index-4byN7Q2L.js} +1 -1
  35. package/out/renderer/assets/{index-B2uMY09U.js → index-CT_UJzBT.js} +1 -1
  36. package/out/renderer/assets/{index-VMcD7L9G.js → index-DLktHc2h.js} +359 -359
  37. package/out/renderer/assets/{infoDiagram-PH2N3AL5-BT9ZT33s.js → infoDiagram-PH2N3AL5-BCOEX5M1.js} +1 -1
  38. package/out/renderer/assets/{installer-D_dKCXm_.js → installer-BsQIORMk.js} +2 -2
  39. package/out/renderer/assets/{journeyDiagram-U35MCT3I-Drfp7Dx4.js → journeyDiagram-U35MCT3I-BeoAemLm.js} +1 -1
  40. package/out/renderer/assets/{kanban-definition-NDS4AKOZ-zrwtWfh3.js → kanban-definition-NDS4AKOZ-g2cpPHPh.js} +1 -1
  41. package/out/renderer/assets/{layout-C_NwCHZA.js → layout-BtO_FG0y.js} +1 -1
  42. package/out/renderer/assets/{legacy-agents-page-DaRJ4ssr.js → legacy-agents-page-CIz8TVKP.js} +1 -1
  43. package/out/renderer/assets/{mermaid.core-BrQV9aUN.js → mermaid.core-CdVpQ79S.js} +5 -5
  44. package/out/renderer/assets/{mindmap-definition-ALO5MXBD-BENJMVb7.js → mindmap-definition-ALO5MXBD-CHVbB4ku.js} +1 -1
  45. package/out/renderer/assets/{page-header-DpmrDCKb.js → page-header-BGvY3vKz.js} +1 -1
  46. package/out/renderer/assets/{parseISO-B3E0He4W.js → parseISO-BejL_jy7.js} +1 -1
  47. package/out/renderer/assets/{pieDiagram-IB7DONF6-Cj_MvCB6.js → pieDiagram-IB7DONF6-BBOrJKFD.js} +1 -1
  48. package/out/renderer/assets/{quadrantDiagram-7GDLP6J5-CSLboz_B.js → quadrantDiagram-7GDLP6J5-DSBlTbPc.js} +1 -1
  49. package/out/renderer/assets/{radar-MK3ICKWK-B5o09zzn.js → radar-MK3ICKWK-pZgkhoLd.js} +1 -1
  50. package/out/renderer/assets/{radient-auth-buttons-D3rHBFpG.js → radient-auth-buttons-BnXJwZfD.js} +1 -1
  51. package/out/renderer/assets/{requirementDiagram-KVF5MWMF-BClS8AfY.js → requirementDiagram-KVF5MWMF-BUV7_UZP.js} +1 -1
  52. package/out/renderer/assets/{sankeyDiagram-QLVOVGJD-7yhRK2Vp.js → sankeyDiagram-QLVOVGJD-DfvIpv82.js} +1 -1
  53. package/out/renderer/assets/{schedules-page-BAO3byEh.js → schedules-page-D0aF6oGQ.js} +1 -1
  54. package/out/renderer/assets/{sequenceDiagram-X6HHIX6F-D8YzpDkf.js → sequenceDiagram-X6HHIX6F-DFuvLBd0.js} +1 -1
  55. package/out/renderer/assets/{settings-page-D3pHslhI.js → settings-page-DrjsU9En.js} +6 -6
  56. package/out/renderer/assets/{shield-check-DLPbFvDD.js → shield-check-DusR2dvL.js} +1 -1
  57. package/out/renderer/assets/{stateDiagram-DGXRK772-D3vCf9jT.js → stateDiagram-DGXRK772-CmDy9j2_.js} +1 -1
  58. package/out/renderer/assets/stateDiagram-v2-YXO3MK2T-Bv3iQPCj.js +1 -0
  59. package/out/renderer/assets/{timeline-definition-BDJGKUSR-BmcDjvMo.js → timeline-definition-BDJGKUSR-De6scz66.js} +1 -1
  60. package/out/renderer/assets/{use-agent-like-mutation-5qH9Ra93.js → use-agent-like-mutation-CxJR8287.js} +1 -1
  61. package/out/renderer/assets/{xychartDiagram-VJFVF3MP-D6EwLz04.js → xychartDiagram-VJFVF3MP-Cz0S_Epm.js} +1 -1
  62. package/out/renderer/index.html +3 -3
  63. package/out/renderer/installer.html +4 -4
  64. package/package.json +2 -3
  65. package/out/renderer/assets/browser-page-DURkioyd.js +0 -1
  66. package/out/renderer/assets/channel-Do5StXgL.js +0 -1
  67. package/out/renderer/assets/classDiagram-GIVACNV2-DXbjXCFm.js +0 -1
  68. package/out/renderer/assets/classDiagram-v2-COTLJTTW-DXbjXCFm.js +0 -1
  69. package/out/renderer/assets/clone-6x4kDBYj.js +0 -1
  70. package/out/renderer/assets/icon-C-eyP-uR.css +0 -1
  71. package/out/renderer/assets/stateDiagram-v2-YXO3MK2T-cJKsjNUG.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);
@@ -2107,13 +2187,18 @@ function pythonBytecodeCacheDir(userDataDir) {
2107
2187
  }
2108
2188
  function withPythonBytecodeCache(env, userDataDir) {
2109
2189
  const existing = env.PYTHONPYCACHEPREFIX?.trim();
2110
- const prefix = existing && !insideAppBundle(existing) ? existing : pythonBytecodeCacheDir(userDataDir);
2190
+ const prefix = isUsablePrefix(existing) ? existing : pythonBytecodeCacheDir(userDataDir);
2111
2191
  return {
2112
2192
  ...env,
2113
2193
  PYTHONPYCACHEPREFIX: prefix,
2114
2194
  PYTHONDONTWRITEBYTECODE: "1"
2115
2195
  };
2116
2196
  }
2197
+ function isUsablePrefix(value) {
2198
+ if (!value) return false;
2199
+ if (!path$1.isAbsolute(value)) return false;
2200
+ return !insideAppBundle(value);
2201
+ }
2117
2202
  const VENV_BYTECODE_GUARD_FILE = "sitecustomize.py";
2118
2203
  const VENV_BYTECODE_GUARD_SENTINEL = "# Local Operator bytecode guard";
2119
2204
  function venvBytecodeGuardSource() {
@@ -2310,8 +2395,6 @@ function isPythonBytecodePath(bundlePath, candidate) {
2310
2395
  );
2311
2396
  const separator = "/";
2312
2397
  if (!path2.endsWith(".pyc")) return false;
2313
- const segments = path2.split(separator);
2314
- if (!segments.includes("__pycache__")) return false;
2315
2398
  return dirs.some((dir) => path2.startsWith(`${dir}${separator}`));
2316
2399
  }
2317
2400
  function planPythonBytecodeHeal(bundlePath, violations) {
@@ -3619,6 +3702,8 @@ const apiConfig = {
3619
3702
  baseUrl: backendConfig.VITE_LOCAL_OPERATOR_API_URL
3620
3703
  };
3621
3704
  const DEGRADED_AFTER_FAILURES = 3;
3705
+ const UNANSWERED_BEFORE_DETACHED = 9;
3706
+ const TRANSPORT_EVIDENCE_MS = 3e4;
3622
3707
  const PROBE_INTERVAL_MS = 1e4;
3623
3708
  const PROBE_TIMEOUT_MS$1 = 2e3;
3624
3709
  const DETACHED_AFTER_MS = 9e4;
@@ -3639,11 +3724,16 @@ class DaemonStateMachine {
3639
3724
  updatedAt;
3640
3725
  detachedSince = null;
3641
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;
3642
3731
  /** Adopt a validated daemon (`owned` says whether this app spawned it). */
3643
3732
  attach(identity, options) {
3644
3733
  this.identity = identity;
3645
3734
  this.owned = options.owned;
3646
3735
  this.failures = 0;
3736
+ this.unanswered = 0;
3647
3737
  this.detachedSince = null;
3648
3738
  this.backoffMs = REATTACH_BACKOFF_MS;
3649
3739
  this.state = "attached";
@@ -3673,6 +3763,7 @@ class DaemonStateMachine {
3673
3763
  switch (observation.kind) {
3674
3764
  case "identified":
3675
3765
  this.failures = 0;
3766
+ this.unanswered = 0;
3676
3767
  this.capabilityStatus = null;
3677
3768
  this.detachedSince = null;
3678
3769
  this.backoffMs = REATTACH_BACKOFF_MS;
@@ -3698,11 +3789,20 @@ class DaemonStateMachine {
3698
3789
  this.enterDetached(observation.detail);
3699
3790
  } else {
3700
3791
  this.state = "degraded";
3701
- 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")}`;
3702
3793
  }
3703
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";
3801
+ }
3802
+ this.detail = observation.detail;
3803
+ break;
3704
3804
  case "pid-dead":
3705
- this.enterDetached("The daemon's process is gone.");
3805
+ this.enterDetached("The daemon's process is gone.", true);
3706
3806
  break;
3707
3807
  case "no-candidate":
3708
3808
  this.enterDetached(observation.detail);
@@ -3711,7 +3811,96 @@ class DaemonStateMachine {
3711
3811
  this.updatedAt = this.now();
3712
3812
  return this.state;
3713
3813
  }
3714
- 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
+ }
3715
3904
  this.failures = DEGRADED_AFTER_FAILURES;
3716
3905
  this.state = "detached";
3717
3906
  if (this.detachedSince === null) this.detachedSince = this.now();
@@ -3767,6 +3956,13 @@ class DaemonStateMachine {
3767
3956
  desktopAvailable: this.desktopAvailable,
3768
3957
  failures: this.failures,
3769
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,
3770
3966
  detail: this.detail,
3771
3967
  updatedAt: this.updatedAt
3772
3968
  };
@@ -3899,6 +4095,24 @@ function recordAddress(record) {
3899
4095
  const host = record.host.includes(":") ? `[${record.host}]` : record.host;
3900
4096
  return `http://${host}:${record.port}`;
3901
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
+ }
3902
4116
  function readIdentity(payload) {
3903
4117
  if (typeof payload !== "object" || payload === null) return null;
3904
4118
  const result = payload.result;
@@ -3955,6 +4169,7 @@ async function probeIdentity(address, expectedInstanceId, options = {}) {
3955
4169
  } catch (error) {
3956
4170
  return {
3957
4171
  outcome: "unreachable",
4172
+ cause: classifyUnreachable(error),
3958
4173
  detail: error instanceof Error ? error.message : String(error)
3959
4174
  };
3960
4175
  }
@@ -4140,14 +4355,16 @@ async function probeUnidentified(address, options = {}) {
4140
4355
  });
4141
4356
  if (response.status !== 200) {
4142
4357
  return {
4143
- reason: "not-a-daemon",
4358
+ reason: "unready-answer",
4144
4359
  detail: `${HEALTH_PATH$1} answered ${response.status} and no record describes this address`
4145
4360
  };
4146
4361
  }
4147
4362
  const payload = await response.json().catch(() => null);
4148
- if (readIdentity(payload)) {
4363
+ const identity = readIdentity(payload);
4364
+ if (identity) {
4149
4365
  return {
4150
4366
  reason: "identity-mismatch",
4367
+ identity,
4151
4368
  detail: `a daemon answered but no serve record describes ${address}, so it cannot be proven to be the one this app found`
4152
4369
  };
4153
4370
  }
@@ -4158,6 +4375,7 @@ async function probeUnidentified(address, options = {}) {
4158
4375
  } catch (error) {
4159
4376
  return {
4160
4377
  reason: "unreachable",
4378
+ cause: classifyUnreachable(error),
4161
4379
  detail: error instanceof Error ? error.message : String(error)
4162
4380
  };
4163
4381
  }
@@ -5186,9 +5404,15 @@ const SHUTDOWN_TIMEOUT_DEFAULTS = {
5186
5404
  };
5187
5405
  const OWNED_STOP_WORST_MS = SHUTDOWN_TIMEOUT_DEFAULTS.normal + SHUTDOWN_TIMEOUT_DEFAULTS.force;
5188
5406
  const READINESS_POLL_INTERVAL_MS = 1e3;
5407
+ const DESKTOP_TOKEN_FILENAME = "desktop-token";
5189
5408
  const execPromise = node_util.promisify(node_child_process.exec);
5190
5409
  const ENV_VAR_REGEX = /^([^=]+)=(.*)$/;
5191
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
+ }
5192
5416
  class BackendServiceManager {
5193
5417
  process = null;
5194
5418
  isRunning = false;
@@ -5212,6 +5436,19 @@ class BackendServiceManager {
5212
5436
  remoteConfigured = false;
5213
5437
  /** A failed probe or unreadable record is not evidence that spawning is safe. */
5214
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;
5215
5452
  /**
5216
5453
  * Records discovery found alive but unresponsive (pid alive, heartbeat
5217
5454
  * stopped). They are why no candidate exists AND why spawning is forbidden,
@@ -5223,6 +5460,14 @@ class BackendServiceManager {
5223
5460
  nextRecoveryAt = 0;
5224
5461
  appDataPath = electron.app.getPath("userData");
5225
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. */
5226
5471
  healthCheckInterval = null;
5227
5472
  /** The only process this manager may terminate. Null means it owns nothing,
5228
5473
  * which is a reason to report "nothing to stop" - never to go looking. */
@@ -5252,6 +5497,29 @@ class BackendServiceManager {
5252
5497
  shellEnv = {};
5253
5498
  // External/dev backends may be explicitly paired through main's environment.
5254
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
+ */
5255
5523
  desktopToken = process.env.LOCAL_OPERATOR_DESKTOP_TOKEN || null;
5256
5524
  /** Authenticated SSE relay for canonical session events. Recreated when the
5257
5525
  * backend URL rotates (external-backend discovery) or the desktop token does
@@ -5444,15 +5712,51 @@ class BackendServiceManager {
5444
5712
  this.feedRelay?.watchState(stateObserver);
5445
5713
  }
5446
5714
  requestDesktop(input) {
5447
- 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
+ });
5448
5723
  }
5449
5724
  requestDesktopMedia(input, bytes) {
5450
- return requestDesktopMedia(
5725
+ return requestDesktopMediaOutcome(
5451
5726
  input,
5452
5727
  bytes,
5453
5728
  this.backendUrl,
5454
5729
  this.desktopToken
5455
- );
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();
5456
5760
  }
5457
5761
  isAppClosing = false;
5458
5762
  // Flag to track when the app is being closed
@@ -5491,6 +5795,7 @@ class BackendServiceManager {
5491
5795
  packaged: electron.app.isPackaged
5492
5796
  });
5493
5797
  this.loadShellEnvironment();
5798
+ this.desktopToken = this.desktopToken || this.persistedDesktopToken();
5494
5799
  logger.info(
5495
5800
  `Backend Service Manager initialized. May spawn or kill: ${this.managerMaySpawn}`,
5496
5801
  LogFileType.BACKEND
@@ -5765,6 +6070,11 @@ class BackendServiceManager {
5765
6070
  kind: "heartbeat-stale",
5766
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.`
5767
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
+ });
5768
6078
  } else {
5769
6079
  this.daemonState.observe({
5770
6080
  kind: "no-candidate",
@@ -5911,6 +6221,7 @@ class BackendServiceManager {
5911
6221
  async adoptFirstUsableDaemon() {
5912
6222
  if (this.isAppClosing) return false;
5913
6223
  this.discoveryWedged = [];
6224
+ this.answeredButUnusable = null;
5914
6225
  if (this.remoteConfigured) {
5915
6226
  this.discoveryBlocksSpawn = true;
5916
6227
  return await this.legacyFixedPortAdoption();
@@ -5993,19 +6304,40 @@ class BackendServiceManager {
5993
6304
  return false;
5994
6305
  }
5995
6306
  }
5996
- if (!await this.authenticatesAgainst(candidate.address, token)) {
6307
+ const probe = await this.probeCandidate(candidate.address, token);
6308
+ if (probe.verdict === "refused") {
5997
6309
  logger.info(
5998
- `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).`,
5999
6311
  LogFileType.BACKEND
6000
6312
  );
6001
6313
  this.daemonState.observe({
6002
6314
  kind: "capability",
6003
- status: 403,
6004
- 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}).`
6005
6317
  });
6006
6318
  this.notifyStatus();
6007
6319
  return false;
6008
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.`
6334
+ });
6335
+ this.notifyStatus();
6336
+ return false;
6337
+ }
6338
+ if (probe.verdict === "unreachable") {
6339
+ return false;
6340
+ }
6009
6341
  await this.attachTo(candidate, token);
6010
6342
  return true;
6011
6343
  }
@@ -6021,16 +6353,17 @@ class BackendServiceManager {
6021
6353
  * `this.desktopToken` because this runs BEFORE adoption: the candidate must
6022
6354
  * be proved usable without the manager having committed to it.
6023
6355
  */
6024
- async authenticatesAgainst(address, token) {
6356
+ async probeCandidate(address, token) {
6025
6357
  try {
6026
6358
  const result = await requestDesktop(
6027
6359
  { op: "sessions.list", limit: 1 },
6028
6360
  address,
6029
6361
  token
6030
6362
  );
6031
- return result.status >= 200 && result.status < 300;
6363
+ const verdict = classifyDesktopAnswer(result.status);
6364
+ return verdict === "accepted" ? { verdict } : { verdict, status: result.status };
6032
6365
  } catch {
6033
- return false;
6366
+ return { verdict: "unreachable" };
6034
6367
  }
6035
6368
  }
6036
6369
  /**
@@ -6136,7 +6469,7 @@ class BackendServiceManager {
6136
6469
  const payload = await response.json().catch(() => null);
6137
6470
  if (!await this.authenticatesAgainstBackend()) {
6138
6471
  logger.info(
6139
- "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.",
6140
6473
  LogFileType.BACKEND
6141
6474
  );
6142
6475
  return false;
@@ -6184,7 +6517,7 @@ class BackendServiceManager {
6184
6517
  op: "sessions.list",
6185
6518
  limit: 1
6186
6519
  });
6187
- return result.status >= 200 && result.status < 300;
6520
+ return classifyDesktopAnswer(result.status) === "accepted";
6188
6521
  } catch {
6189
6522
  return false;
6190
6523
  }
@@ -6265,7 +6598,13 @@ class BackendServiceManager {
6265
6598
  this.startHealthCheck();
6266
6599
  return false;
6267
6600
  }
6268
- 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();
6269
6608
  let captured = null;
6270
6609
  try {
6271
6610
  this.venvPath = managedVenvPath({
@@ -6697,11 +7036,142 @@ class BackendServiceManager {
6697
7036
  `Backend health check response status: ${response.status}`,
6698
7037
  LogFileType.BACKEND
6699
7038
  );
7039
+ this.lastHealthFailure = null;
6700
7040
  return response.ok;
6701
- } catch (_error) {
7041
+ } catch (error) {
7042
+ this.lastHealthFailure = classifyUnreachable(error);
6702
7043
  return false;
6703
7044
  }
6704
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
+ }
6705
7175
  /**
6706
7176
  * One probe of the daemon this app is attached to.
6707
7177
  *
@@ -6719,9 +7189,12 @@ class BackendServiceManager {
6719
7189
  const expected = this.daemonState.expectedInstanceId();
6720
7190
  if (!expected) {
6721
7191
  const ok = await this.checkHealth();
6722
- return ok ? { kind: "identified" } : {
6723
- kind: "failed",
6724
- 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}`
6725
7198
  };
6726
7199
  }
6727
7200
  const probe = await probeIdentity(this.backendUrl, expected, {
@@ -6744,8 +7217,12 @@ class BackendServiceManager {
6744
7217
  detail: `${this.backendUrl} answered but is not a Local Operator daemon (${probe.detail})`
6745
7218
  };
6746
7219
  case "unreachable":
6747
- return {
7220
+ return probe.cause === "refused" ? {
6748
7221
  kind: "failed",
7222
+ detail: `${this.backendUrl} refused the connection (${probe.detail})`
7223
+ } : {
7224
+ kind: "unanswered",
7225
+ cause: probe.cause,
6749
7226
  detail: `${this.backendUrl} did not answer (${probe.detail})`
6750
7227
  };
6751
7228
  }
@@ -8337,13 +8814,35 @@ class ApprovalStore {
8337
8814
  /**
8338
8815
  * Raise a request, or report that one already covers this caller.
8339
8816
  *
8340
- * The rules are `vendor/driver/access-flow.ts`'s, applied to the queue: a repeat
8341
- * request for the same origin by the same requester is idempotent with its
8342
- * original TTL, a request for a different origin REPLACES the live one (with
8343
- * a tombstone for the displaced requester), and a DURABLE denial answers
8344
- * "denied" without re-prompting, because that is what the button promises —
8345
- * "Don't allow" says the agent stops asking about this site until the denial is
8346
- * revoked.
8817
+ * THE QUEUE IS A QUEUE, and this comment is the record of what changed. Until
8818
+ * this round `requestAccess` called `displaceLive` on every new request, which
8819
+ * cleared the WHOLE live queue and tombstoned it: one prompt slot,
8820
+ * replace-don't-queue, so a second agent's request destroyed the first agent's
8821
+ * (and the test said so plainly, `"one prompt slot, replace-don't-queue"`).
8822
+ * The design authority says the opposite — "the queue is FIFO with a 10-minute
8823
+ * async TTL" (design 9.1), which is why `access-queue.ts` ships a cap of 16 that
8824
+ * this host could never reach — and the operator asked for it by name ("allow a
8825
+ * queue"). So:
8826
+ *
8827
+ * 1. A repeat request for the same origin by the same requester is idempotent
8828
+ * with its ORIGINAL TTL. Resetting the TTL would let a polling agent extend
8829
+ * the window indefinitely.
8830
+ * 2. A live receipt answers without re-prompting (see the deny arm below), so a
8831
+ * user does not learn to click Allow to make the prompt stop.
8832
+ * 3. A request for a different origin, or from a different requester, is
8833
+ * APPENDED in `sequence` order and waits its turn.
8834
+ * 4. At the cap the OLDEST live entry is displaced, with a `superseded`
8835
+ * tombstone so the agent that raised it learns what happened rather than
8836
+ * timing out into an anonymous `none`. The NEWEST requester is never refused:
8837
+ * refusing the agent that is behaving normally would turn a full queue into a
8838
+ * hard, unactionable failure for the one caller that did nothing wrong.
8839
+ *
8840
+ * WHAT THE CHANGE COSTS, stated because it is a real trade a reviewer should not
8841
+ * have to find: a displaced request taught its agent immediately, where a queued
8842
+ * one now waits for its own `await_access` budget and then re-requests
8843
+ * (idempotent while its entry is live). In exchange, a user with four agents
8844
+ * working sees four requests instead of one, and no agent's request is destroyed
8845
+ * by another's arrival.
8347
8846
  */
8348
8847
  requestAccess(rawUrl, requester, kind = "async", commandId) {
8349
8848
  const url = safeHttpUrl(rawUrl);
@@ -8360,13 +8859,8 @@ class ApprovalStore {
8360
8859
  }
8361
8860
  this.sweep(now);
8362
8861
  if (liveQueue(this.queue, now).length >= ACCESS_QUEUE_CAP) {
8363
- throw new BridgeCommandError(
8364
- "access_queue_full",
8365
- `site approval queue is full with ${liveQueue(this.queue, now).length} pending requests`,
8366
- { pending_count: liveQueue(this.queue, now).length }
8367
- );
8862
+ this.displaceOldest(now);
8368
8863
  }
8369
- this.displaceLive(now);
8370
8864
  const entry = newEntry(
8371
8865
  url.origin,
8372
8866
  displayAuthority(url),
@@ -8415,18 +8909,19 @@ class ApprovalStore {
8415
8909
  requestedAt: entry.requestedAt,
8416
8910
  expiresAt: entry.expiresAt
8417
8911
  /*
8418
- * NO `decision` TERM, and the removal is the point. The vendored
8419
- * `accessState()` takes an optional decision so that a LIVE record can read
8420
- * `allowed`/`denied` before the grant lands, and this host fed it from the
8421
- * decision receipts — but every arm that writes a receipt removes its entry
8422
- * from the queue FIRST (`respond` filters, `cancelAccess` splices), entry ids
8423
- * are minted per entry and nothing here is restored from disk, so a record
8424
- * with a decision can never be the record this reads. It was unreachable code
8425
- * carrying the residue of the defect this fix closes, and a reader would
8426
- * reasonably infer from it that a receipt can still answer. It cannot:
8427
- * `requestAccess` answers from `liveAuthority` and from a durable verdict, and
8428
- * the receipts are the store's record of what was decided (swept by TTL)
8429
- * rather than a reader's source.
8912
+ * NO `decision` TERM, and the removal is the point (review round 1,
8913
+ * finding 5). The vendored `accessState()` takes an optional decision so
8914
+ * that a LIVE record can read `allowed`/`denied` before the grant lands,
8915
+ * and this host fed it from the decision receipts — but every arm that
8916
+ * writes a receipt removes its entry from the queue FIRST (`respond`
8917
+ * filters, `cancelAccess` splices), entry ids are minted per entry and
8918
+ * nothing here is restored from disk, so a record with a decision can
8919
+ * never be the record this reads. It was unreachable code carrying the
8920
+ * residue of the defect this PR fixes, and a reader would reasonably infer
8921
+ * from it that a receipt can still answer. It cannot: `requestAccess`
8922
+ * answers from `liveAuthority` and from a durable verdict, and the
8923
+ * receipts are the store's record of what was decided (purged by
8924
+ * `revokeOrigin` and swept by TTL) rather than a reader's source.
8430
8925
  */
8431
8926
  } : void 0,
8432
8927
  this.tombstones,
@@ -8484,6 +8979,16 @@ class ApprovalStore {
8484
8979
  );
8485
8980
  }
8486
8981
  const now = this.now();
8982
+ if (now >= entry.expiresAt) {
8983
+ this.queue = this.queue.filter(
8984
+ (candidate) => candidate.entryId !== entryId
8985
+ );
8986
+ this.onChanged();
8987
+ throw new BridgeCommandError(
8988
+ "internal",
8989
+ "that site approval request expired before it was answered; the agent has to ask again"
8990
+ );
8991
+ }
8487
8992
  this.queue = this.queue.filter(
8488
8993
  (candidate) => candidate.entryId !== entryId
8489
8994
  );
@@ -8644,6 +9149,9 @@ class ApprovalStore {
8644
9149
  delete this.store.origins[origin];
8645
9150
  removed += 1;
8646
9151
  }
9152
+ for (const key of Object.keys(this.results)) {
9153
+ if (this.results[key]?.origin === origin) delete this.results[key];
9154
+ }
8647
9155
  if (this.sessionGrants.delete(origin)) removed += 1;
8648
9156
  const before = this.store.records.length;
8649
9157
  this.store.records = this.store.records.filter(
@@ -8701,13 +9209,17 @@ class ApprovalStore {
8701
9209
  this.onChanged();
8702
9210
  }
8703
9211
  // ---- internals -----------------------------------------------------------
8704
- /** Replace-don't-queue: the live entry for another origin (or another
8705
- * requester) is displaced, and its requester gets a tombstone so its next poll
9212
+ /** At the cap: the OLDEST live entry (or entries, if a queue was restored over
9213
+ * the cap) steps aside, and its requester gets a tombstone so its next poll
8706
9214
  * reads "superseded" rather than the neutral "none". */
8707
- displaceLive(now) {
9215
+ displaceOldest(now) {
8708
9216
  const live = liveQueue(this.queue, now);
8709
9217
  if (!live.length) return;
8710
- for (const entry of live) {
9218
+ const displaced = live.slice(
9219
+ 0,
9220
+ Math.max(1, live.length - ACCESS_QUEUE_CAP + 1)
9221
+ );
9222
+ for (const entry of displaced) {
8711
9223
  this.tombstones[receiptKey(entry.origin, entry.requester)] = tombstoneFor(
8712
9224
  {
8713
9225
  origin: entry.origin,
@@ -8718,7 +9230,8 @@ class ApprovalStore {
8718
9230
  }
8719
9231
  );
8720
9232
  }
8721
- this.queue = [];
9233
+ const displacedIds = new Set(displaced.map((entry) => entry.entryId));
9234
+ this.queue = this.queue.filter((entry) => !displacedIds.has(entry.entryId));
8722
9235
  this.trimTombstones();
8723
9236
  }
8724
9237
  trimTombstones() {
@@ -9147,45 +9660,56 @@ class CdpPool {
9147
9660
  }
9148
9661
  }
9149
9662
  const CONSENT_TITLE = "Site approval needed";
9150
- function consentBody(origin) {
9151
- return `An agent wants to open ${origin}. Approve or deny it in the browser tab.`;
9663
+ function consentBody(count, origin) {
9664
+ return count === 1 ? `An agent wants to open ${origin}. Approve or deny it in the browser tab.` : `${count} site approvals are waiting. Approve or deny them in the browser tab.`;
9152
9665
  }
9153
9666
  class ConsentNotifier {
9154
9667
  constructor(options) {
9155
9668
  this.options = options;
9156
9669
  }
9157
- announced = /* @__PURE__ */ new Set();
9670
+ announced = 0;
9158
9671
  /**
9159
- * Announce the pending entries that have not been announced yet.
9672
+ * Announce a change in the size of the pending set — ONE banner per increase.
9160
9673
  *
9161
- * Called with the CURRENT pending list on every change rather than on an
9162
- * "added" event: the store's change hook is one callback for a mutable set,
9163
- * and a diff against what was already announced is both simpler and correct
9164
- * when an entry is superseded and replaced by a new one with a new id.
9674
+ * WHY A COUNT WATERMARK RATHER THAN A SET OF ANNOUNCED IDS, which is what this
9675
+ * was: with one prompt slot the set could hold at most one entry, and with a
9676
+ * real queue (`approvals.ts` no longer displaces) the old rule raised one
9677
+ * banner PER UNANNOUNCED ENTRY — up to 16 banners for one busy minute, against
9678
+ * a rule that exists to avoid interrupting the operator (design 9.2, 11.4).
9679
+ * The count of the live set is the thing the user can act on, so an increase is
9680
+ * the event and a banner is the whole response to it.
9681
+ *
9682
+ * WHAT THIS GIVES UP, deliberately: a request that replaces another without
9683
+ * changing the count (a displacement at the cap, a cancel plus an arrival in
9684
+ * one refresh) raises no second banner. The alternative is a banner per entry,
9685
+ * and the banner's job is to bring the user to the band — where the tray shows
9686
+ * the whole live set, numbered. The click names the OLDEST live entry, which is
9687
+ * the one the tray selects by default.
9165
9688
  */
9166
9689
  announce(pending) {
9167
- const live = new Set(pending.map((entry) => entry.entryId));
9168
- for (const entryId of [...this.announced]) {
9169
- if (!live.has(entryId)) this.announced.delete(entryId);
9170
- }
9690
+ const count = pending.length;
9691
+ const increased = count > this.announced;
9692
+ this.announced = count;
9693
+ if (!increased) return;
9171
9694
  if (this.options.show !== "focus") return;
9172
- if (!electron.Notification.isSupported()) return;
9173
- for (const entry of pending) {
9174
- if (this.announced.has(entry.entryId)) continue;
9175
- this.announced.add(entry.entryId);
9176
- try {
9177
- const notification = new electron.Notification({
9695
+ const create = this.options.createNotification;
9696
+ if (!create && !electron.Notification.isSupported()) return;
9697
+ const oldest = pending[0];
9698
+ if (!oldest) return;
9699
+ try {
9700
+ const notification = (create ?? ((options) => new electron.Notification(options)))(
9701
+ {
9178
9702
  title: CONSENT_TITLE,
9179
- body: consentBody(entry.origin),
9703
+ body: consentBody(count, oldest.origin),
9180
9704
  silent: false
9181
- });
9182
- notification.on("click", () => this.options.onAttention(entry.entryId));
9183
- notification.show();
9184
- } catch (error) {
9185
- this.options.log?.(
9186
- `[browser] could not raise a consent banner: ${String(error)}`
9187
- );
9188
- }
9705
+ }
9706
+ );
9707
+ notification.on("click", () => this.options.onAttention(oldest.entryId));
9708
+ notification.show();
9709
+ } catch (error) {
9710
+ this.options.log?.(
9711
+ `[browser] could not raise a consent banner: ${String(error)}`
9712
+ );
9189
9713
  }
9190
9714
  }
9191
9715
  }
@@ -10718,6 +11242,16 @@ class BrowserHost {
10718
11242
  titleForChrome(title) {
10719
11243
  return title === "about:blank" ? "" : title;
10720
11244
  }
11245
+ /** Whether a tab is loading right now, false for a tab that is gone or whose
11246
+ * webContents has already been destroyed. Guarded the same way `snapshot()`
11247
+ * guards its reads: a read that lands between destruction and the
11248
+ * `destroyed` handler would otherwise throw "Object has been destroyed" out of
11249
+ * an IPC handler and blank the whole strip instead of dropping one tab. */
11250
+ tabLoading(tabId) {
11251
+ const record = this.registry.get(tabId);
11252
+ if (!record || record.view.webContents.isDestroyed()) return false;
11253
+ return record.view.webContents.isLoading();
11254
+ }
10721
11255
  chromeState() {
10722
11256
  const activeRecord = this.registry.activeTab;
10723
11257
  const active = activeRecord && !activeRecord.view.webContents.isDestroyed() ? activeRecord : null;
@@ -10734,7 +11268,16 @@ class BrowserHost {
10734
11268
  // Per TAB, not only for the active one: a background tab whose load was
10735
11269
  // refused has no other way to say so - its page area is blank and the
10736
11270
  // band belongs to the active tab - so the strip carries the mark.
10737
- failed: this.loadFailures.has(entry.tabId)
11271
+ failed: this.loadFailures.has(entry.tabId),
11272
+ // PER TAB, and it is the one projection field this feature adds. The strip
11273
+ // could only ever see the ACTIVE tab's loading state before (`loading`
11274
+ // below), so a background agent tab that is loading a page showed nothing
11275
+ // at all - and since an agent tab is created non-active and only the active
11276
+ // tab occupies the content rect (`registry.ts:492-503`), "nothing at all"
11277
+ // was the whole of what the user saw of an agent's work. Read from the
11278
+ // record rather than the snapshot: `snapshot()` is the diagnostic
11279
+ // projection shared with `status`/`tabs`, and this field is chrome.
11280
+ loading: this.tabLoading(entry.tabId)
10738
11281
  })),
10739
11282
  activeTabId: activeRecord?.tabId ?? null,
10740
11283
  url: active ? active.view.webContents.getURL() : "",