local-operator-ui 0.30.27 → 0.30.29

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 (60) hide show
  1. package/out/main/index.js +589 -55
  2. package/out/renderer/assets/{_basePickBy-B84zJBrd.js → _basePickBy-B-NI55Qi.js} +1 -1
  3. package/out/renderer/assets/{_baseUniq-5FE_0Pzy.js → _baseUniq-QqU-ovXp.js} +1 -1
  4. package/out/renderer/assets/{agent-details-page-C4IFbyqT.js → agent-details-page-DJVrrjGz.js} +1 -1
  5. package/out/renderer/assets/{agent-hub-page-cffVZ-mr.js → agent-hub-page-DGc_yArZ.js} +1 -1
  6. package/out/renderer/assets/{agents-page-DHfUbAC6.js → agents-page-Bt1QxEpc.js} +2 -2
  7. package/out/renderer/assets/{arc-SqmNIkGJ.js → arc-BD4X1YDX.js} +1 -1
  8. package/out/renderer/assets/{architectureDiagram-IEHRJDOE-CdSu8M64.js → architectureDiagram-IEHRJDOE-D0a_Nf2k.js} +1 -1
  9. package/out/renderer/assets/{blockDiagram-JOT3LUYC-DNrf8kDd.js → blockDiagram-JOT3LUYC-UGbo6reS.js} +1 -1
  10. package/out/renderer/assets/{browser-page-C-tPZTiq.js → browser-page-DWkbX95_.js} +1 -1
  11. package/out/renderer/assets/{browser-webauthn-prompt-ChmryyLk.js → browser-webauthn-prompt-DNZXVAtn.js} +1 -1
  12. package/out/renderer/assets/{c4Diagram-VJAJSXHY-CuQ6O6uB.js → c4Diagram-VJAJSXHY-LtoyzeNf.js} +1 -1
  13. package/out/renderer/assets/channel-DZPWjVFP.js +1 -0
  14. package/out/renderer/assets/{chunk-4BMEZGHF-DJFSZIyL.js → chunk-4BMEZGHF-DYdhqKcB.js} +1 -1
  15. package/out/renderer/assets/{chunk-A2AXSNBT-Bs4uneQw.js → chunk-A2AXSNBT-BaXRjt-L.js} +1 -1
  16. package/out/renderer/assets/{chunk-AEK57VVT-DHKVdN2z.js → chunk-AEK57VVT-BTlk0NKY.js} +1 -1
  17. package/out/renderer/assets/{chunk-D6G4REZN-D4SM9H9Z.js → chunk-D6G4REZN-L8pXaXu4.js} +1 -1
  18. package/out/renderer/assets/{chunk-RZ5BOZE2-CY9RYiKS.js → chunk-RZ5BOZE2-DlBmG3dv.js} +1 -1
  19. package/out/renderer/assets/{chunk-XZIHB7SX-8K5WFc_P.js → chunk-XZIHB7SX-DQ99vLKP.js} +1 -1
  20. package/out/renderer/assets/{classDiagram-GIVACNV2-CkICxv3_.js → classDiagram-GIVACNV2-BX-i0wvn.js} +1 -1
  21. package/out/renderer/assets/{classDiagram-v2-COTLJTTW-CkICxv3_.js → classDiagram-v2-COTLJTTW-BX-i0wvn.js} +1 -1
  22. package/out/renderer/assets/clone-B458zF2X.js +1 -0
  23. package/out/renderer/assets/{compact-pagination-eHm_EY38.js → compact-pagination-Yze9c5sn.js} +1 -1
  24. package/out/renderer/assets/{dagre-OKDRZEBW-txZLCqw6.js → dagre-OKDRZEBW-JWUAfKY8.js} +1 -1
  25. package/out/renderer/assets/{diagram-SSKATNLV-W0Y2-AT4.js → diagram-SSKATNLV-DYViy9gs.js} +1 -1
  26. package/out/renderer/assets/{diagram-VNBRO52H-CKvWDuA4.js → diagram-VNBRO52H-BmbRwpMM.js} +1 -1
  27. package/out/renderer/assets/{erDiagram-Q7BY3M3F-PAacAmGI.js → erDiagram-Q7BY3M3F-4AVF78sA.js} +1 -1
  28. package/out/renderer/assets/{flowDiagram-4HSFHLVR-Bei90EPK.js → flowDiagram-4HSFHLVR-CT6Umjpq.js} +1 -1
  29. package/out/renderer/assets/{ganttDiagram-APWFNJXF-BQC18zab.js → ganttDiagram-APWFNJXF-Dk1Y08hY.js} +1 -1
  30. package/out/renderer/assets/{gitGraphDiagram-7IBYFJ6S-Xl-dmbuM.js → gitGraphDiagram-7IBYFJ6S-0geYV52b.js} +1 -1
  31. package/out/renderer/assets/{graph-QpX0KD6W.js → graph-DdZ6i8JQ.js} +1 -1
  32. package/out/renderer/assets/{index-DsgIPrxr.js → index-CGqR7D8S.js} +1 -1
  33. package/out/renderer/assets/{index-gJ55MyAP.js → index-DAQIcg1R.js} +1 -1
  34. package/out/renderer/assets/{index-Lnk-n9Ed.js → index-DECk1RkU.js} +393 -393
  35. package/out/renderer/assets/{infoDiagram-PH2N3AL5-DQ-2E-kT.js → infoDiagram-PH2N3AL5-B823Dzuf.js} +1 -1
  36. package/out/renderer/assets/{journeyDiagram-U35MCT3I-q3TfHG-b.js → journeyDiagram-U35MCT3I-ZAnEIMhZ.js} +1 -1
  37. package/out/renderer/assets/{kanban-definition-NDS4AKOZ-Dd_coELP.js → kanban-definition-NDS4AKOZ-C76KVhSZ.js} +1 -1
  38. package/out/renderer/assets/{layout-BrIhdApn.js → layout-B7w4mI-2.js} +1 -1
  39. package/out/renderer/assets/{legacy-agents-page-BRlhom2N.js → legacy-agents-page-Cs5WxptN.js} +1 -1
  40. package/out/renderer/assets/{mermaid.core-vjvZmBcS.js → mermaid.core-CzXJ6T_7.js} +5 -5
  41. package/out/renderer/assets/{mindmap-definition-ALO5MXBD-D-tWSRs5.js → mindmap-definition-ALO5MXBD-CrmEkNv0.js} +1 -1
  42. package/out/renderer/assets/{parseISO-NuSQVcJg.js → parseISO-BT8-3R9w.js} +1 -1
  43. package/out/renderer/assets/{pieDiagram-IB7DONF6-CaNEIaNu.js → pieDiagram-IB7DONF6-jaP9Rhad.js} +1 -1
  44. package/out/renderer/assets/{quadrantDiagram-7GDLP6J5-5_VRt6bt.js → quadrantDiagram-7GDLP6J5-D0uakfby.js} +1 -1
  45. package/out/renderer/assets/{radar-MK3ICKWK-Bz9hgA49.js → radar-MK3ICKWK-BFkpguxO.js} +1 -1
  46. package/out/renderer/assets/{radient-auth-buttons-8loqiUtv.js → radient-auth-buttons-THSzcFBG.js} +1 -1
  47. package/out/renderer/assets/{requirementDiagram-KVF5MWMF-DOqzxMkp.js → requirementDiagram-KVF5MWMF-Dp9PokO5.js} +1 -1
  48. package/out/renderer/assets/{sankeyDiagram-QLVOVGJD-5xoq_e6b.js → sankeyDiagram-QLVOVGJD-BsoeL4Y7.js} +1 -1
  49. package/out/renderer/assets/{schedules-page-C9QqpkWH.js → schedules-page-bUZKQ5r9.js} +1 -1
  50. package/out/renderer/assets/{sequenceDiagram-X6HHIX6F-DEPOymjg.js → sequenceDiagram-X6HHIX6F-Fg4Ar7iI.js} +1 -1
  51. package/out/renderer/assets/{settings-page-NDXqna_p.js → settings-page-QVevKIgf.js} +1 -1
  52. package/out/renderer/assets/{stateDiagram-DGXRK772-C6eugpT-.js → stateDiagram-DGXRK772-BuwiXTGP.js} +1 -1
  53. package/out/renderer/assets/{stateDiagram-v2-YXO3MK2T-DVXfZBv0.js → stateDiagram-v2-YXO3MK2T-C_0tdFKm.js} +1 -1
  54. package/out/renderer/assets/{timeline-definition-BDJGKUSR-CEKhSr-Q.js → timeline-definition-BDJGKUSR-CBJfeBmR.js} +1 -1
  55. package/out/renderer/assets/{use-agent-like-mutation-B1JkoyfW.js → use-agent-like-mutation-ChuwGRmd.js} +1 -1
  56. package/out/renderer/assets/{xychartDiagram-VJFVF3MP-CHZb4F-v.js → xychartDiagram-VJFVF3MP-Okgqap83.js} +1 -1
  57. package/out/renderer/index.html +1 -1
  58. package/package.json +3 -3
  59. package/out/renderer/assets/channel-gUbnyrYQ.js +0 -1
  60. package/out/renderer/assets/clone-5v3rSCz3.js +0 -1
