local-operator-ui 0.30.28 → 0.30.30

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 +627 -61
  2. package/out/renderer/assets/{_basePickBy-DVGL4dyS.js → _basePickBy-CPsYVQWB.js} +1 -1
  3. package/out/renderer/assets/{_baseUniq-BSXxR5nj.js → _baseUniq-DLGgf-MG.js} +1 -1
  4. package/out/renderer/assets/{agent-details-page-C1L4BYPg.js → agent-details-page-Dk5IUnd-.js} +1 -1
  5. package/out/renderer/assets/{agent-hub-page-KuHsq4as.js → agent-hub-page-B4vQ7ciV.js} +1 -1
  6. package/out/renderer/assets/{agents-page-CMyFoTdV.js → agents-page-C88U0W1_.js} +2 -2
  7. package/out/renderer/assets/{arc-DYtCn3b7.js → arc-BNeqY63k.js} +1 -1
  8. package/out/renderer/assets/{architectureDiagram-IEHRJDOE-DT3LiUFK.js → architectureDiagram-IEHRJDOE-CtUGmGSQ.js} +1 -1
  9. package/out/renderer/assets/{blockDiagram-JOT3LUYC-CeYb8oXA.js → blockDiagram-JOT3LUYC-C6moozjO.js} +1 -1
  10. package/out/renderer/assets/{browser-page-DH0PUHZ6.js → browser-page-BURCNtzT.js} +1 -1
  11. package/out/renderer/assets/{browser-webauthn-prompt-D3cdsaO1.js → browser-webauthn-prompt-8Zq_BRcf.js} +1 -1
  12. package/out/renderer/assets/{c4Diagram-VJAJSXHY-BfLZhnkq.js → c4Diagram-VJAJSXHY-5_-LJprq.js} +1 -1
  13. package/out/renderer/assets/channel-B4PSxtWF.js +1 -0
  14. package/out/renderer/assets/{chunk-4BMEZGHF-XdpXDuYe.js → chunk-4BMEZGHF-CA9BbUte.js} +1 -1
  15. package/out/renderer/assets/{chunk-A2AXSNBT-B55G-7NN.js → chunk-A2AXSNBT-B0_KaWEY.js} +1 -1
  16. package/out/renderer/assets/{chunk-AEK57VVT-Cvjg3Mip.js → chunk-AEK57VVT-DtEnX2KS.js} +1 -1
  17. package/out/renderer/assets/{chunk-D6G4REZN-B20z3IQR.js → chunk-D6G4REZN-C0ti2z-O.js} +1 -1
  18. package/out/renderer/assets/{chunk-RZ5BOZE2-CBx1LpGz.js → chunk-RZ5BOZE2-Z1oOd5-p.js} +1 -1
  19. package/out/renderer/assets/{chunk-XZIHB7SX-DQ3yYz4L.js → chunk-XZIHB7SX-C05DfsK9.js} +1 -1
  20. package/out/renderer/assets/{classDiagram-GIVACNV2-DhyBtCQr.js → classDiagram-GIVACNV2-nXu6mING.js} +1 -1
  21. package/out/renderer/assets/{classDiagram-v2-COTLJTTW-DhyBtCQr.js → classDiagram-v2-COTLJTTW-nXu6mING.js} +1 -1
  22. package/out/renderer/assets/clone-DtFNWeKR.js +1 -0
  23. package/out/renderer/assets/{compact-pagination-Ahq7SAdj.js → compact-pagination-CzQ02nEw.js} +1 -1
  24. package/out/renderer/assets/{dagre-OKDRZEBW-D51os3J2.js → dagre-OKDRZEBW-BAXMHrvQ.js} +1 -1
  25. package/out/renderer/assets/{diagram-SSKATNLV-DiqlVRyZ.js → diagram-SSKATNLV-C9P77nAF.js} +1 -1
  26. package/out/renderer/assets/{diagram-VNBRO52H-wyOk-7rJ.js → diagram-VNBRO52H-xFRgDIAb.js} +1 -1
  27. package/out/renderer/assets/{erDiagram-Q7BY3M3F-ClwBz7Vl.js → erDiagram-Q7BY3M3F-BO4H8v6o.js} +1 -1
  28. package/out/renderer/assets/{flowDiagram-4HSFHLVR-6vCU0UK_.js → flowDiagram-4HSFHLVR-66rwm5Ys.js} +1 -1
  29. package/out/renderer/assets/{ganttDiagram-APWFNJXF-oQ2A5p_K.js → ganttDiagram-APWFNJXF-BqqnKK3l.js} +1 -1
  30. package/out/renderer/assets/{gitGraphDiagram-7IBYFJ6S-Jmz0NydS.js → gitGraphDiagram-7IBYFJ6S-BCX5W9Fv.js} +1 -1
  31. package/out/renderer/assets/{graph-CZlT6mkf.js → graph-MPlQJvAd.js} +1 -1
  32. package/out/renderer/assets/{index-BBIALF90.js → index-BlA78hq8.js} +1 -1
  33. package/out/renderer/assets/{index-CMKwoZ6K.js → index-SOwWJvc_.js} +405 -405
  34. package/out/renderer/assets/{index-NY8u3QY3.js → index-omFl7ggM.js} +1 -1
  35. package/out/renderer/assets/{infoDiagram-PH2N3AL5-CWdY5eB5.js → infoDiagram-PH2N3AL5-D5BTO0mH.js} +1 -1
  36. package/out/renderer/assets/{journeyDiagram-U35MCT3I-CZN3Z4Do.js → journeyDiagram-U35MCT3I-_t8-Br70.js} +1 -1
  37. package/out/renderer/assets/{kanban-definition-NDS4AKOZ-O78rt8N8.js → kanban-definition-NDS4AKOZ-BSX0xk8p.js} +1 -1
  38. package/out/renderer/assets/{layout-BCu2X7f9.js → layout-BEmQhA9p.js} +1 -1
  39. package/out/renderer/assets/{legacy-agents-page-V_ZM9450.js → legacy-agents-page-tWvKwmxt.js} +1 -1
  40. package/out/renderer/assets/{mermaid.core-CQECvCmy.js → mermaid.core-DHWyYSZ-.js} +5 -5
  41. package/out/renderer/assets/{mindmap-definition-ALO5MXBD-BYiKzv5D.js → mindmap-definition-ALO5MXBD-lcm9xUYF.js} +1 -1
  42. package/out/renderer/assets/{parseISO-DUtfRwUe.js → parseISO-BRYRtYXt.js} +1 -1
  43. package/out/renderer/assets/{pieDiagram-IB7DONF6-BK-dabrh.js → pieDiagram-IB7DONF6-B7h5vMH0.js} +1 -1
  44. package/out/renderer/assets/{quadrantDiagram-7GDLP6J5-DwNrRMPZ.js → quadrantDiagram-7GDLP6J5-8MSjEGwK.js} +1 -1
  45. package/out/renderer/assets/{radar-MK3ICKWK-Dwa-5BxU.js → radar-MK3ICKWK-rPCWWY0t.js} +1 -1
  46. package/out/renderer/assets/{radient-auth-buttons-DPj8zZ0H.js → radient-auth-buttons-CPaSSPI7.js} +1 -1
  47. package/out/renderer/assets/{requirementDiagram-KVF5MWMF-CAvi9bJA.js → requirementDiagram-KVF5MWMF-qLpc_srh.js} +1 -1
  48. package/out/renderer/assets/{sankeyDiagram-QLVOVGJD-BIJv0fDy.js → sankeyDiagram-QLVOVGJD-xL32Ga0-.js} +1 -1
  49. package/out/renderer/assets/{schedules-page-yik4dl4q.js → schedules-page-CyGDJ-Z-.js} +1 -1
  50. package/out/renderer/assets/{sequenceDiagram-X6HHIX6F-so-gEOtr.js → sequenceDiagram-X6HHIX6F-B-KNPCAR.js} +1 -1
  51. package/out/renderer/assets/{settings-page-C85MzeE6.js → settings-page-CuxTNnIH.js} +66 -66
  52. package/out/renderer/assets/{stateDiagram-DGXRK772-CI5xRVbz.js → stateDiagram-DGXRK772-BjCqsm5E.js} +1 -1
  53. package/out/renderer/assets/{stateDiagram-v2-YXO3MK2T-CGpOff_s.js → stateDiagram-v2-YXO3MK2T-D_SOQqRZ.js} +1 -1
  54. package/out/renderer/assets/{timeline-definition-BDJGKUSR-Cr3Lo1sS.js → timeline-definition-BDJGKUSR-ByfHlvXq.js} +1 -1
  55. package/out/renderer/assets/{use-agent-like-mutation-CWE6shLn.js → use-agent-like-mutation-B2YnyhlD.js} +1 -1
  56. package/out/renderer/assets/{xychartDiagram-VJFVF3MP-CUwvyhxh.js → xychartDiagram-VJFVF3MP-DiWQbreg.js} +1 -1
  57. package/out/renderer/index.html +1 -1
  58. package/package.json +3 -3
  59. package/out/renderer/assets/channel-CN16DSI1.js +0 -1
  60. package/out/renderer/assets/clone-C90IZifK.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
  }