package/out/main/index.js CHANGED
@@ -47,6 +47,9 @@ const DAEMON_UNPAIRED = {
47
47
  };
48
48
  const BACKEND_STATUS_EVENT = "backend-status-changed";
49
49
  const BACKEND_RECONNECT_CHANNEL = "backend-reconnect";
50
+ function isServerReachable(state) {
51
+ return state !== "detached" && state !== "wedged";
52
+ }
50
53
  function relayNeedsRebuild(current, next) {
51
54
  return current.url !== next.url || current.token !== next.token;
52
55
  }
@@ -2056,6 +2059,65 @@ function readServeRecords(dir) {
2056
2059
  }
2057
2060
  return { files, problem: null };
2058
2061
  }
2062
+ function recordsHolding(files, address, now) {
2063
+ if (!address) return [];
2064
+ const wanted = normaliseAddress(address) ?? address;
2065
+ const records = [];
2066
+ for (const entry of files) {
2067
+ if (!entry.record) continue;
2068
+ if (recordAddress(entry.record) !== wanted) continue;
2069
+ if (recordPidLiveness(entry.record, now) === "dead") continue;
2070
+ records.push(entry.record);
2071
+ }
2072
+ return records;
2073
+ }
2074
+ function addressHoldsLiveRecord(address, options = {}) {
2075
+ const { unreadable, records } = addressHolders(address, options);
2076
+ return unreadable || records.length > 0;
2077
+ }
2078
+ function anyRecordHoldsSomething(files, now) {
2079
+ return files.some(
2080
+ (entry) => !entry.record || recordPidLiveness(entry.record, now) !== "dead"
2081
+ );
2082
+ }
2083
+ function addressHolders(address, options = {}) {
2084
+ if (!address) return { unreadable: false, records: [] };
2085
+ const { files, problem } = readServeRecords(
2086
+ serveRunDir(options.env ?? process.env)
2087
+ );
2088
+ return {
2089
+ unreadable: problem === "unreadable",
2090
+ records: recordsHolding(files, address, (options.now ?? Date.now)())
2091
+ };
2092
+ }
2093
+ function listenerPidsOn(port) {
2094
+ if (!Number.isInteger(port) || port <= 0 || port > 65535) return [];
2095
+ try {
2096
+ const out = node_child_process.execFileSync(
2097
+ "lsof",
2098
+ ["-nP", `-iTCP:${port}`, "-sTCP:LISTEN", "-t"],
2099
+ { encoding: "utf8", timeout: 2e3 }
2100
+ );
2101
+ const pids = /* @__PURE__ */ new Set();
2102
+ for (const line of out.split("\n")) {
2103
+ const pid = Number.parseInt(line.trim(), 10);
2104
+ if (Number.isInteger(pid) && pid > 0) pids.add(pid);
2105
+ }
2106
+ return [...pids];
2107
+ } catch {
2108
+ return [];
2109
+ }
2110
+ }
2111
+ function readRecordForPid(pid, env = process.env) {
2112
+ if (!Number.isInteger(pid) || pid <= 0) return null;
2113
+ const file = path$1.join(serveRunDir(env), `${pid}.json`);
2114
+ try {
2115
+ const entry = parseRecord(JSON.parse(fs.readFileSync(file, "utf8")), file);
2116
+ return entry.record && entry.record.pid === pid ? entry.record : null;
2117
+ } catch {
2118
+ return null;
2119
+ }
2120
+ }
2059
2121
  function pidLiveness(pid, options = {}) {
2060
2122
  try {
2061
2123
  process.kill(pid, 0);
@@ -2096,8 +2158,18 @@ function classifyRecord(record, now, liveness = recordPidLiveness(record, now))
2096
2158
  return age > HEARTBEAT_TIMEOUT_MS ? "wedged" : "live";
2097
2159
  }
2098
2160
  function recordAddress(record) {
2099
- const host = record.host.includes(":") ? `[${record.host}]` : record.host;
2100
- return `http://${host}:${record.port}`;
2161
+ return addressOf("http:", canonicalHost(record.host), record.port);
2162
+ }
2163
+ function canonicalHost(host) {
2164
+ const bare = host.startsWith("[") && host.endsWith("]") ? host.slice(1, -1) : host;
2165
+ const unqualified = bare.endsWith(".") ? bare.slice(0, -1) : bare;
2166
+ const lower = unqualified.toLowerCase();
2167
+ if (lower === "localhost") return "127.0.0.1";
2168
+ if (lower === "::1" || lower === "0:0:0:0:0:0:0:1") return "::1";
2169
+ return bare;
2170
+ }
2171
+ function addressOf(scheme, host, port) {
2172
+ return `${scheme}//${host.includes(":") ? `[${host}]` : host}:${port}`;
2101
2173
  }
2102
2174
  function classifyUnreachable(error) {
2103
2175
  const name = error?.name;
@@ -2320,12 +2392,24 @@ async function discoverDaemons(options = {}) {
2320
2392
  wedged,
2321
2393
  noRecordsAtAll: files.length === 0,
2322
2394
  // A dead PID is positive evidence; timeout, malformed JSON or a stopped
2323
- // heartbeat is not. Do not spawn over a busy or temporarily unreadable daemon.
2324
- // The liveness question is asked the way `registry.py::scan` asks it, so a
2395
+ // heartbeat is not. Do not spawn over an address a record says is held. The
2396
+ // liveness question is asked the way `registry.py::scan` asks it, so a
2325
2397
  // zombie record is dead here too and cannot block spawning forever.
2326
- blocksSpawn: problem === "unreadable" || files.some(
2327
- (entry) => !entry.record || recordPidLiveness(entry.record, now()) !== "dead"
2328
- )
2398
+ //
2399
+ // SCOPED TO THE CONFIGURED ADDRESS (2026-09-23). It used to be scoped to the
2400
+ // ROOT - any record anywhere in this config root with a non-dead pid - which
2401
+ // forbade the spawn onto the address this app was actually configured for
2402
+ // because something else in the root had a live record. `addressHoldsLiveRecord`
2403
+ // is the same predicate for the addresses this app may also spawn on, so the
2404
+ // per-address gate in `backend-service.ts` and this verdict cannot disagree.
2405
+ // A caller that names NO address gets the old root-wide answer instead
2406
+ // (`anyRecordHoldsSomething`): there is then no address this verdict can call
2407
+ // free, and answering "free" on no evidence is the direction this module never
2408
+ // takes. The unparseable record lives in that arm for the same reason - it
2409
+ // cannot be attributed to an address at all - and what it used to stand for, a
2410
+ // daemon holding a port and not answering, is now asked directly of the port by
2411
+ // the spawn gate's own occupancy probe.
2412
+ blocksSpawn: problem === "unreadable" || (configuredAddress === null ? anyRecordHoldsSomething(files, now()) : recordsHolding(files, configuredAddress, now()).length > 0)
2329
2413
  };
2330
2414
  for (const rejection of rejected) {
2331
2415
  log(
@@ -2341,9 +2425,13 @@ function normaliseAddress(url) {
2341
2425
  if (!url) return null;
2342
2426
  try {
2343
2427
  const parsed = new URL(url);
2344
- const host = parsed.hostname.includes(":") ? `[${parsed.hostname}]` : parsed.hostname;
2428
+ if (!parsed.hostname) return null;
2345
2429
  const port = parsed.port || (parsed.protocol === "https:" ? "443" : "80");
2346
- return `${parsed.protocol}//${host}:${port}`;
2430
+ return addressOf(
2431
+ parsed.protocol,
2432
+ canonicalHost(parsed.hostname),
2433
+ Number(port)
2434
+ );
2347
2435
  } catch {
2348
2436
  return null;
2349
2437
  }
@@ -6290,6 +6378,17 @@ class DaemonStateMachine {
6290
6378
  getUrl() {
6291
6379
  return this.identity?.url ?? null;
6292
6380
  }
6381
+ /**
6382
+ * The connection's own snapshot.
6383
+ *
6384
+ * IT RETURNS EVERY FIELD EXCEPT `addressSubstitution`, and the Omission is the
6385
+ * point rather than a type trick (agent round 2, R2-1): that field is DERIVED
6386
+ * from the address this app is actually on, so it is assembled by the manager
6387
+ * (`BackendServiceManager.getStatusSnapshot`) which is the only object that holds
6388
+ * both the configured address and the connection. A machine that could write it
6389
+ * here would be a second producer, which is exactly how the field came to claim a
6390
+ * fallback the app had already left.
6391
+ */
6293
6392
  snapshot() {
6294
6393
  return {
6295
6394
  state: this.state,
@@ -7302,6 +7401,110 @@ function readinessPollDelayMs(elapsedMs) {
7302
7401
  return READINESS_POLL_INTERVAL_MS;
7303
7402
  }
7304
7403
  const DESKTOP_TOKEN_FILENAME = "desktop-token";
7404
+ function portOf(address) {
7405
+ try {
7406
+ const parsed = Number.parseInt(new URL(address).port, 10);
7407
+ return Number.isInteger(parsed) && parsed > 0 ? parsed : null;
7408
+ } catch {
7409
+ return null;
7410
+ }
7411
+ }
7412
+ function installName(prefix) {
7413
+ return prefix ? path$1.basename(prefix) : "";
7414
+ }
7415
+ function describeStartedAt(startedAtMs) {
7416
+ const started = new Date(startedAtMs);
7417
+ if (started.toDateString() === (/* @__PURE__ */ new Date()).toDateString()) {
7418
+ return `started ${started.toLocaleTimeString(void 0, {
7419
+ hour: "numeric",
7420
+ minute: "2-digit"
7421
+ })}`;
7422
+ }
7423
+ return `started ${started.toLocaleDateString(void 0, {
7424
+ year: "numeric",
7425
+ month: "short",
7426
+ day: "numeric"
7427
+ })}`;
7428
+ }
7429
+ function describeOccupant(occupant) {
7430
+ const facts = [];
7431
+ if (occupant.pid !== null) {
7432
+ facts.push(
7433
+ occupant.pidSource === "listener" ? `pid ${occupant.pid} read off the listening socket` : `pid ${occupant.pid}`
7434
+ );
7435
+ }
7436
+ const install = occupant.installKind || installName(occupant.prefix);
7437
+ if (install) facts.push(install);
7438
+ if (occupant.version) facts.push(`v${occupant.version}`);
7439
+ if (occupant.startedAtMs !== null) {
7440
+ facts.push(describeStartedAt(occupant.startedAtMs));
7441
+ }
7442
+ if (occupant.desktopReadStatus !== null) {
7443
+ facts.push(
7444
+ classifyDesktopAnswer(occupant.desktopReadStatus) === "refused" ? `refused this app's credential for its desktop plane (HTTP ${occupant.desktopReadStatus})` : `answered this app's desktop read with HTTP ${occupant.desktopReadStatus}`
7445
+ );
7446
+ }
7447
+ return facts.length > 0 ? ` (${facts.join(", ")})` : "";
7448
+ }
7449
+ const HOLDER_CLASS = {
7450
+ daemon: {
7451
+ one: "is running a Local Operator daemon this app has no key for",
7452
+ many: "are running Local Operator daemons this app has no key for"
7453
+ },
7454
+ record: {
7455
+ one: "is listed as still running in this app's own records",
7456
+ many: "are listed as still running in this app's own records"
7457
+ }
7458
+ };
7459
+ function listAddresses(items) {
7460
+ if (items.length === 0) return "";
7461
+ if (items.length === 1) return items[0];
7462
+ return `${items.slice(0, -1).join(", ")} and ${items[items.length - 1]}`;
7463
+ }
7464
+ function describeHolder(occupancy) {
7465
+ return `${occupancy.occupant.address}${describeOccupant(occupancy.occupant)}`;
7466
+ }
7467
+ function describeHolders(refusals) {
7468
+ const fragments = [];
7469
+ for (const kind of ["daemon", "record"]) {
7470
+ const group = refusals.filter((occupancy) => occupancy.kind === kind);
7471
+ if (group.length === 0) continue;
7472
+ fragments.push(
7473
+ `${listAddresses(group.map(describeHolder))} ${HOLDER_CLASS[kind][group.length === 1 ? "one" : "many"]}`
7474
+ );
7475
+ }
7476
+ for (const occupancy of refusals) {
7477
+ if (occupancy.kind !== "silent") continue;
7478
+ fragments.push(
7479
+ `${describeHolder(occupancy)} did not answer this app's probe with anything it could use (${occupancy.detail})`
7480
+ );
7481
+ }
7482
+ if (refusals.some((occupancy) => occupancy.kind === "unreadable")) {
7483
+ fragments.push(
7484
+ "this app's own records could not be read, so no address it may serve on could be called free"
7485
+ );
7486
+ }
7487
+ return fragments.join("; ");
7488
+ }
7489
+ const holderCount = (refusals) => refusals.length;
7490
+ function reclaimClause(refusals) {
7491
+ const pids = refusals.map((occupancy) => occupancy.occupant.pid).filter((pid) => pid !== null);
7492
+ if (pids.length === 0) return "";
7493
+ const single = holderCount(refusals) === 1 && pids.length === 1;
7494
+ const command = single ? `lop services reclaim ${pids[0]}` : "lop services reclaim <pid>";
7495
+ const direct = single ? "it from the install that owns it" : "them from the installs that own them";
7496
+ return ` Stop ${direct} with ${command} (lop services status lists what is running).`;
7497
+ }
7498
+ function describeSpawnRefusal(refusals) {
7499
+ const named = refusals.filter((occupancy) => occupancy.kind !== "unreadable");
7500
+ const stopped = named.length === 0 ? "Nothing was started" : named.length === 1 ? "Nothing was started over it" : "Nothing was started over them";
7501
+ return `${describeHolders(refusals)}. ${stopped}.${reclaimClause(refusals)} It keeps probing for a server it can open.`;
7502
+ }
7503
+ const FALLBACK_SPAWN_URL = "http://127.0.0.1:8080";
7504
+ const NO_SUBSTITUTION_REASON = Object.freeze({
7505
+ holder: null,
7506
+ reclaim: null
7507
+ });
7305
7508
  const execPromise = node_util.promisify(node_child_process.exec);
7306
7509
  const ENV_VAR_REGEX = /^([^=]+)=(.*)$/;
7307
7510
  function classifyDesktopAnswer(status2) {
@@ -7329,7 +7532,50 @@ class BackendServiceManager {
7329
7532
  startupMode = "NOT_STARTED";
7330
7533
  port;
7331
7534
  backendUrl;
7535
+ /**
7536
+ * The address this app is CONFIGURED to serve on, which never rotates.
7537
+ *
7538
+ * `backendUrl` is where the app is TALKING - a daemon discovery adopted, or the
7539
+ * fallback address after a fallback spawn - so a spawn gate keyed on it would
7540
+ * ask "may I start a daemon here" about whatever the app happens to be attached
7541
+ * to, which is somebody else's daemon. The config address is a separate fact and
7542
+ * it is the one the gate needs.
7543
+ */
7544
+ configuredUrl;
7545
+ /**
7546
+ * The addresses this app may spawn a managed daemon on after the configured one,
7547
+ * best first (`FALLBACK_SPAWN_URL` by default).
7548
+ *
7549
+ * A field rather than a bare constant so a rig can exercise the fallback on a
7550
+ * port the kernel picked, which is the same reason `DiscoverOptions.fetchImpl`
7551
+ * is injectable: proving this path must not require binding a fixed port on a
7552
+ * machine somebody else is using.
7553
+ */
7554
+ fallbackSpawnUrls;
7555
+ /**
7556
+ * What stopped the last spawn attempt, when what stopped it was an address this
7557
+ * app may spawn on being held by something IT DOES NOT OWN. Null when nothing
7558
+ * did.
7559
+ *
7560
+ * Read by `index.ts` through `isStartBlockedByOccupiedAddress()`: a false return
7561
+ * from `start()` is the one place the app used to quit, and the whole point of
7562
+ * this field is that this class of failure is not a reason to take the app down.
7563
+ */
7564
+ spawnRefusals = null;
7332
7565
  remoteConfigured = false;
7566
+ /**
7567
+ * The last address this app served on that was NOT the one it is configured for,
7568
+ * and the reason the gate gave for moving there.
7569
+ *
7570
+ * NEITHER IS THE CLAIM - both are history. The claim itself is derived on every
7571
+ * snapshot from the address the connection is on (`addressSubstitutionFor`), so a
7572
+ * reason recorded here can only reach a surface while the app is genuinely on
7573
+ * that other address (agent round 2, R2-1/R2-2). That is what makes it safe to
7574
+ * write the reason at the moment the gate makes its decision, which is the only
7575
+ * moment the holder is observable at all.
7576
+ */
7577
+ servedElsewhere = null;
7578
+ substitutionReason = NO_SUBSTITUTION_REASON;
7333
7579
  /** A failed probe or unreadable record is not evidence that spawning is safe. */
7334
7580
  discoveryBlocksSpawn = false;
7335
7581
  /**
@@ -7540,7 +7786,98 @@ class BackendServiceManager {
7540
7786
  * "I am not allowed" into "it is down".
7541
7787
  */
7542
7788
  getStatusSnapshot() {
7543
- return this.daemonState.snapshot();
7789
+ const snapshot2 = this.daemonState.snapshot();
7790
+ return {
7791
+ ...snapshot2,
7792
+ addressSubstitution: this.addressSubstitutionFor(snapshot2)
7793
+ };
7794
+ }
7795
+ /**
7796
+ * Where this app is serving, relative to the address it is configured for.
7797
+ *
7798
+ * THE INVARIANT (agent round 2, R2-1): a snapshot's address claim is a FUNCTION
7799
+ * OF THE ADDRESS THE APP IS ACTUALLY USING, never of the last decision a gate
7800
+ * made. The first version of this field was written once by the spawn gate, and
7801
+ * that produced two wrong answers in opposite directions:
7802
+ *
7803
+ * - a launch that ATTACHES to a daemon discovered on another address (the
7804
+ * second launch of the incident - the fallback daemon is still there and the
7805
+ * persisted credential still opens it) records nothing, so the app was
7806
+ * silently on another address, which is the invisibility D1 exists to remove;
7807
+ * - a substitution recorded earlier was never retracted: once recovery attached
7808
+ * the app back to the configured address the record still said `serving:
7809
+ * <fallback>`, beside a snapshot whose own `url` was the configured address,
7810
+ * and its reclaim advice named a pid that may by then be the daemon the
7811
+ * operator is using.
7812
+ *
7813
+ * It also answers agent round 2's R2-2 for free: the claim is derived from
7814
+ * `isRunning` and the attached URL, so a start that never landed - a readiness
7815
+ * timeout, a throw, a stop - reports nothing at all, where a record written at
7816
+ * intent time announced a return over a dead backend.
7817
+ *
7818
+ * `holder`/`reclaim` are the one fact the derivation cannot recover, because the
7819
+ * gate is the only witness to what held the address; they are kept beside the
7820
+ * landing (`substitutionReasonFor`, which the landing publishes) and reach a surface
7821
+ * only through an arm
7822
+ * that is true. Null holder/reclaim is the honest state for a launch that adopted
7823
+ * somebody else's daemon: it never asked the configured address anything.
7824
+ */
7825
+ addressSubstitutionFor(snapshot2) {
7826
+ const configured = normaliseAddress(this.configuredUrl);
7827
+ if (!configured || !snapshot2.url || !isServerReachable(snapshot2.state)) {
7828
+ return null;
7829
+ }
7830
+ const serving = normaliseAddress(snapshot2.url);
7831
+ if (!serving) return null;
7832
+ if (serving !== configured) {
7833
+ return {
7834
+ kind: "substituted",
7835
+ configured,
7836
+ serving,
7837
+ ...this.substitutionReason
7838
+ };
7839
+ }
7840
+ return this.servedElsewhere ? { kind: "returned", configured, serving: this.servedElsewhere } : null;
7841
+ }
7842
+ /**
7843
+ * Adopt a validated daemon, and record WHERE the app landed and WHY it landed there.
7844
+ *
7845
+ * Every landing in this class goes through here - the spawn's registration and
7846
+ * both attach paths - which is what makes the claim a property of the app's
7847
+ * address rather than of one code path (agent round 2, R2-1). `servedElsewhere`
7848
+ * survives a landing on the configured address on purpose: it is what a return
7849
+ * has to name.
7850
+ *
7851
+ * THE REASON TRAVELS WITH THE LANDING rather than being written beside it (agent
7852
+ * round 3, R3-1). It used to be cleared here and re-recorded by the caller AFTER
7853
+ * this call returned, which meant the snapshot this landing pushed carried
7854
+ * `holder: null` for a fallback spawn - the ADOPTED arm's sentence about a daemon
7855
+ * the app had just started, and with the act withheld - until the next read. Now
7856
+ * the spawn path hands the reason in and the landing publishes it, so there is one
7857
+ * push per landing and it is the true one. The default is the empty pair, which is
7858
+ * what an attach path means: this app observed no holder because it never asked.
7859
+ */
7860
+ attachDaemon(identity, options, reason = NO_SUBSTITUTION_REASON) {
7861
+ this.daemonState.attach(identity, options);
7862
+ const configured = normaliseAddress(this.configuredUrl);
7863
+ const landed = normaliseAddress(identity.url);
7864
+ if (landed && configured && landed !== configured) {
7865
+ this.servedElsewhere = landed;
7866
+ }
7867
+ this.substitutionReason = reason;
7868
+ }
7869
+ /**
7870
+ * The reason a fallback was taken, composed where the gate's refusals are still in
7871
+ * hand - and HANDED to the landing rather than written beside it (agent round 2,
7872
+ * R2-2 for the timing, agent round 3, R3-1 for the hand-off). A start that never
7873
+ * lands never carries this anywhere, and the claim is not there to render it
7874
+ * anyway.
7875
+ */
7876
+ substitutionReasonFor(refusals) {
7877
+ return refusals.length > 0 ? {
7878
+ holder: describeHolders(refusals),
7879
+ reclaim: reclaimClause(refusals).trim() || null
7880
+ } : NO_SUBSTITUTION_REASON;
7544
7881
  }
7545
7882
  getStreamRelay() {
7546
7883
  if (!this.streamRelay || this.streamRelayUrl !== this.backendUrl || this.streamRelayToken !== this.desktopToken) {
@@ -7720,6 +8057,7 @@ class BackendServiceManager {
7720
8057
  */
7721
8058
  constructor(options = {}) {
7722
8059
  this.userShellPath = options.userShellPath;
8060
+ this.fallbackSpawnUrls = options.fallbackSpawnUrls ?? [FALLBACK_SPAWN_URL];
7723
8061
  try {
7724
8062
  const apiUrl = new URL(backendConfig.VITE_LOCAL_OPERATOR_API_URL);
7725
8063
  this.port = Number.parseInt(apiUrl.port, 10) || 1111;
@@ -7740,6 +8078,7 @@ class BackendServiceManager {
7740
8078
  error
7741
8079
  );
7742
8080
  }
8081
+ this.configuredUrl = this.backendUrl;
7743
8082
  this.venvPath = managedVenvPath({
7744
8083
  platform: process.platform,
7745
8084
  home: electron.app.getPath("home"),
@@ -8420,7 +8759,7 @@ class BackendServiceManager {
8420
8759
  `Attached to daemon ${candidate.address} (pid ${candidate.record.pid}, v${candidate.identity.version}, ${candidate.record.install_kind || "kind unknown"}, record ${candidate.file})${previousUrl !== candidate.address ? ` - backend URL moved from ${previousUrl}` : ""}`,
8421
8760
  LogFileType.BACKEND
8422
8761
  );
8423
- this.daemonState.attach(
8762
+ this.attachDaemon(
8424
8763
  {
8425
8764
  url: candidate.address,
8426
8765
  instanceId: candidate.identity.instanceId,
@@ -8511,7 +8850,7 @@ class BackendServiceManager {
8511
8850
  this.attachedRecord = null;
8512
8851
  this.isExternalBackend = true;
8513
8852
  this.startupMode = "EXISTING_SERVER";
8514
- this.daemonState.attach(
8853
+ this.attachDaemon(
8515
8854
  {
8516
8855
  url: this.backendUrl,
8517
8856
  instanceId: "",
@@ -8639,8 +8978,8 @@ class BackendServiceManager {
8639
8978
  return true;
8640
8979
  }
8641
8980
  if (epoch !== this.startEpoch || this.isAppClosing) return false;
8642
- if (!this.managerMaySpawn || this.remoteConfigured || this.discoveryBlocksSpawn) {
8643
- const noSpawnReason = this.managerMaySpawn ? this.remoteConfigured ? `the configured target (${this.backendUrl}) is remote` : "a local daemon may still be running and could not be attached" : "VITE_DISABLE_BACKEND_MANAGER=true";
8981
+ if (!this.managerMaySpawn || this.remoteConfigured) {
8982
+ const noSpawnReason = this.managerMaySpawn ? `the configured target (${this.backendUrl}) is remote` : "VITE_DISABLE_BACKEND_MANAGER=true";
8644
8983
  logger.info(
8645
8984
  `No daemon discovered, and this app is configured not to spawn one (${noSpawnReason}).`,
8646
8985
  LogFileType.BACKEND
@@ -8649,12 +8988,21 @@ class BackendServiceManager {
8649
8988
  this.startHealthCheck();
8650
8989
  return false;
8651
8990
  }
8652
- const occupancy = await this.configuredOriginOccupancy();
8653
- if (occupancy) {
8654
- this.observeOriginOccupancy(occupancy);
8991
+ const { target: target2, refusals } = await this.resolveSpawnTarget();
8992
+ this.spawnRefusals = target2 ? null : refusals;
8993
+ if (!target2) {
8994
+ this.observeSpawnRefusal(refusals);
8655
8995
  this.startHealthCheck();
8656
8996
  return false;
8657
8997
  }
8998
+ this.port = target2.port;
8999
+ this.backendUrl = target2.address;
9000
+ if (refusals.length > 0) {
9001
+ logger.warn(
9002
+ `Starting a managed daemon on ${target2.address} instead: ${describeHolders(refusals)}.`,
9003
+ LogFileType.BACKEND
9004
+ );
9005
+ }
8658
9006
  this.desktopToken = this.mintDesktopToken();
8659
9007
  let captured = null;
8660
9008
  try {
@@ -8723,7 +9071,8 @@ class BackendServiceManager {
8723
9071
  break;
8724
9072
  if (healthy) {
8725
9073
  this.isRunning = true;
8726
- await this.registerOwnedDaemon(child);
9074
+ const reason = this.substitutionReasonFor(refusals);
9075
+ await this.registerOwnedDaemon(child, reason);
8727
9076
  this.startHealthCheck();
8728
9077
  this.notifyBackendReady();
8729
9078
  return true;
@@ -8788,7 +9137,7 @@ class BackendServiceManager {
8788
9137
  * second arm is the case of an install predating the record format, which is
8789
9138
  * reachable on a fixed port exactly as the deprecated adoption path allows.
8790
9139
  */
8791
- async registerOwnedDaemon(child) {
9140
+ async registerOwnedDaemon(child, reason) {
8792
9141
  const identity = await this.ownedDaemonIdentity(child);
8793
9142
  if (!identity) {
8794
9143
  logger.warn(
@@ -8797,7 +9146,7 @@ class BackendServiceManager {
8797
9146
  );
8798
9147
  return;
8799
9148
  }
8800
- this.daemonState.attach(identity, { owned: true });
9149
+ this.attachDaemon(identity, { owned: true }, reason);
8801
9150
  this.daemonState.setPairing(DAEMON_PAIRED);
8802
9151
  this.notifyStatus();
8803
9152
  logger.info(
@@ -9106,16 +9455,15 @@ class BackendServiceManager {
9106
9455
  * F-2), and it returns a `silent` occupancy below rather than a licence to
9107
9456
  * spawn.
9108
9457
  */
9109
- async configuredOriginOccupancy() {
9110
- const probe = await probeUnidentified(this.backendUrl, {
9458
+ async configuredOriginOccupancy(address) {
9459
+ const probe = await probeUnidentified(address, {
9111
9460
  timeoutMs: PROBE_TIMEOUT_MS
9112
9461
  });
9113
9462
  switch (probe.reason) {
9114
9463
  case "identity-mismatch":
9115
9464
  return {
9116
9465
  kind: "daemon",
9117
- pid: probe.identity?.pid ?? null,
9118
- version: probe.identity?.version ?? "",
9466
+ occupant: this.occupantOf(address, probe.identity ?? null),
9119
9467
  detail: probe.detail
9120
9468
  };
9121
9469
  /*
@@ -9135,11 +9483,12 @@ class BackendServiceManager {
9135
9483
  return {
9136
9484
  kind: "silent",
9137
9485
  cause: "other",
9486
+ occupant: this.occupantOf(address, null),
9138
9487
  detail: probe.detail
9139
9488
  };
9140
9489
  case "not-a-daemon":
9141
9490
  logger.info(
9142
- `${this.backendUrl}${HEALTH_PATH$1} answered and is not a Local Operator daemon (${probe.detail}); starting a daemon anyway, as this app always has.`,
9491
+ `${address}${HEALTH_PATH$1} answered and is not a Local Operator daemon (${probe.detail}); starting a daemon anyway, as this app always has.`,
9143
9492
  LogFileType.BACKEND
9144
9493
  );
9145
9494
  return null;
@@ -9147,32 +9496,203 @@ class BackendServiceManager {
9147
9496
  return probe.cause === "refused" ? null : {
9148
9497
  kind: "silent",
9149
9498
  cause: probe.cause ?? "other",
9499
+ occupant: this.occupantOf(address, null),
9150
9500
  detail: probe.detail
9151
9501
  };
9152
9502
  default:
9153
9503
  return {
9154
9504
  kind: "silent",
9155
9505
  cause: "other",
9506
+ occupant: this.occupantOf(address, null),
9156
9507
  detail: probe.detail
9157
9508
  };
9158
9509
  }
9159
9510
  }
9511
+ /**
9512
+ * One address's holder, from the facts already in hand.
9513
+ *
9514
+ * WHERE EACH FACT COMES FROM, because the copy has to be able to say it. The
9515
+ * occupant's own `/health` is asked first and is the only source that proves who
9516
+ * holds the address; when it did not answer with an identity - a timeout, a
9517
+ * non-200, a body with no `instance_id` - the LISTENING SOCKET is asked instead
9518
+ * (`listenerPidsOn`), and `pidSource` carries which of the two produced the pid
9519
+ * so the sentence reports a kernel fact as a kernel fact.
9520
+ *
9521
+ * The record lookup goes the other way round: it is keyed by the pid, so it adds
9522
+ * `started_at` and the published install to whichever source named the holder.
9523
+ * A holder that ALSO has a record in this app's own root is usually one of this
9524
+ * app's own daemons from an earlier run, and that is worth saying out loud.
9525
+ */
9526
+ occupantOf(address, identity) {
9527
+ let pid = identity && Number.isInteger(identity.pid) && identity.pid > 0 ? identity.pid : null;
9528
+ let pidSource = pid === null ? null : "answer";
9529
+ if (pid === null) {
9530
+ const port = portOf(address);
9531
+ const [found] = port === null ? [] : listenerPidsOn(port);
9532
+ if (found !== void 0) {
9533
+ pid = found;
9534
+ pidSource = "listener";
9535
+ }
9536
+ }
9537
+ const record = pid === null ? null : readRecordForPid(pid);
9538
+ return {
9539
+ address,
9540
+ pid,
9541
+ pidSource,
9542
+ version: identity?.version || record?.version || "",
9543
+ prefix: identity?.prefix || record?.prefix || "",
9544
+ installKind: identity?.installKind || record?.install_kind || "",
9545
+ startedAtMs: record ? record.started_at * 1e3 : null,
9546
+ // Probe-side occupancy never carries this: `configuredOriginOccupancy` is
9547
+ // asked about an address the attach path did not just read, and inventing
9548
+ // a status here would be the same class of guess the record arm avoids.
9549
+ desktopReadStatus: null
9550
+ };
9551
+ }
9552
+ /**
9553
+ * The addresses this app may spawn a managed daemon on, best first.
9554
+ *
9555
+ * The configured address leads, so a fallback spawn does not make the app forget
9556
+ * where it was told to serve: `backendUrl` rotates to wherever the app ended up,
9557
+ * while this list is recomputed from the configuration on every attempt. The rest
9558
+ * are the fallback budget, de-duplicated by `normaliseAddress` so a configuration
9559
+ * that already names 8080 does not probe one address twice - and the de-dup reads
9560
+ * through `canonicalHost`, so a configuration naming `localhost` is not probed a
9561
+ * second time as `127.0.0.1` (review round 1, R1-6: this comment asserted that
9562
+ * folding before `normaliseAddress` did it, which is how the assertion and the
9563
+ * comparison could disagree without either looking wrong).
9564
+ */
9565
+ spawnAddresses() {
9566
+ const addresses = [this.configuredUrl, ...this.fallbackSpawnUrls];
9567
+ const seen = /* @__PURE__ */ new Set();
9568
+ const allowed = [];
9569
+ for (const address of addresses) {
9570
+ const normalised = normaliseAddress(address);
9571
+ if (!normalised || seen.has(normalised)) continue;
9572
+ if (portOf(normalised) === null) continue;
9573
+ seen.add(normalised);
9574
+ allowed.push(normalised);
9575
+ }
9576
+ return allowed;
9577
+ }
9578
+ /**
9579
+ * Record where this attempt's daemon is going, relative to the address this app
9580
+ * is configured for (design round 1, D1).
9581
+ *
9582
+ * WHY THE SNAPSHOT AND NOT ONLY THE LOG LINE. Measured on this change: serving on
9583
+ * the fallback address was pixel-for-pixel invisible - the "attached on 8080" and
9584
+ * "attached on 1111" frames of the whole surface hashed identically - and the
9585
+ * operator's own question in that state had no in-product answer while they
9586
+ * worked. What is recorded here is what the band renders.
9587
+ *
9588
+ /**
9589
+ * The first address this attempt may actually start a daemon on, and what the
9590
+ * gate found on every address that refused.
9591
+ *
9592
+ * TWO GATES PER ADDRESS, and each answers a different question. The record gate
9593
+ * (`addressHoldsLiveRecord`) is "does this app's own registry say a process holds
9594
+ * this address": its subject is a pid, and it needs no network. The occupancy
9595
+ * probe is "would a child of mine be able to bind this address", asked of the
9596
+ * address itself - and it exists precisely because the two came apart on the
9597
+ * operator's machine, where discovery had no record to work with, the app spawned
9598
+ * onto a port a daemon was already serving, and the child died on `[Errno 48]`
9599
+ * every ~10 s (see the comment at the call site).
9600
+ *
9601
+ * ORDER MATTERS and it is the record gate first: a live record for the address
9602
+ * means the address is held by something whose pid we can already name, and
9603
+ * probing it would only add an answer to a question already answered.
9604
+ */
9605
+ async resolveSpawnTarget() {
9606
+ const refusals = [];
9607
+ for (const address of this.spawnAddresses()) {
9608
+ const port = portOf(address);
9609
+ if (port === null) continue;
9610
+ if (addressHoldsLiveRecord(address)) {
9611
+ const { unreadable, records } = addressHolders(address);
9612
+ if (unreadable) {
9613
+ refusals.push({
9614
+ kind: "unreadable",
9615
+ occupant: {
9616
+ address,
9617
+ pid: null,
9618
+ pidSource: null,
9619
+ version: "",
9620
+ prefix: "",
9621
+ installKind: "",
9622
+ startedAtMs: null,
9623
+ desktopReadStatus: null
9624
+ },
9625
+ detail: "this app's own records could not be read, so no address it may serve on could be called free"
9626
+ });
9627
+ continue;
9628
+ }
9629
+ const record = records[0] ?? null;
9630
+ const observed = this.answeredButUnusable?.address === address ? this.answeredButUnusable.status : null;
9631
+ refusals.push({
9632
+ kind: "record",
9633
+ occupant: {
9634
+ address,
9635
+ pid: record?.pid ?? null,
9636
+ // The record is this app's own file about its own child, so the pid
9637
+ // is this app's own answer rather than a socket's.
9638
+ pidSource: record ? "answer" : null,
9639
+ version: record?.version ?? "",
9640
+ prefix: record?.prefix ?? "",
9641
+ installKind: record?.install_kind ?? "",
9642
+ startedAtMs: record ? record.started_at * 1e3 : null,
9643
+ desktopReadStatus: observed
9644
+ },
9645
+ /*
9646
+ * Not user-facing: the record arm is rendered by `HOLDER_CLASS.record`, which
9647
+ * words the fact for a reader. This is the diagnostic beside it, for a log or
9648
+ * a debugger, so it keeps the module's own nouns.
9649
+ */
9650
+ detail: `a serve record names ${address} with a pid that is not proven dead`
9651
+ });
9652
+ continue;
9653
+ }
9654
+ const occupancy = await this.configuredOriginOccupancy(address);
9655
+ if (!occupancy) return { target: { address, port }, refusals };
9656
+ refusals.push(occupancy);
9657
+ if (occupancy.kind === "silent") return { target: null, refusals };
9658
+ }
9659
+ return { target: null, refusals };
9660
+ }
9661
+ /**
9662
+ * Whether the last spawn attempt declined because an address this app may serve
9663
+ * on is held by something it does not own, so the app has nowhere of its own to
9664
+ * start a daemon.
9665
+ *
9666
+ * THIS IS THE ONE THING `index.ts` NEEDS FROM THIS CLASS, and it is the whole of
9667
+ * the incident it answers: a `false` from `start()` used to mean "quit", and this
9668
+ * class of false means the opposite - the app is fine, its address is taken, and
9669
+ * waiting is what recovers it.
9670
+ */
9671
+ isStartBlockedByOccupiedAddress() {
9672
+ return this.spawnRefusals !== null;
9673
+ }
9160
9674
  /**
9161
9675
  * Publish what the spawn gate saw, in the vocabulary the copy already uses.
9162
9676
  *
9163
- * Two outcomes reach here and each gets the state that names it: a Local
9677
+ * Three outcomes reach here and each gets the state that names it: a Local
9164
9678
  * Operator daemon this app may not drive is `unattachable` (state `wedged` - a
9165
9679
  * server IS running and this app did not attach to it, no banner claiming it is
9166
- * offline), and an address that did not answer in time is `unanswered` -
9680
+ * offline), an address that did not answer in time is `unanswered` -
9167
9681
  * `degraded`, usable, no banner, and still counted, because a budget that
9168
- * expired is not evidence of absence either way.
9169
- */
9170
- observeOriginOccupancy(occupancy) {
9171
- const daemonLine = occupancy.kind === "daemon";
9172
- 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`;
9173
- const detail = `${what}. It keeps probing for a server it can open.`;
9682
+ * expired is not evidence of absence either way - and a registry this app could
9683
+ * not read is `no-candidate`, which claims only that this app has no daemon of its
9684
+ * own (review round 1, R1-3).
9685
+ */
9686
+ observeSpawnRefusal(refusals) {
9687
+ const detail = describeSpawnRefusal(refusals);
9688
+ const holder = refusals.find(
9689
+ (occupancy) => occupancy.kind === "daemon" || occupancy.kind === "record"
9690
+ );
9691
+ const silent = refusals.find(
9692
+ (occupancy) => occupancy.kind === "silent"
9693
+ );
9174
9694
  this.daemonState.observe(
9175
- occupancy.kind === "silent" ? { kind: "unanswered", cause: occupancy.cause, detail } : { kind: "unattachable", detail }
9695
+ holder ? { kind: "unattachable", detail } : silent ? { kind: "unanswered", cause: silent.cause, detail } : { kind: "no-candidate", detail }
9176
9696
  );
9177
9697
  logger.info(`Not spawning a daemon: ${detail}`, LogFileType.BACKEND);
9178
9698
  this.notifyStatus();
@@ -9466,7 +9986,7 @@ class BackendServiceManager {
9466
9986
  if (this.process && this.process.exitCode === null && this.process.signalCode == null)
9467
9987
  return;
9468
9988
  if (await this.discoverAndAttach()) return;
9469
- if (!this.managerMaySpawn || this.remoteConfigured || this.isExternalBackend || this.discoveryBlocksSpawn)
9989
+ if (!this.managerMaySpawn || this.remoteConfigured || this.isExternalBackend)
9470
9990
  return;
9471
9991
  await this.start({ quiet: true });
9472
9992
  } finally {
@@ -30703,32 +31223,46 @@ electron.app.whenReady().then(async () => {
30703
31223
  startAttempts++;
30704
31224
  }
30705
31225
  if (!backendStarted) {
30706
- logger.error(
30707
- "Failed to start backend after installation, quitting app",
30708
- LogFileType.INSTALLER
30709
- );
30710
- reportBackendFailure(
30711
- "Failed to start the Local Operator backend service after installation. Please restart the application.",
30712
- LogFileType.INSTALLER
30713
- );
30714
- electron.app.quit();
30715
- return;
31226
+ if (backendService.isStartBlockedByOccupiedAddress()) {
31227
+ logger.error(
31228
+ "Failed to start backend after installation: every address this app may serve on is held by something it does not own. Continuing without quitting; the status surface names the holder and the app keeps probing.",
31229
+ LogFileType.INSTALLER
31230
+ );
31231
+ } else {
31232
+ logger.error(
31233
+ "Failed to start backend after installation, quitting app",
31234
+ LogFileType.INSTALLER
31235
+ );
31236
+ reportBackendFailure(
31237
+ "Failed to start the Local Operator backend service after installation. Please restart the application.",
31238
+ LogFileType.INSTALLER
31239
+ );
31240
+ electron.app.quit();
31241
+ return;
31242
+ }
30716
31243
  }
30717
31244
  } else {
30718
31245
  const backendStarted = await backendService.start({
30719
31246
  reuseDiscovery: true
30720
31247
  });
30721
31248
  if (!backendStarted) {
30722
- logger.error(
30723
- "Failed to start backend with existing installation, quitting app",
30724
- LogFileType.BACKEND
30725
- );
30726
- reportBackendFailure(
30727
- "Failed to start the Local Operator backend service. Please restart the application.",
30728
- LogFileType.BACKEND
30729
- );
30730
- electron.app.quit();
30731
- return;
31249
+ if (backendService.isStartBlockedByOccupiedAddress()) {
31250
+ logger.error(
31251
+ "Failed to start backend with existing installation: every address this app may serve on is held by something it does not own. Continuing without quitting; the status surface names the holder and the app keeps probing.",
31252
+ LogFileType.BACKEND
31253
+ );
31254
+ } else {
31255
+ logger.error(
31256
+ "Failed to start backend with existing installation, quitting app",
31257
+ LogFileType.BACKEND
31258
+ );
31259
+ reportBackendFailure(
31260
+ "Failed to start the Local Operator backend service. Please restart the application.",
31261
+ LogFileType.BACKEND
31262
+ );
31263
+ electron.app.quit();
31264
+ return;
31265
+ }
30732
31266
  }
30733
31267
  }
30734
31268
  }