@@ -223,6 +226,9 @@ const publicationDocument = zod.z.object({
223
226
  categories: zod.z.array(zod.z.string()),
224
227
  tags: zod.z.array(zod.z.string())
225
228
  }).partial().strict();
229
+ const catalogueScopeKind = zod.z.enum(["team", "agent"]);
230
+ const catalogueScopeName = zod.z.string().min(1).max(64);
231
+ const catalogueCursor = zod.z.string().min(1).max(256);
226
232
  const desktopRequestSchema = zod.z.discriminatedUnion("op", [
227
233
  zod.z.object({ op: zod.z.literal("capabilities") }).strict(),
228
234
  zod.z.object({ op: zod.z.literal("profiles.list") }).strict(),
@@ -272,7 +278,31 @@ const desktopRequestSchema = zod.z.discriminatedUnion("op", [
272
278
  * questions a list that hides them cannot answer: the open conversation's own
273
279
  * archived state, and an unarchive control on a row found by search.
274
280
  */
275
- include_archived: zod.z.boolean().optional()
281
+ include_archived: zod.z.boolean().optional(),
282
+ /*
283
+ * The four parameters that make the catalogue PAGEABLE, and the switch that
284
+ * makes the daemon count it.
285
+ *
286
+ * ALL FOUR ARE OPTIONAL AND DEFAULTED, which is the whole compatibility
287
+ * promise: a request that sends none of them is byte-identical to the one
288
+ * this app sent before they existed, and an older daemon is therefore fully
289
+ * supported. They are gated on the `session_catalogue_page` capability rather
290
+ * than on a `session_catalogue` version bump, for the reason that map's own
291
+ * register states (an EXISTING surface must keep working against a backend
292
+ * that lacks the new one): FastAPI silently ignores unknown query parameters,
293
+ * so an un-gated client asking for `scope_kind=team&scope_name=lopdev` would
294
+ * receive the UNSCOPED page and draw other teams' rows under that team, and an
295
+ * un-gated `cursor` would receive page one again and duplicate it. The client
296
+ * has to be able to ask whether the daemon understands these, and the
297
+ * capability map is how this codebase asks.
298
+ *
299
+ * They travel as ONE contract revision rather than three: counts without the
300
+ * scope could not be rendered consistently with that scope's paged rows.
301
+ */
302
+ scope_kind: catalogueScopeKind.optional(),
303
+ scope_name: catalogueScopeName.optional(),
304
+ cursor: catalogueCursor.optional(),
305
+ with_counts: zod.z.boolean().optional()
276
306
  }).strict(),
277
307
  zod.z.object({
278
308
  // Content search over the store: name, id, exact conversation body, and
@@ -1323,14 +1353,19 @@ function desktopEndpoint(request) {
1323
1353
  method: "PATCH",
1324
1354
  body: { request_id: request.requestId, ...request.fields }
1325
1355
  };
1326
- case "sessions.list":
1356
+ case "sessions.list": {
1357
+ const params = new URLSearchParams();
1358
+ params.set("limit", String(request.limit ?? 100));
1359
+ if (request.include_archived) params.set("include_archived", "true");
1360
+ if (request.scope_kind) params.set("scope_kind", request.scope_kind);
1361
+ if (request.scope_name) params.set("scope_name", request.scope_name);
1362
+ if (request.cursor) params.set("cursor", request.cursor);
1363
+ if (request.with_counts) params.set("with_counts", "true");
1327
1364
  return {
1328
- // Omitted when false, for the reason `sessions.search`'s own query
1329
- // states: the pre-flag request is what an older backend must keep
1330
- // seeing, and `false` is the route's default anyway.
1331
- path: `/v1/desktop/sessions?limit=${request.limit ?? 100}${request.include_archived ? "&include_archived=true" : ""}`,
1365
+ path: `/v1/desktop/sessions?${params}`,
1332
1366
  method: "GET"
1333
1367
  };
1368
+ }
1334
1369
  case "sessions.search": {
1335
1370
  const query = new URLSearchParams({
1336
1371
  q: request.q,
@@ -2056,6 +2091,65 @@ function readServeRecords(dir) {
2056
2091
  }
2057
2092
  return { files, problem: null };
2058
2093
  }
2094
+ function recordsHolding(files, address, now) {
2095
+ if (!address) return [];
2096
+ const wanted = normaliseAddress(address) ?? address;
2097
+ const records = [];
2098
+ for (const entry of files) {
2099
+ if (!entry.record) continue;
2100
+ if (recordAddress(entry.record) !== wanted) continue;
2101
+ if (recordPidLiveness(entry.record, now) === "dead") continue;
2102
+ records.push(entry.record);
2103
+ }
2104
+ return records;
2105
+ }
2106
+ function addressHoldsLiveRecord(address, options = {}) {
2107
+ const { unreadable, records } = addressHolders(address, options);
2108
+ return unreadable || records.length > 0;
2109
+ }
2110
+ function anyRecordHoldsSomething(files, now) {
2111
+ return files.some(
2112
+ (entry) => !entry.record || recordPidLiveness(entry.record, now) !== "dead"
2113
+ );
2114
+ }
2115
+ function addressHolders(address, options = {}) {
2116
+ if (!address) return { unreadable: false, records: [] };
2117
+ const { files, problem } = readServeRecords(
2118
+ serveRunDir(options.env ?? process.env)
2119
+ );
2120
+ return {
2121
+ unreadable: problem === "unreadable",
2122
+ records: recordsHolding(files, address, (options.now ?? Date.now)())
2123
+ };
2124
+ }
2125
+ function listenerPidsOn(port) {
2126
+ if (!Number.isInteger(port) || port <= 0 || port > 65535) return [];
2127
+ try {
2128
+ const out = node_child_process.execFileSync(
2129
+ "lsof",
2130
+ ["-nP", `-iTCP:${port}`, "-sTCP:LISTEN", "-t"],
2131
+ { encoding: "utf8", timeout: 2e3 }
2132
+ );
2133
+ const pids = /* @__PURE__ */ new Set();
2134
+ for (const line of out.split("\n")) {
2135
+ const pid = Number.parseInt(line.trim(), 10);
2136
+ if (Number.isInteger(pid) && pid > 0) pids.add(pid);
2137
+ }
2138
+ return [...pids];
2139
+ } catch {
2140
+ return [];
2141
+ }
2142
+ }
2143
+ function readRecordForPid(pid, env = process.env) {
2144
+ if (!Number.isInteger(pid) || pid <= 0) return null;
2145
+ const file = path$1.join(serveRunDir(env), `${pid}.json`);
2146
+ try {
2147
+ const entry = parseRecord(JSON.parse(fs.readFileSync(file, "utf8")), file);
2148
+ return entry.record && entry.record.pid === pid ? entry.record : null;
2149
+ } catch {
2150
+ return null;
2151
+ }
2152
+ }
2059
2153
  function pidLiveness(pid, options = {}) {
2060
2154
  try {
2061
2155
  process.kill(pid, 0);
@@ -2096,8 +2190,18 @@ function classifyRecord(record, now, liveness = recordPidLiveness(record, now))
2096
2190
  return age > HEARTBEAT_TIMEOUT_MS ? "wedged" : "live";
2097
2191
  }
2098
2192
  function recordAddress(record) {
2099
- const host = record.host.includes(":") ? `[${record.host}]` : record.host;
2100
- return `http://${host}:${record.port}`;
2193
+ return addressOf("http:", canonicalHost(record.host), record.port);
2194
+ }
2195
+ function canonicalHost(host) {
2196
+ const bare = host.startsWith("[") && host.endsWith("]") ? host.slice(1, -1) : host;
2197
+ const unqualified = bare.endsWith(".") ? bare.slice(0, -1) : bare;
2198
+ const lower = unqualified.toLowerCase();
2199
+ if (lower === "localhost") return "127.0.0.1";
2200
+ if (lower === "::1" || lower === "0:0:0:0:0:0:0:1") return "::1";
2201
+ return bare;
2202
+ }
2203
+ function addressOf(scheme, host, port) {
2204
+ return `${scheme}//${host.includes(":") ? `[${host}]` : host}:${port}`;
2101
2205
  }
2102
2206
  function classifyUnreachable(error) {
2103
2207
  const name = error?.name;
@@ -2320,12 +2424,24 @@ async function discoverDaemons(options = {}) {
2320
2424
  wedged,
2321
2425
  noRecordsAtAll: files.length === 0,
2322
2426
  // 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
2427
+ // heartbeat is not. Do not spawn over an address a record says is held. The
2428
+ // liveness question is asked the way `registry.py::scan` asks it, so a
2325
2429
  // 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
- )
2430
+ //
2431
+ // SCOPED TO THE CONFIGURED ADDRESS (2026-09-23). It used to be scoped to the
2432
+ // ROOT - any record anywhere in this config root with a non-dead pid - which
2433
+ // forbade the spawn onto the address this app was actually configured for
2434
+ // because something else in the root had a live record. `addressHoldsLiveRecord`
2435
+ // is the same predicate for the addresses this app may also spawn on, so the
2436
+ // per-address gate in `backend-service.ts` and this verdict cannot disagree.
2437
+ // A caller that names NO address gets the old root-wide answer instead
2438
+ // (`anyRecordHoldsSomething`): there is then no address this verdict can call
2439
+ // free, and answering "free" on no evidence is the direction this module never
2440
+ // takes. The unparseable record lives in that arm for the same reason - it
2441
+ // cannot be attributed to an address at all - and what it used to stand for, a
2442
+ // daemon holding a port and not answering, is now asked directly of the port by
2443
+ // the spawn gate's own occupancy probe.
2444
+ blocksSpawn: problem === "unreadable" || (configuredAddress === null ? anyRecordHoldsSomething(files, now()) : recordsHolding(files, configuredAddress, now()).length > 0)
2329
2445
  };
2330
2446
  for (const rejection of rejected) {
2331
2447
  log(
@@ -2341,9 +2457,13 @@ function normaliseAddress(url) {
2341
2457
  if (!url) return null;
2342
2458
  try {
2343
2459
  const parsed = new URL(url);
2344
- const host = parsed.hostname.includes(":") ? `[${parsed.hostname}]` : parsed.hostname;
2460
+ if (!parsed.hostname) return null;
2345
2461
  const port = parsed.port || (parsed.protocol === "https:" ? "443" : "80");
2346
- return `${parsed.protocol}//${host}:${port}`;
2462
+ return addressOf(
2463
+ parsed.protocol,
2464
+ canonicalHost(parsed.hostname),
2465
+ Number(port)
2466
+ );
2347
2467
  } catch {
2348
2468
  return null;
2349
2469
  }
@@ -6290,6 +6410,17 @@ class DaemonStateMachine {
6290
6410
  getUrl() {
6291
6411
  return this.identity?.url ?? null;
6292
6412
  }
6413
+ /**
6414
+ * The connection's own snapshot.
6415
+ *
6416
+ * IT RETURNS EVERY FIELD EXCEPT `addressSubstitution`, and the Omission is the
6417
+ * point rather than a type trick (agent round 2, R2-1): that field is DERIVED
6418
+ * from the address this app is actually on, so it is assembled by the manager
6419
+ * (`BackendServiceManager.getStatusSnapshot`) which is the only object that holds
6420
+ * both the configured address and the connection. A machine that could write it
6421
+ * here would be a second producer, which is exactly how the field came to claim a
6422
+ * fallback the app had already left.
6423
+ */
6293
6424
  snapshot() {
6294
6425
  return {
6295
6426
  state: this.state,
@@ -7302,6 +7433,110 @@ function readinessPollDelayMs(elapsedMs) {
7302
7433
  return READINESS_POLL_INTERVAL_MS;
7303
7434
  }
7304
7435
  const DESKTOP_TOKEN_FILENAME = "desktop-token";
7436
+ function portOf(address) {
7437
+ try {
7438
+ const parsed = Number.parseInt(new URL(address).port, 10);
7439
+ return Number.isInteger(parsed) && parsed > 0 ? parsed : null;
7440
+ } catch {
7441
+ return null;
7442
+ }
7443
+ }
7444
+ function installName(prefix) {
7445
+ return prefix ? path$1.basename(prefix) : "";
7446
+ }
7447
+ function describeStartedAt(startedAtMs) {
7448
+ const started = new Date(startedAtMs);
7449
+ if (started.toDateString() === (/* @__PURE__ */ new Date()).toDateString()) {
7450
+ return `started ${started.toLocaleTimeString(void 0, {
7451
+ hour: "numeric",
7452
+ minute: "2-digit"
7453
+ })}`;
7454
+ }
7455
+ return `started ${started.toLocaleDateString(void 0, {
7456
+ year: "numeric",
7457
+ month: "short",
7458
+ day: "numeric"
7459
+ })}`;
7460
+ }
7461
+ function describeOccupant(occupant) {
7462
+ const facts = [];
7463
+ if (occupant.pid !== null) {
7464
+ facts.push(
7465
+ occupant.pidSource === "listener" ? `pid ${occupant.pid} read off the listening socket` : `pid ${occupant.pid}`
7466
+ );
7467
+ }
7468
+ const install = occupant.installKind || installName(occupant.prefix);
7469
+ if (install) facts.push(install);
7470
+ if (occupant.version) facts.push(`v${occupant.version}`);
7471
+ if (occupant.startedAtMs !== null) {
7472
+ facts.push(describeStartedAt(occupant.startedAtMs));
7473
+ }
7474
+ if (occupant.desktopReadStatus !== null) {
7475
+ facts.push(
7476
+ 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}`
7477
+ );
7478
+ }
7479
+ return facts.length > 0 ? ` (${facts.join(", ")})` : "";
7480
+ }
7481
+ const HOLDER_CLASS = {
7482
+ daemon: {
7483
+ one: "is running a Local Operator daemon this app has no key for",
7484
+ many: "are running Local Operator daemons this app has no key for"
7485
+ },
7486
+ record: {
7487
+ one: "is listed as still running in this app's own records",
7488
+ many: "are listed as still running in this app's own records"
7489
+ }
7490
+ };
7491
+ function listAddresses(items) {
7492
+ if (items.length === 0) return "";
7493
+ if (items.length === 1) return items[0];
7494
+ return `${items.slice(0, -1).join(", ")} and ${items[items.length - 1]}`;
7495
+ }
7496
+ function describeHolder(occupancy) {
7497
+ return `${occupancy.occupant.address}${describeOccupant(occupancy.occupant)}`;
7498
+ }
7499
+ function describeHolders(refusals) {
7500
+ const fragments = [];
7501
+ for (const kind of ["daemon", "record"]) {
7502
+ const group = refusals.filter((occupancy) => occupancy.kind === kind);
7503
+ if (group.length === 0) continue;
7504
+ fragments.push(
7505
+ `${listAddresses(group.map(describeHolder))} ${HOLDER_CLASS[kind][group.length === 1 ? "one" : "many"]}`
7506
+ );
7507
+ }
7508
+ for (const occupancy of refusals) {
7509
+ if (occupancy.kind !== "silent") continue;
7510
+ fragments.push(
7511
+ `${describeHolder(occupancy)} did not answer this app's probe with anything it could use (${occupancy.detail})`
7512
+ );
7513
+ }
7514
+ if (refusals.some((occupancy) => occupancy.kind === "unreadable")) {
7515
+ fragments.push(
7516
+ "this app's own records could not be read, so no address it may serve on could be called free"
7517
+ );
7518
+ }
7519
+ return fragments.join("; ");
7520
+ }
7521
+ const holderCount = (refusals) => refusals.length;
7522
+ function reclaimClause(refusals) {
7523
+ const pids = refusals.map((occupancy) => occupancy.occupant.pid).filter((pid) => pid !== null);
7524
+ if (pids.length === 0) return "";
7525
+ const single = holderCount(refusals) === 1 && pids.length === 1;
7526
+ const command = single ? `lop services reclaim ${pids[0]}` : "lop services reclaim <pid>";
7527
+ const direct = single ? "it from the install that owns it" : "them from the installs that own them";
7528
+ return ` Stop ${direct} with ${command} (lop services status lists what is running).`;
7529
+ }
7530
+ function describeSpawnRefusal(refusals) {
7531
+ const named = refusals.filter((occupancy) => occupancy.kind !== "unreadable");
7532
+ const stopped = named.length === 0 ? "Nothing was started" : named.length === 1 ? "Nothing was started over it" : "Nothing was started over them";
7533
+ return `${describeHolders(refusals)}. ${stopped}.${reclaimClause(refusals)} It keeps probing for a server it can open.`;
7534
+ }
7535
+ const FALLBACK_SPAWN_URL = "http://127.0.0.1:8080";
7536
+ const NO_SUBSTITUTION_REASON = Object.freeze({
7537
+ holder: null,
7538
+ reclaim: null
7539
+ });
7305
7540
  const execPromise = node_util.promisify(node_child_process.exec);
7306
7541
  const ENV_VAR_REGEX = /^([^=]+)=(.*)$/;
7307
7542
  function classifyDesktopAnswer(status2) {
@@ -7329,7 +7564,50 @@ class BackendServiceManager {
7329
7564
  startupMode = "NOT_STARTED";
7330
7565
  port;
7331
7566
  backendUrl;
7567
+ /**
7568
+ * The address this app is CONFIGURED to serve on, which never rotates.
7569
+ *
7570
+ * `backendUrl` is where the app is TALKING - a daemon discovery adopted, or the
7571
+ * fallback address after a fallback spawn - so a spawn gate keyed on it would
7572
+ * ask "may I start a daemon here" about whatever the app happens to be attached
7573
+ * to, which is somebody else's daemon. The config address is a separate fact and
7574
+ * it is the one the gate needs.
7575
+ */
7576
+ configuredUrl;
7577
+ /**
7578
+ * The addresses this app may spawn a managed daemon on after the configured one,
7579
+ * best first (`FALLBACK_SPAWN_URL` by default).
7580
+ *
7581
+ * A field rather than a bare constant so a rig can exercise the fallback on a
7582
+ * port the kernel picked, which is the same reason `DiscoverOptions.fetchImpl`
7583
+ * is injectable: proving this path must not require binding a fixed port on a
7584
+ * machine somebody else is using.
7585
+ */
7586
+ fallbackSpawnUrls;
7587
+ /**
7588
+ * What stopped the last spawn attempt, when what stopped it was an address this
7589
+ * app may spawn on being held by something IT DOES NOT OWN. Null when nothing
7590
+ * did.
7591
+ *
7592
+ * Read by `index.ts` through `isStartBlockedByOccupiedAddress()`: a false return
7593
+ * from `start()` is the one place the app used to quit, and the whole point of
7594
+ * this field is that this class of failure is not a reason to take the app down.
7595
+ */
7596
+ spawnRefusals = null;
7332
7597
  remoteConfigured = false;
7598
+ /**
7599
+ * The last address this app served on that was NOT the one it is configured for,
7600
+ * and the reason the gate gave for moving there.
7601
+ *
7602
+ * NEITHER IS THE CLAIM - both are history. The claim itself is derived on every
7603
+ * snapshot from the address the connection is on (`addressSubstitutionFor`), so a
7604
+ * reason recorded here can only reach a surface while the app is genuinely on
7605
+ * that other address (agent round 2, R2-1/R2-2). That is what makes it safe to
7606
+ * write the reason at the moment the gate makes its decision, which is the only
7607
+ * moment the holder is observable at all.
7608
+ */
7609
+ servedElsewhere = null;
7610
+ substitutionReason = NO_SUBSTITUTION_REASON;
7333
7611
  /** A failed probe or unreadable record is not evidence that spawning is safe. */
7334
7612
  discoveryBlocksSpawn = false;
7335
7613
  /**
@@ -7540,7 +7818,98 @@ class BackendServiceManager {
7540
7818
  * "I am not allowed" into "it is down".
7541
7819
  */
7542
7820
  getStatusSnapshot() {
7543
- return this.daemonState.snapshot();
7821
+ const snapshot2 = this.daemonState.snapshot();
7822
+ return {
7823
+ ...snapshot2,
7824
+ addressSubstitution: this.addressSubstitutionFor(snapshot2)
7825
+ };
7826
+ }
7827
+ /**
7828
+ * Where this app is serving, relative to the address it is configured for.
7829
+ *
7830
+ * THE INVARIANT (agent round 2, R2-1): a snapshot's address claim is a FUNCTION
7831
+ * OF THE ADDRESS THE APP IS ACTUALLY USING, never of the last decision a gate
7832
+ * made. The first version of this field was written once by the spawn gate, and
7833
+ * that produced two wrong answers in opposite directions:
7834
+ *
7835
+ * - a launch that ATTACHES to a daemon discovered on another address (the
7836
+ * second launch of the incident - the fallback daemon is still there and the
7837
+ * persisted credential still opens it) records nothing, so the app was
7838
+ * silently on another address, which is the invisibility D1 exists to remove;
7839
+ * - a substitution recorded earlier was never retracted: once recovery attached
7840
+ * the app back to the configured address the record still said `serving:
7841
+ * <fallback>`, beside a snapshot whose own `url` was the configured address,
7842
+ * and its reclaim advice named a pid that may by then be the daemon the
7843
+ * operator is using.
7844
+ *
7845
+ * It also answers agent round 2's R2-2 for free: the claim is derived from
7846
+ * `isRunning` and the attached URL, so a start that never landed - a readiness
7847
+ * timeout, a throw, a stop - reports nothing at all, where a record written at
7848
+ * intent time announced a return over a dead backend.
7849
+ *
7850
+ * `holder`/`reclaim` are the one fact the derivation cannot recover, because the
7851
+ * gate is the only witness to what held the address; they are kept beside the
7852
+ * landing (`substitutionReasonFor`, which the landing publishes) and reach a surface
7853
+ * only through an arm
7854
+ * that is true. Null holder/reclaim is the honest state for a launch that adopted
7855
+ * somebody else's daemon: it never asked the configured address anything.
7856
+ */
7857
+ addressSubstitutionFor(snapshot2) {
7858
+ const configured = normaliseAddress(this.configuredUrl);
7859
+ if (!configured || !snapshot2.url || !isServerReachable(snapshot2.state)) {
7860
+ return null;
7861
+ }
7862
+ const serving = normaliseAddress(snapshot2.url);
7863
+ if (!serving) return null;
7864
+ if (serving !== configured) {
7865
+ return {
7866
+ kind: "substituted",
7867
+ configured,
7868
+ serving,
7869
+ ...this.substitutionReason
7870
+ };
7871
+ }
7872
+ return this.servedElsewhere ? { kind: "returned", configured, serving: this.servedElsewhere } : null;
7873
+ }
7874
+ /**
7875
+ * Adopt a validated daemon, and record WHERE the app landed and WHY it landed there.
7876
+ *
7877
+ * Every landing in this class goes through here - the spawn's registration and
7878
+ * both attach paths - which is what makes the claim a property of the app's
7879
+ * address rather than of one code path (agent round 2, R2-1). `servedElsewhere`
7880
+ * survives a landing on the configured address on purpose: it is what a return
7881
+ * has to name.
7882
+ *
7883
+ * THE REASON TRAVELS WITH THE LANDING rather than being written beside it (agent
7884
+ * round 3, R3-1). It used to be cleared here and re-recorded by the caller AFTER
7885
+ * this call returned, which meant the snapshot this landing pushed carried
7886
+ * `holder: null` for a fallback spawn - the ADOPTED arm's sentence about a daemon
7887
+ * the app had just started, and with the act withheld - until the next read. Now
7888
+ * the spawn path hands the reason in and the landing publishes it, so there is one
7889
+ * push per landing and it is the true one. The default is the empty pair, which is
7890
+ * what an attach path means: this app observed no holder because it never asked.
7891
+ */
7892
+ attachDaemon(identity, options, reason = NO_SUBSTITUTION_REASON) {
7893
+ this.daemonState.attach(identity, options);
7894
+ const configured = normaliseAddress(this.configuredUrl);
7895
+ const landed = normaliseAddress(identity.url);
7896
+ if (landed && configured && landed !== configured) {
7897
+ this.servedElsewhere = landed;
7898
+ }
7899
+ this.substitutionReason = reason;
7900
+ }
7901
+ /**
7902
+ * The reason a fallback was taken, composed where the gate's refusals are still in
7903
+ * hand - and HANDED to the landing rather than written beside it (agent round 2,
7904
+ * R2-2 for the timing, agent round 3, R3-1 for the hand-off). A start that never
7905
+ * lands never carries this anywhere, and the claim is not there to render it
7906
+ * anyway.
7907
+ */
7908
+ substitutionReasonFor(refusals) {
7909
+ return refusals.length > 0 ? {
7910
+ holder: describeHolders(refusals),
7911
+ reclaim: reclaimClause(refusals).trim() || null
7912
+ } : NO_SUBSTITUTION_REASON;
7544
7913
  }
7545
7914
  getStreamRelay() {
7546
7915
  if (!this.streamRelay || this.streamRelayUrl !== this.backendUrl || this.streamRelayToken !== this.desktopToken) {
@@ -7720,6 +8089,7 @@ class BackendServiceManager {
7720
8089
  */
7721
8090
  constructor(options = {}) {
7722
8091
  this.userShellPath = options.userShellPath;
8092
+ this.fallbackSpawnUrls = options.fallbackSpawnUrls ?? [FALLBACK_SPAWN_URL];
7723
8093
  try {
7724
8094
  const apiUrl = new URL(backendConfig.VITE_LOCAL_OPERATOR_API_URL);
7725
8095
  this.port = Number.parseInt(apiUrl.port, 10) || 1111;
@@ -7740,6 +8110,7 @@ class BackendServiceManager {
7740
8110
  error
7741
8111
  );
7742
8112
  }
8113
+ this.configuredUrl = this.backendUrl;
7743
8114
  this.venvPath = managedVenvPath({
7744
8115
  platform: process.platform,
7745
8116
  home: electron.app.getPath("home"),
@@ -8420,7 +8791,7 @@ class BackendServiceManager {
8420
8791
  `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
8792
  LogFileType.BACKEND
8422
8793
  );
8423
- this.daemonState.attach(
8794
+ this.attachDaemon(
8424
8795
  {
8425
8796
  url: candidate.address,
8426
8797
  instanceId: candidate.identity.instanceId,
@@ -8511,7 +8882,7 @@ class BackendServiceManager {
8511
8882
  this.attachedRecord = null;
8512
8883
  this.isExternalBackend = true;
8513
8884
  this.startupMode = "EXISTING_SERVER";
8514
- this.daemonState.attach(
8885
+ this.attachDaemon(
8515
8886
  {
8516
8887
  url: this.backendUrl,
8517
8888
  instanceId: "",
@@ -8639,8 +9010,8 @@ class BackendServiceManager {
8639
9010
  return true;
8640
9011
  }
8641
9012
  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";
9013
+ if (!this.managerMaySpawn || this.remoteConfigured) {
9014
+ const noSpawnReason = this.managerMaySpawn ? `the configured target (${this.backendUrl}) is remote` : "VITE_DISABLE_BACKEND_MANAGER=true";
8644
9015
  logger.info(
8645
9016
  `No daemon discovered, and this app is configured not to spawn one (${noSpawnReason}).`,
8646
9017
  LogFileType.BACKEND
@@ -8649,12 +9020,21 @@ class BackendServiceManager {
8649
9020
  this.startHealthCheck();
8650
9021
  return false;
8651
9022
  }
8652
- const occupancy = await this.configuredOriginOccupancy();
8653
- if (occupancy) {
8654
- this.observeOriginOccupancy(occupancy);
9023
+ const { target: target2, refusals } = await this.resolveSpawnTarget();
9024
+ this.spawnRefusals = target2 ? null : refusals;
9025
+ if (!target2) {
9026
+ this.observeSpawnRefusal(refusals);
8655
9027
  this.startHealthCheck();
8656
9028
  return false;
8657
9029
  }
9030
+ this.port = target2.port;
9031
+ this.backendUrl = target2.address;
9032
+ if (refusals.length > 0) {
9033
+ logger.warn(
9034
+ `Starting a managed daemon on ${target2.address} instead: ${describeHolders(refusals)}.`,
9035
+ LogFileType.BACKEND
9036
+ );
9037
+ }
8658
9038
  this.desktopToken = this.mintDesktopToken();
8659
9039
  let captured = null;
8660
9040
  try {
@@ -8723,7 +9103,8 @@ class BackendServiceManager {
8723
9103
  break;
8724
9104
  if (healthy) {
8725
9105
  this.isRunning = true;
8726
- await this.registerOwnedDaemon(child);
9106
+ const reason = this.substitutionReasonFor(refusals);
9107
+ await this.registerOwnedDaemon(child, reason);
8727
9108
  this.startHealthCheck();
8728
9109
  this.notifyBackendReady();
8729
9110
  return true;
@@ -8788,7 +9169,7 @@ class BackendServiceManager {
8788
9169
  * second arm is the case of an install predating the record format, which is
8789
9170
  * reachable on a fixed port exactly as the deprecated adoption path allows.
8790
9171
  */
8791
- async registerOwnedDaemon(child) {
9172
+ async registerOwnedDaemon(child, reason) {
8792
9173
  const identity = await this.ownedDaemonIdentity(child);
8793
9174
  if (!identity) {
8794
9175
  logger.warn(
@@ -8797,7 +9178,7 @@ class BackendServiceManager {
8797
9178
  );
8798
9179
  return;
8799
9180
  }
8800
- this.daemonState.attach(identity, { owned: true });
9181
+ this.attachDaemon(identity, { owned: true }, reason);
8801
9182
  this.daemonState.setPairing(DAEMON_PAIRED);
8802
9183
  this.notifyStatus();
8803
9184
  logger.info(
@@ -9106,16 +9487,15 @@ class BackendServiceManager {
9106
9487
  * F-2), and it returns a `silent` occupancy below rather than a licence to
9107
9488
  * spawn.
9108
9489
  */
9109
- async configuredOriginOccupancy() {
9110
- const probe = await probeUnidentified(this.backendUrl, {
9490
+ async configuredOriginOccupancy(address) {
9491
+ const probe = await probeUnidentified(address, {
9111
9492
  timeoutMs: PROBE_TIMEOUT_MS
9112
9493
  });
9113
9494
  switch (probe.reason) {
9114
9495
  case "identity-mismatch":
9115
9496
  return {
9116
9497
  kind: "daemon",
9117
- pid: probe.identity?.pid ?? null,
9118
- version: probe.identity?.version ?? "",
9498
+ occupant: this.occupantOf(address, probe.identity ?? null),
9119
9499
  detail: probe.detail
9120
9500
  };
9121
9501
  /*
@@ -9135,11 +9515,12 @@ class BackendServiceManager {
9135
9515
  return {
9136
9516
  kind: "silent",
9137
9517
  cause: "other",
9518
+ occupant: this.occupantOf(address, null),
9138
9519
  detail: probe.detail
9139
9520
  };
9140
9521
  case "not-a-daemon":
9141
9522
  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.`,
9523
+ `${address}${HEALTH_PATH$1} answered and is not a Local Operator daemon (${probe.detail}); starting a daemon anyway, as this app always has.`,
9143
9524
  LogFileType.BACKEND
9144
9525
  );
9145
9526
  return null;
@@ -9147,32 +9528,203 @@ class BackendServiceManager {
9147
9528
  return probe.cause === "refused" ? null : {
9148
9529
  kind: "silent",
9149
9530
  cause: probe.cause ?? "other",
9531
+ occupant: this.occupantOf(address, null),
9150
9532
  detail: probe.detail
9151
9533
  };
9152
9534
  default:
9153
9535
  return {
9154
9536
  kind: "silent",
9155
9537
  cause: "other",
9538
+ occupant: this.occupantOf(address, null),
9156
9539
  detail: probe.detail
9157
9540
  };
9158
9541
  }
9159
9542
  }
9543
+ /**
9544
+ * One address's holder, from the facts already in hand.
9545
+ *
9546
+ * WHERE EACH FACT COMES FROM, because the copy has to be able to say it. The
9547
+ * occupant's own `/health` is asked first and is the only source that proves who
9548
+ * holds the address; when it did not answer with an identity - a timeout, a
9549
+ * non-200, a body with no `instance_id` - the LISTENING SOCKET is asked instead
9550
+ * (`listenerPidsOn`), and `pidSource` carries which of the two produced the pid
9551
+ * so the sentence reports a kernel fact as a kernel fact.
9552
+ *
9553
+ * The record lookup goes the other way round: it is keyed by the pid, so it adds
9554
+ * `started_at` and the published install to whichever source named the holder.
9555
+ * A holder that ALSO has a record in this app's own root is usually one of this
9556
+ * app's own daemons from an earlier run, and that is worth saying out loud.
9557
+ */
9558
+ occupantOf(address, identity) {
9559
+ let pid = identity && Number.isInteger(identity.pid) && identity.pid > 0 ? identity.pid : null;
9560
+ let pidSource = pid === null ? null : "answer";
9561
+ if (pid === null) {
9562
+ const port = portOf(address);
9563
+ const [found] = port === null ? [] : listenerPidsOn(port);
9564
+ if (found !== void 0) {
9565
+ pid = found;
9566
+ pidSource = "listener";
9567
+ }
9568
+ }
9569
+ const record = pid === null ? null : readRecordForPid(pid);
9570
+ return {
9571
+ address,
9572
+ pid,
9573
+ pidSource,
9574
+ version: identity?.version || record?.version || "",
9575
+ prefix: identity?.prefix || record?.prefix || "",
9576
+ installKind: identity?.installKind || record?.install_kind || "",
9577
+ startedAtMs: record ? record.started_at * 1e3 : null,
9578
+ // Probe-side occupancy never carries this: `configuredOriginOccupancy` is
9579
+ // asked about an address the attach path did not just read, and inventing
9580
+ // a status here would be the same class of guess the record arm avoids.
9581
+ desktopReadStatus: null
9582
+ };
9583
+ }
9584
+ /**
9585
+ * The addresses this app may spawn a managed daemon on, best first.
9586
+ *
9587
+ * The configured address leads, so a fallback spawn does not make the app forget
9588
+ * where it was told to serve: `backendUrl` rotates to wherever the app ended up,
9589
+ * while this list is recomputed from the configuration on every attempt. The rest
9590
+ * are the fallback budget, de-duplicated by `normaliseAddress` so a configuration
9591
+ * that already names 8080 does not probe one address twice - and the de-dup reads
9592
+ * through `canonicalHost`, so a configuration naming `localhost` is not probed a
9593
+ * second time as `127.0.0.1` (review round 1, R1-6: this comment asserted that
9594
+ * folding before `normaliseAddress` did it, which is how the assertion and the
9595
+ * comparison could disagree without either looking wrong).
9596
+ */
9597
+ spawnAddresses() {
9598
+ const addresses = [this.configuredUrl, ...this.fallbackSpawnUrls];
9599
+ const seen = /* @__PURE__ */ new Set();
9600
+ const allowed = [];
9601
+ for (const address of addresses) {
9602
+ const normalised = normaliseAddress(address);
9603
+ if (!normalised || seen.has(normalised)) continue;
9604
+ if (portOf(normalised) === null) continue;
9605
+ seen.add(normalised);
9606
+ allowed.push(normalised);
9607
+ }
9608
+ return allowed;
9609
+ }
9610
+ /**
9611
+ * Record where this attempt's daemon is going, relative to the address this app
9612
+ * is configured for (design round 1, D1).
9613
+ *
9614
+ * WHY THE SNAPSHOT AND NOT ONLY THE LOG LINE. Measured on this change: serving on
9615
+ * the fallback address was pixel-for-pixel invisible - the "attached on 8080" and
9616
+ * "attached on 1111" frames of the whole surface hashed identically - and the
9617
+ * operator's own question in that state had no in-product answer while they
9618
+ * worked. What is recorded here is what the band renders.
9619
+ *
9620
+ /**
9621
+ * The first address this attempt may actually start a daemon on, and what the
9622
+ * gate found on every address that refused.
9623
+ *
9624
+ * TWO GATES PER ADDRESS, and each answers a different question. The record gate
9625
+ * (`addressHoldsLiveRecord`) is "does this app's own registry say a process holds
9626
+ * this address": its subject is a pid, and it needs no network. The occupancy
9627
+ * probe is "would a child of mine be able to bind this address", asked of the
9628
+ * address itself - and it exists precisely because the two came apart on the
9629
+ * operator's machine, where discovery had no record to work with, the app spawned
9630
+ * onto a port a daemon was already serving, and the child died on `[Errno 48]`
9631
+ * every ~10 s (see the comment at the call site).
9632
+ *
9633
+ * ORDER MATTERS and it is the record gate first: a live record for the address
9634
+ * means the address is held by something whose pid we can already name, and
9635
+ * probing it would only add an answer to a question already answered.
9636
+ */
9637
+ async resolveSpawnTarget() {
9638
+ const refusals = [];
9639
+ for (const address of this.spawnAddresses()) {
9640
+ const port = portOf(address);
9641
+ if (port === null) continue;
9642
+ if (addressHoldsLiveRecord(address)) {
9643
+ const { unreadable, records } = addressHolders(address);
9644
+ if (unreadable) {
9645
+ refusals.push({
9646
+ kind: "unreadable",
9647
+ occupant: {
9648
+ address,
9649
+ pid: null,
9650
+ pidSource: null,
9651
+ version: "",
9652
+ prefix: "",
9653
+ installKind: "",
9654
+ startedAtMs: null,
9655
+ desktopReadStatus: null
9656
+ },
9657
+ detail: "this app's own records could not be read, so no address it may serve on could be called free"
9658
+ });
9659
+ continue;
9660
+ }
9661
+ const record = records[0] ?? null;
9662
+ const observed = this.answeredButUnusable?.address === address ? this.answeredButUnusable.status : null;
9663
+ refusals.push({
9664
+ kind: "record",
9665
+ occupant: {
9666
+ address,
9667
+ pid: record?.pid ?? null,
9668
+ // The record is this app's own file about its own child, so the pid
9669
+ // is this app's own answer rather than a socket's.
9670
+ pidSource: record ? "answer" : null,
9671
+ version: record?.version ?? "",
9672
+ prefix: record?.prefix ?? "",
9673
+ installKind: record?.install_kind ?? "",
9674
+ startedAtMs: record ? record.started_at * 1e3 : null,
9675
+ desktopReadStatus: observed
9676
+ },
9677
+ /*
9678
+ * Not user-facing: the record arm is rendered by `HOLDER_CLASS.record`, which
9679
+ * words the fact for a reader. This is the diagnostic beside it, for a log or
9680
+ * a debugger, so it keeps the module's own nouns.
9681
+ */
9682
+ detail: `a serve record names ${address} with a pid that is not proven dead`
9683
+ });
9684
+ continue;
9685
+ }
9686
+ const occupancy = await this.configuredOriginOccupancy(address);
9687
+ if (!occupancy) return { target: { address, port }, refusals };
9688
+ refusals.push(occupancy);
9689
+ if (occupancy.kind === "silent") return { target: null, refusals };
9690
+ }
9691
+ return { target: null, refusals };
9692
+ }
9693
+ /**
9694
+ * Whether the last spawn attempt declined because an address this app may serve
9695
+ * on is held by something it does not own, so the app has nowhere of its own to
9696
+ * start a daemon.
9697
+ *
9698
+ * THIS IS THE ONE THING `index.ts` NEEDS FROM THIS CLASS, and it is the whole of
9699
+ * the incident it answers: a `false` from `start()` used to mean "quit", and this
9700
+ * class of false means the opposite - the app is fine, its address is taken, and
9701
+ * waiting is what recovers it.
9702
+ */
9703
+ isStartBlockedByOccupiedAddress() {
9704
+ return this.spawnRefusals !== null;
9705
+ }
9160
9706
  /**
9161
9707
  * Publish what the spawn gate saw, in the vocabulary the copy already uses.
9162
9708
  *
9163
- * Two outcomes reach here and each gets the state that names it: a Local
9709
+ * Three outcomes reach here and each gets the state that names it: a Local
9164
9710
  * Operator daemon this app may not drive is `unattachable` (state `wedged` - a
9165
9711
  * 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` -
9712
+ * offline), an address that did not answer in time is `unanswered` -
9167
9713
  * `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.`;
9714
+ * expired is not evidence of absence either way - and a registry this app could
9715
+ * not read is `no-candidate`, which claims only that this app has no daemon of its
9716
+ * own (review round 1, R1-3).
9717
+ */
9718
+ observeSpawnRefusal(refusals) {
9719
+ const detail = describeSpawnRefusal(refusals);
9720
+ const holder = refusals.find(
9721
+ (occupancy) => occupancy.kind === "daemon" || occupancy.kind === "record"
9722
+ );
9723
+ const silent = refusals.find(
9724
+ (occupancy) => occupancy.kind === "silent"
9725
+ );
9174
9726
  this.daemonState.observe(
9175
- occupancy.kind === "silent" ? { kind: "unanswered", cause: occupancy.cause, detail } : { kind: "unattachable", detail }
9727
+ holder ? { kind: "unattachable", detail } : silent ? { kind: "unanswered", cause: silent.cause, detail } : { kind: "no-candidate", detail }
9176
9728
  );
9177
9729
  logger.info(`Not spawning a daemon: ${detail}`, LogFileType.BACKEND);
9178
9730
  this.notifyStatus();
@@ -9466,7 +10018,7 @@ class BackendServiceManager {
9466
10018
  if (this.process && this.process.exitCode === null && this.process.signalCode == null)
9467
10019
  return;
9468
10020
  if (await this.discoverAndAttach()) return;
9469
- if (!this.managerMaySpawn || this.remoteConfigured || this.isExternalBackend || this.discoveryBlocksSpawn)
10021
+ if (!this.managerMaySpawn || this.remoteConfigured || this.isExternalBackend)
9470
10022
  return;
9471
10023
  await this.start({ quiet: true });
9472
10024
  } finally {
@@ -30703,32 +31255,46 @@ electron.app.whenReady().then(async () => {
30703
31255
  startAttempts++;
30704
31256
  }
30705
31257
  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;
31258
+ if (backendService.isStartBlockedByOccupiedAddress()) {
31259
+ logger.error(
31260
+ "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.",
31261
+ LogFileType.INSTALLER
31262
+ );
31263
+ } else {
31264
+ logger.error(
31265
+ "Failed to start backend after installation, quitting app",
31266
+ LogFileType.INSTALLER
31267
+ );
31268
+ reportBackendFailure(
31269
+ "Failed to start the Local Operator backend service after installation. Please restart the application.",
31270
+ LogFileType.INSTALLER
31271
+ );
31272
+ electron.app.quit();
31273
+ return;
31274
+ }
30716
31275
  }
30717
31276
  } else {
30718
31277
  const backendStarted = await backendService.start({
30719
31278
  reuseDiscovery: true
30720
31279
  });
30721
31280
  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;
31281
+ if (backendService.isStartBlockedByOccupiedAddress()) {
31282
+ logger.error(
31283
+ "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.",
31284
+ LogFileType.BACKEND
31285
+ );
31286
+ } else {
31287
+ logger.error(
31288
+ "Failed to start backend with existing installation, quitting app",
31289
+ LogFileType.BACKEND
31290
+ );
31291
+ reportBackendFailure(
31292
+ "Failed to start the Local Operator backend service. Please restart the application.",
31293
+ LogFileType.BACKEND
31294
+ );
31295
+ electron.app.quit();
31296
+ return;
31297
+ }
30732
31298
  }
30733
31299
  }
30734
31300
  }