local-operator-ui 0.26.3 → 0.26.5

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 (73) hide show
  1. package/out/main/index.js +834 -132
  2. package/out/preload/index.js +8 -1
  3. package/out/renderer/assets/{_basePickBy-wJDm0FQM.js → _basePickBy-BLuUp4QK.js} +1 -1
  4. package/out/renderer/assets/{_baseUniq-CcKmT_7c.js → _baseUniq-DePIps_6.js} +1 -1
  5. package/out/renderer/assets/{agent-details-page-DPNTGNIQ.js → agent-details-page-Db-YJzt6.js} +1 -1
  6. package/out/renderer/assets/{agent-hub-page-DDcxflg3.js → agent-hub-page-DRyyNTin.js} +1 -1
  7. package/out/renderer/assets/{agents-page-DDavoEDg.js → agents-page-DaXWx8aU.js} +2 -2
  8. package/out/renderer/assets/{arc-TtqBtXpS.js → arc-Z4zwjTAe.js} +1 -1
  9. package/out/renderer/assets/{architectureDiagram-IEHRJDOE-Sn3dwx1M.js → architectureDiagram-IEHRJDOE-DiG2qR12.js} +1 -1
  10. package/out/renderer/assets/{blockDiagram-JOT3LUYC-DmWRMx-8.js → blockDiagram-JOT3LUYC-pi13-4YI.js} +1 -1
  11. package/out/renderer/assets/browser-page-CNWAMKUe.js +1 -0
  12. package/out/renderer/assets/{c4Diagram-VJAJSXHY-kk1xWrtQ.js → c4Diagram-VJAJSXHY-DOyO4k2M.js} +1 -1
  13. package/out/renderer/assets/channel-BFPzik6A.js +1 -0
  14. package/out/renderer/assets/{chunk-4BMEZGHF-C0l8JKva.js → chunk-4BMEZGHF-C4kfm4yo.js} +1 -1
  15. package/out/renderer/assets/{chunk-A2AXSNBT-BcX6OXd5.js → chunk-A2AXSNBT-sKLqjp3u.js} +1 -1
  16. package/out/renderer/assets/{chunk-AEK57VVT-Ea-04ABD.js → chunk-AEK57VVT-QI8CQrmd.js} +1 -1
  17. package/out/renderer/assets/{chunk-D6G4REZN-CgZKUTL7.js → chunk-D6G4REZN-B1DgRB2C.js} +1 -1
  18. package/out/renderer/assets/{chunk-RZ5BOZE2-CaYdXK61.js → chunk-RZ5BOZE2-RLBIfyM0.js} +1 -1
  19. package/out/renderer/assets/{chunk-XZIHB7SX-eINKeCA_.js → chunk-XZIHB7SX-BjFARQFb.js} +1 -1
  20. package/out/renderer/assets/classDiagram-GIVACNV2-CWImqn1t.js +1 -0
  21. package/out/renderer/assets/classDiagram-v2-COTLJTTW-CWImqn1t.js +1 -0
  22. package/out/renderer/assets/clone-D-1PDEwk.js +1 -0
  23. package/out/renderer/assets/{compact-pagination-BGC_ltPs.js → compact-pagination-DPQei6f5.js} +1 -1
  24. package/out/renderer/assets/{dagre-OKDRZEBW-CRdErrN5.js → dagre-OKDRZEBW-GeATWU3w.js} +1 -1
  25. package/out/renderer/assets/{diagram-SSKATNLV-D1AJyKR9.js → diagram-SSKATNLV-D6hL_QPd.js} +1 -1
  26. package/out/renderer/assets/{diagram-VNBRO52H-BjgCteQB.js → diagram-VNBRO52H-BxOwjZ1b.js} +1 -1
  27. package/out/renderer/assets/{erDiagram-Q7BY3M3F-11QyodNt.js → erDiagram-Q7BY3M3F-BJk20kOF.js} +1 -1
  28. package/out/renderer/assets/{flowDiagram-4HSFHLVR-ou_Lc3fB.js → flowDiagram-4HSFHLVR-BbcLtPRv.js} +1 -1
  29. package/out/renderer/assets/{ganttDiagram-APWFNJXF-CcVshyxS.js → ganttDiagram-APWFNJXF-D0HNQeFR.js} +1 -1
  30. package/out/renderer/assets/{gitGraphDiagram-7IBYFJ6S-C0rtso3u.js → gitGraphDiagram-7IBYFJ6S-EhbqLwxm.js} +1 -1
  31. package/out/renderer/assets/{graph-Bc6JXDog.js → graph-Cu3KArle.js} +1 -1
  32. package/out/renderer/assets/icon-Bk3LTZOX.js +200 -0
  33. package/out/renderer/assets/icon-D-yj4FC9.css +1 -0
  34. package/out/renderer/assets/{index-Bo2Wge3p.js → index-8aG_nWVc.js} +378 -371
  35. package/out/renderer/assets/{index-BhElRiu7.js → index-CYq1FQ2s.js} +1 -1
  36. package/out/renderer/assets/{index-Cb6T4AFi.js → index-CboM5PdL.js} +1 -1
  37. package/out/renderer/assets/{infoDiagram-PH2N3AL5-BYkNE3tJ.js → infoDiagram-PH2N3AL5-Dxa-HGpK.js} +1 -1
  38. package/out/renderer/assets/{installer-DDldqUI5.js → installer-jsvAmRaf.js} +1 -1
  39. package/out/renderer/assets/{journeyDiagram-U35MCT3I-CUIyoA3C.js → journeyDiagram-U35MCT3I-Br4vrzlp.js} +1 -1
  40. package/out/renderer/assets/{kanban-definition-NDS4AKOZ-iPrfQ2oJ.js → kanban-definition-NDS4AKOZ-CeOXrKhE.js} +1 -1
  41. package/out/renderer/assets/{layout-CbD9heAw.js → layout-w7tbbHLe.js} +1 -1
  42. package/out/renderer/assets/{legacy-agents-page-D4ch8fHv.js → legacy-agents-page-vhQqiUoY.js} +1 -1
  43. package/out/renderer/assets/{mermaid.core-B2WQhMve.js → mermaid.core-QBBXcBK4.js} +5 -5
  44. package/out/renderer/assets/{mindmap-definition-ALO5MXBD-B-zVnzUG.js → mindmap-definition-ALO5MXBD-DKRUYw6a.js} +1 -1
  45. package/out/renderer/assets/{page-header-un3csrkZ.js → page-header-Bw6lWiL2.js} +1 -1
  46. package/out/renderer/assets/{parseISO-BFHy1myG.js → parseISO-Css5aQYl.js} +1 -1
  47. package/out/renderer/assets/{pieDiagram-IB7DONF6-D2Kfrawf.js → pieDiagram-IB7DONF6-CZCvLhMK.js} +1 -1
  48. package/out/renderer/assets/{quadrantDiagram-7GDLP6J5-KBSPvm-o.js → quadrantDiagram-7GDLP6J5-azGy65Fa.js} +1 -1
  49. package/out/renderer/assets/{radar-MK3ICKWK-CD0L2WL4.js → radar-MK3ICKWK-CTGAoI5c.js} +1 -1
  50. package/out/renderer/assets/{radient-auth-buttons-CLf4igff.js → radient-auth-buttons-DO7qzs5-.js} +1 -1
  51. package/out/renderer/assets/{requirementDiagram-KVF5MWMF-C1-0khAP.js → requirementDiagram-KVF5MWMF-ueLMHZrp.js} +1 -1
  52. package/out/renderer/assets/{sankeyDiagram-QLVOVGJD-DsGLUITt.js → sankeyDiagram-QLVOVGJD-CioMFt7y.js} +1 -1
  53. package/out/renderer/assets/schedules-page-K6EnN54w.js +120 -0
  54. package/out/renderer/assets/{sequenceDiagram-X6HHIX6F-Du4VNaA2.js → sequenceDiagram-X6HHIX6F-DLku8WUG.js} +1 -1
  55. package/out/renderer/assets/settings-page-Ba9k67Xk.js +312 -0
  56. package/out/renderer/assets/{stateDiagram-DGXRK772-DbUfrePQ.js → stateDiagram-DGXRK772-DjJMQUHb.js} +1 -1
  57. package/out/renderer/assets/stateDiagram-v2-YXO3MK2T-aXkG5aIh.js +1 -0
  58. package/out/renderer/assets/{timeline-definition-BDJGKUSR-BKMUc_3-.js → timeline-definition-BDJGKUSR-q6FY-C2d.js} +1 -1
  59. package/out/renderer/assets/{use-agent-like-mutation-CiYF_2uI.js → use-agent-like-mutation-DOacZ4RL.js} +1 -1
  60. package/out/renderer/assets/{xychartDiagram-VJFVF3MP-DFG0Vjf7.js → xychartDiagram-VJFVF3MP-CHOJM1A9.js} +1 -1
  61. package/out/renderer/index.html +3 -3
  62. package/out/renderer/installer.html +3 -3
  63. package/package.json +2 -2
  64. package/out/renderer/assets/browser-page-DgVk9bJD.js +0 -1
  65. package/out/renderer/assets/channel-n5Rajrsc.js +0 -1
  66. package/out/renderer/assets/classDiagram-GIVACNV2-CoTV4VPG.js +0 -1
  67. package/out/renderer/assets/classDiagram-v2-COTLJTTW-CoTV4VPG.js +0 -1
  68. package/out/renderer/assets/clone-DE7Ya7xI.js +0 -1
  69. package/out/renderer/assets/icon-B8Li1YYS.css +0 -1
  70. package/out/renderer/assets/icon-Bny6j4NZ.js +0 -200
  71. package/out/renderer/assets/schedules-page-wm_dIpC7.js +0 -115
  72. package/out/renderer/assets/settings-page-Cy6oljBR.js +0 -312
  73. package/out/renderer/assets/stateDiagram-v2-YXO3MK2T-MuPTTa0H.js +0 -1
package/out/main/index.js CHANGED
@@ -99,6 +99,9 @@ const variableKey = zod.z.string().min(1).max(128).refine(
99
99
  ).refine(isAddressableVariableKey);
100
100
  const variableType = zod.z.enum(["str", "int", "float", "bool", "list", "dict"]);
101
101
  const variableValue = zod.z.string().max(16384);
102
+ const WAKE_MESSAGE_MAX_CHARS = 2e3;
103
+ const wakeMessage = zod.z.string().min(1).max(WAKE_MESSAGE_MAX_CHARS);
104
+ const wakeId = zod.z.string().min(1).max(64);
102
105
  const scheduleWrite = zod.z.object({
103
106
  is_active: zod.z.boolean().nullish(),
104
107
  one_time: zod.z.boolean().nullish(),
@@ -620,6 +623,91 @@ const desktopRequestSchema = zod.z.discriminatedUnion("op", [
620
623
  sessionId,
621
624
  asideId: requestId
622
625
  }).strict(),
626
+ /*
627
+ * The wake surface: the Schedules page's machine-wide read and its writes.
628
+ *
629
+ * Deliberately NOT an extension of `sessions.*`. The session list is
630
+ * paginated by recency (`limit ≤ 500`), so a session armed once and never
631
+ * opened falls off it - and the page whose whole job is "every session that
632
+ * has wakes" would then list fewer sessions than exist. `wakes.list` reads
633
+ * the wake index instead, which is one small JSON file per wake-carrying
634
+ * session and is complete at any store size.
635
+ *
636
+ * The four ops sit behind the same router-level desktop bearer as their
637
+ * `sessions.*` siblings, and every one of them does its filesystem work off
638
+ * the event loop, so a listing over a cold store cannot stall the desktop
639
+ * plane's other readers.
640
+ */
641
+ zod.z.object({
642
+ op: zod.z.literal("wakes.list"),
643
+ /* Bounded like `sessions.list`: the field exists so a pathological
644
+ store degrades visibly (through `truncated`) rather than silently. */
645
+ limit: zod.z.number().int().min(1).max(500).optional(),
646
+ /* Dormant rows are the ones whose session was stopped, which the page
647
+ SHOWS ("parked") rather than hides: stopping a conversation parks its
648
+ wakes, and a management surface that dropped them would report a
649
+ scheduled task as gone when it is only parked. */
650
+ includeDormant: zod.z.boolean().optional()
651
+ }).strict(),
652
+ zod.z.object({
653
+ op: zod.z.literal("wakes.create"),
654
+ requestId,
655
+ /*
656
+ * The session half of the request is EXACTLY ONE of two shapes: name an
657
+ * existing `sessionId`, or name a `cwd` (plus an optional `target`) and
658
+ * have the backend create the conversation and arm the wake in one
659
+ * call. The exclusivity cannot be stated with a `.refine()` on this
660
+ * object - that turns the member into a `ZodEffects`, which a
661
+ * discriminated union cannot take (see `mcp.credentials.store`, which
662
+ * records the same trap) - so the API client enforces it on the way out
663
+ * with `wakeCreateBody`, and the backend refuses the malformed shape
664
+ * with a 422 rather than a 500.
665
+ */
666
+ sessionId: sessionId.optional(),
667
+ cwd: zod.z.string().min(1).max(4096).optional(),
668
+ target: target.optional(),
669
+ message: wakeMessage,
670
+ /*
671
+ * Timing, in the same spellings the wake tool and `lop wake create`
672
+ * take, parsed by the backend's own `parse_wake_duration` /
673
+ * `parse_wake_at`: a relative duration (`30m`), an ISO instant or the
674
+ * next `HH:MM`. The dialog's presets are `in`/`at` pairs so the common
675
+ * path never touches a calendar, and `Pick a time…` sends the ISO
676
+ * instant the picker yields.
677
+ */
678
+ in: zod.z.string().min(1).max(64).optional(),
679
+ at: zod.z.string().min(1).max(64).optional(),
680
+ every: zod.z.string().min(1).max(64).optional(),
681
+ until: zod.z.string().min(1).max(64).optional(),
682
+ limit: zod.z.number().int().min(1).optional(),
683
+ /*
684
+ * The conversation's own title, when a caller wants to override the one
685
+ * derived from the prompt.
686
+ *
687
+ * Carried because the interface declares it, and never set by this
688
+ * page: the prompt IS the name here (the backend writes it into the
689
+ * session's stored-title sidecar, which `resume.session_name` consults
690
+ * first), which is what keeps the create flow free of a "name" field
691
+ * nobody would fill in.
692
+ */
693
+ title: zod.z.string().min(1).max(200).optional()
694
+ }).strict(),
695
+ zod.z.object({
696
+ op: zod.z.literal("wakes.edit"),
697
+ sessionId,
698
+ wakeId,
699
+ /* Every field optional, and the body is sent with the absent ones
700
+ OMITTED rather than nulled: the backend applies the update against
701
+ the schedule it holds, so a `null` here would be a request to clear
702
+ a bound the user did not touch. */
703
+ message: wakeMessage.optional(),
704
+ in: zod.z.string().min(1).max(64).optional(),
705
+ at: zod.z.string().min(1).max(64).optional(),
706
+ every: zod.z.string().min(1).max(64).optional(),
707
+ until: zod.z.string().min(1).max(64).optional(),
708
+ limit: zod.z.number().int().min(1).optional()
709
+ }).strict(),
710
+ zod.z.object({ op: zod.z.literal("wakes.remove"), sessionId, wakeId }).strict(),
623
711
  zod.z.object({ op: zod.z.literal("mcp.list"), sessionId }).strict(),
624
712
  zod.z.object({
625
713
  op: zod.z.literal("mcp.credentials.store"),
@@ -849,6 +937,22 @@ function desktopRequestTooLargeDetail(op) {
849
937
  if (MESSAGE_OPS.has(op)) return DESKTOP_REQUEST_TOO_LARGE_DETAIL;
850
938
  return "This request is too large to send. Shorten the text in this form.";
851
939
  }
940
+ function wakeSessionHalf(request) {
941
+ if (request.sessionId) return { session_id: request.sessionId };
942
+ return {
943
+ ...request.cwd ? { cwd: request.cwd } : {},
944
+ ...request.target ? { target: request.target } : {}
945
+ };
946
+ }
947
+ function wakeTiming(request) {
948
+ return {
949
+ ...request.in !== void 0 ? { in: request.in } : {},
950
+ ...request.at !== void 0 ? { at: request.at } : {},
951
+ ...request.every !== void 0 ? { every: request.every } : {},
952
+ ...request.until !== void 0 ? { until: request.until } : {},
953
+ ...request.limit !== void 0 ? { limit: request.limit } : {}
954
+ };
955
+ }
852
956
  function desktopEndpoint(request) {
853
957
  switch (request.op) {
854
958
  case "capabilities":
@@ -1137,6 +1241,47 @@ function desktopEndpoint(request) {
1137
1241
  method: "GET"
1138
1242
  };
1139
1243
  }
1244
+ /*
1245
+ * The wake routes, mapped beside their `sessions.*` siblings: one op per
1246
+ * method, snake_case on the wire (`include_dormant`, `request_id`), which is
1247
+ * what the backend's models declare.
1248
+ */
1249
+ case "wakes.list": {
1250
+ const query = new URLSearchParams();
1251
+ if (request.limit !== void 0)
1252
+ query.set("limit", String(request.limit));
1253
+ if (request.includeDormant !== void 0)
1254
+ query.set("include_dormant", String(request.includeDormant));
1255
+ return {
1256
+ path: query.size > 0 ? `/v1/desktop/wakes?${query}` : "/v1/desktop/wakes",
1257
+ method: "GET"
1258
+ };
1259
+ }
1260
+ case "wakes.create":
1261
+ return {
1262
+ path: "/v1/desktop/wakes",
1263
+ method: "POST",
1264
+ body: {
1265
+ request_id: request.requestId,
1266
+ ...wakeSessionHalf(request),
1267
+ message: request.message,
1268
+ ...wakeTiming(request)
1269
+ }
1270
+ };
1271
+ case "wakes.edit":
1272
+ return {
1273
+ path: `/v1/desktop/wakes/${request.sessionId}/${request.wakeId}`,
1274
+ method: "PATCH",
1275
+ body: {
1276
+ ...request.message !== void 0 ? { message: request.message } : {},
1277
+ ...wakeTiming(request)
1278
+ }
1279
+ };
1280
+ case "wakes.remove":
1281
+ return {
1282
+ path: `/v1/desktop/wakes/${request.sessionId}/${request.wakeId}`,
1283
+ method: "DELETE"
1284
+ };
1140
1285
  case "legacy.jobs.list": {
1141
1286
  const query = new URLSearchParams();
1142
1287
  if (request.agentId) query.set("agent_id", request.agentId);
@@ -2735,6 +2880,49 @@ function clearPendingInstallMarker(dir) {
2735
2880
  fs.rmSync(path2, { force: true });
2736
2881
  return true;
2737
2882
  }
2883
+ const PENDING_SERVER_UPDATE_MARKER_FILE = "pending-server-update.json";
2884
+ function pendingServerUpdateMarkerPath(dir) {
2885
+ return path$1.join(dir, PENDING_SERVER_UPDATE_MARKER_FILE);
2886
+ }
2887
+ function writePendingServerUpdateMarker(dir, marker) {
2888
+ fs.mkdirSync(dir, { recursive: true });
2889
+ const target2 = pendingServerUpdateMarkerPath(dir);
2890
+ const temp = `${target2}.tmp`;
2891
+ fs.writeFileSync(temp, `${JSON.stringify(marker, null, 2)}
2892
+ `, "utf8");
2893
+ fs.renameSync(temp, target2);
2894
+ return marker;
2895
+ }
2896
+ function parsePendingServerUpdateMarker(raw) {
2897
+ try {
2898
+ const parsed = JSON.parse(raw);
2899
+ if (typeof parsed?.startedAt !== "string" || parsed.startedAt.length === 0) {
2900
+ return null;
2901
+ }
2902
+ return {
2903
+ before: typeof parsed.before === "string" ? parsed.before : null,
2904
+ target: typeof parsed.target === "string" ? parsed.target : null,
2905
+ startedAt: parsed.startedAt
2906
+ };
2907
+ } catch {
2908
+ return null;
2909
+ }
2910
+ }
2911
+ function readPendingServerUpdateMarker(dir) {
2912
+ const path2 = pendingServerUpdateMarkerPath(dir);
2913
+ if (!fs.existsSync(path2)) return null;
2914
+ try {
2915
+ return parsePendingServerUpdateMarker(fs.readFileSync(path2, "utf8"));
2916
+ } catch {
2917
+ return null;
2918
+ }
2919
+ }
2920
+ function clearPendingServerUpdateMarker(dir) {
2921
+ const path2 = pendingServerUpdateMarkerPath(dir);
2922
+ if (!fs.existsSync(path2)) return false;
2923
+ fs.rmSync(path2, { force: true });
2924
+ return true;
2925
+ }
2738
2926
  const LAST_INSTALL_ATTEMPT_FILE = "last-update-install.json";
2739
2927
  function lastInstallAttemptPath(dir) {
2740
2928
  return path$1.join(dir, LAST_INSTALL_ATTEMPT_FILE);
@@ -3314,6 +3502,33 @@ function pipxListNamesLocalOperator(output) {
3314
3502
  }
3315
3503
  const PYTHON_LIB_DIR = /^python\d/;
3316
3504
  const LOCAL_OPERATOR_DIST_INFO = /^local[-_]operator[-_]/;
3505
+ const PYPI_SOURCE_TOKEN = "pypi";
3506
+ const SOURCE_MARKER_SEPARATOR = /\s+/;
3507
+ function isSourceBuildRef(token) {
3508
+ if (typeof token !== "string") return false;
3509
+ const trimmed = token.trim();
3510
+ if (trimmed.length < 7 || trimmed.length > 40) return false;
3511
+ for (const char of trimmed) {
3512
+ if (!"0123456789abcdefABCDEF".includes(char)) return false;
3513
+ }
3514
+ return trimmed.toLowerCase() !== PYPI_SOURCE_TOKEN;
3515
+ }
3516
+ function readSourceRef(prefix) {
3517
+ try {
3518
+ const raw = fs.readFileSync(path$1.join(prefix, ".lop-source"), "utf8");
3519
+ const token = raw.split(SOURCE_MARKER_SEPARATOR).filter(Boolean)[0];
3520
+ return token && token.length > 0 ? token : null;
3521
+ } catch {
3522
+ return null;
3523
+ }
3524
+ }
3525
+ const LOCAL_OPERATOR_DIST_INFO_VERSION = /^local[-_]operator[-_](.+)\.dist-info$/;
3526
+ function newerDistInfoVersion(candidate, current) {
3527
+ if (current === null) return true;
3528
+ const order = compareVersions$1(candidate, current);
3529
+ if (order !== null) return order > 0;
3530
+ return compareVersions$1(current, candidate) === null && candidate > current;
3531
+ }
3317
3532
  function resolveDistributionMarkers(prefix) {
3318
3533
  const sitePackages = [];
3319
3534
  try {
@@ -3328,6 +3543,7 @@ function resolveDistributionMarkers(prefix) {
3328
3543
  if (fs.existsSync(windowsSitePackages)) sitePackages.push(windowsSitePackages);
3329
3544
  let installer = null;
3330
3545
  let editable = false;
3546
+ let version = null;
3331
3547
  for (const dir of sitePackages) {
3332
3548
  let entries;
3333
3549
  try {
@@ -3339,6 +3555,8 @@ function resolveDistributionMarkers(prefix) {
3339
3555
  if (!LOCAL_OPERATOR_DIST_INFO.test(entry)) continue;
3340
3556
  if (!entry.endsWith(".dist-info")) continue;
3341
3557
  const distInfo = path$1.join(dir, entry);
3558
+ const parsed = LOCAL_OPERATOR_DIST_INFO_VERSION.exec(entry)?.[1]?.trim();
3559
+ if (parsed && newerDistInfoVersion(parsed, version)) version = parsed;
3342
3560
  try {
3343
3561
  const installerText = fs.readFileSync(path$1.join(distInfo, "INSTALLER"), "utf8");
3344
3562
  installer = installerText.trim().toLowerCase() || installer;
@@ -3355,7 +3573,7 @@ function resolveDistributionMarkers(prefix) {
3355
3573
  }
3356
3574
  }
3357
3575
  }
3358
- return { installer, editable };
3576
+ return { installer, editable, version, sourceRef: readSourceRef(prefix) };
3359
3577
  }
3360
3578
  const TRAILING_SLASHES = /\/+$/;
3361
3579
  function commandSearchDirs(env, home) {
@@ -3377,6 +3595,14 @@ function commandSearchDirs(env, home) {
3377
3595
  add("/bin");
3378
3596
  return dirs;
3379
3597
  }
3598
+ function installerSearchPath(env = process.env, home = os.homedir()) {
3599
+ const inherited = (env.PATH ?? "").split(path$1.delimiter).map((dir) => dir.replace(TRAILING_SLASHES, "")).filter((dir) => dir.length > 0);
3600
+ const inheritedSet = new Set(inherited);
3601
+ const ours = commandSearchDirs(env, home).filter(
3602
+ (dir) => !inheritedSet.has(dir)
3603
+ );
3604
+ return [...ours, ...inherited].join(path$1.delimiter);
3605
+ }
3380
3606
  function uvToolBinDirs(env, home, listDir, platform) {
3381
3607
  const root = env.UV_TOOL_DIR ?? (env.XDG_DATA_HOME ? path$1.join(env.XDG_DATA_HOME, "uv", "tools") : path$1.join(home, ".local", "share", "uv", "tools"));
3382
3608
  const scriptDir = platform === "win32" ? "Scripts" : "bin";
@@ -3435,6 +3661,23 @@ function resolveCommandPath(name, options = {}) {
3435
3661
  }
3436
3662
  return null;
3437
3663
  }
3664
+ function resolveGlobalConsoleScript(options = {}) {
3665
+ if ((options.platform ?? process.platform) === "win32") {
3666
+ const first = (name) => {
3667
+ try {
3668
+ const resolved = node_child_process.execFileSync("where", [name], {
3669
+ encoding: "utf8",
3670
+ timeout: 5e3
3671
+ }).split("\n")[0]?.trim();
3672
+ return resolved && resolved.length > 0 ? resolved : null;
3673
+ } catch {
3674
+ return null;
3675
+ }
3676
+ };
3677
+ return first("local-operator") ?? first("lop");
3678
+ }
3679
+ return resolveCommandPath("local-operator", options) ?? resolveCommandPath("lop", options);
3680
+ }
3438
3681
  function readInstallIdentity(shimPath) {
3439
3682
  if (!shimPath) return { path: null };
3440
3683
  let realPath = null;
@@ -3461,9 +3704,30 @@ function readInstallIdentity(shimPath) {
3461
3704
  uvReceipt: existing(path$1.join(prefix, "uv-receipt.toml")),
3462
3705
  venvPrefix: fs.existsSync(path$1.join(prefix, "pyvenv.cfg")) ? prefix : null,
3463
3706
  installer: markers.installer,
3464
- editable: markers.editable
3707
+ editable: markers.editable,
3708
+ version: markers.version,
3709
+ sourceRef: markers.sourceRef
3465
3710
  };
3466
3711
  }
3712
+ function generationInstallRoot(identity) {
3713
+ const resolved = identity.realPath ?? identity.path;
3714
+ if (!resolved) return null;
3715
+ let executable;
3716
+ try {
3717
+ executable = fs.realpathSync(resolved);
3718
+ } catch {
3719
+ return null;
3720
+ }
3721
+ let dir = executable;
3722
+ for (; ; ) {
3723
+ const parent = path$1.dirname(dir);
3724
+ if (parent === dir) return null;
3725
+ if (path$1.basename(parent) === "generations") {
3726
+ return fs.existsSync(path$1.join(path$1.dirname(parent), "current")) ? dir : null;
3727
+ }
3728
+ dir = parent;
3729
+ }
3730
+ }
3467
3731
  function classifyGlobalInstall(identity) {
3468
3732
  if (!identity.path) return "global-unknown";
3469
3733
  if (identity.editable) return "editable";
@@ -3484,8 +3748,8 @@ function classifyGlobalInstall(identity) {
3484
3748
  }
3485
3749
  function resolveGlobalInstallPlan(input) {
3486
3750
  const kind = classifyGlobalInstall(input.identity);
3487
- const sourceBuild = kind === "editable" || kind === "uv-tool" && Boolean(input.lopUpdatePath);
3488
- const detail = input.identity.path ? `local-operator resolves to ${input.identity.path}${input.identity.realPath && input.identity.realPath !== input.identity.path ? ` (${input.identity.realPath})` : ""}, classified as ${kind}${sourceBuild ? ", built from source on this machine, so it follows the checkout rather than the published release" : ""}` : "local-operator was not found on PATH";
3751
+ const sourceBuild = kind === "editable" || kind === "uv-tool" && isSourceBuildRef(input.identity.sourceRef);
3752
+ const detail = input.identity.path ? `local-operator resolves to ${input.identity.path}${input.identity.realPath && input.identity.realPath !== input.identity.path ? ` (${input.identity.realPath})` : ""}, classified as ${kind}.${kind === "editable" ? " Built from source on this machine, so it follows the checkout rather than the published release." : ""}` : "local-operator was not found on PATH.";
3489
3753
  if (kind === "editable") {
3490
3754
  return {
3491
3755
  canManageUpdate: false,
@@ -3496,12 +3760,33 @@ function resolveGlobalInstallPlan(input) {
3496
3760
  };
3497
3761
  }
3498
3762
  if (kind === "uv-tool") {
3499
- const useLopUpdate = Boolean(input.lopUpdatePath);
3763
+ const managed = generationInstallRoot(input.identity) !== null;
3764
+ const provenance = sourceBuild ? " Built from source on this machine, so `lop update` installs the published release over it - the harness prints the same notice." : "";
3500
3765
  return {
3501
- canManageUpdate: false,
3502
- updateCommand: useLopUpdate ? "lop-update" : "uv tool upgrade local-operator",
3503
- remedy: useLopUpdate ? "The server is a uv tool install built from source on this machine, so update it from your terminal:" : "The server is a uv tool install, so update it from your terminal:",
3504
- detail,
3766
+ canManageUpdate: managed,
3767
+ updateCommand: "lop update",
3768
+ /*
3769
+ * The remedy is the sentence above the command well in the by-hand panel
3770
+ * and the consequence line in the managed offer, and each arm says the
3771
+ * thing that decides the reader's next move:
3772
+ *
3773
+ * Managed - what the app is about to do TO THEM. This arm used to tell the
3774
+ * user to go to a terminal on the one path where the app runs the command
3775
+ * itself, which no surface rendered (reviews D7, U7), and the offer named
3776
+ * no cost at all before the click (review U3). The restart is the one
3777
+ * destructive thing this path does, so it is stated here rather than in
3778
+ * the in-flight panel the user reaches only after pressing.
3779
+ *
3780
+ * Legacy - WHY the app will not press the button, which is what the user
3781
+ * is choosing between (reviews U8, N1). It lived only in the mono Details
3782
+ * line, trailing a resolved path and a classification.
3783
+ *
3784
+ * NEITHER ENDS IN A COLON: the well below is visually distinct in both
3785
+ * panels, and in the by-hand panel the next line was the version sentence,
3786
+ * so the promise landed on the wrong line (review D5).
3787
+ */
3788
+ remedy: managed ? "The app updates this install and then restarts the server it started, so a turn that is in flight is dropped while the server comes back. This can take a minute or two." : "This install predates the non-disruptive installer, so update it once from your terminal. This install's updater rewrites the shared environment in place, which can interrupt sessions mid-turn; the app manages updates after that.",
3789
+ detail: `${detail}${provenance}`,
3505
3790
  sourceBuild
3506
3791
  };
3507
3792
  }
@@ -3509,7 +3794,7 @@ function resolveGlobalInstallPlan(input) {
3509
3794
  return {
3510
3795
  canManageUpdate: false,
3511
3796
  updateCommand: "pipx upgrade local-operator",
3512
- remedy: "The server is a pipx install, so update it from your terminal:",
3797
+ remedy: "The server is a pipx install, so update it from your terminal",
3513
3798
  detail,
3514
3799
  sourceBuild: false
3515
3800
  };
@@ -3518,7 +3803,7 @@ function resolveGlobalInstallPlan(input) {
3518
3803
  return {
3519
3804
  canManageUpdate: false,
3520
3805
  updateCommand: "pip install --upgrade local-operator",
3521
- remedy: "The server is a pip install, so update it from your terminal:",
3806
+ remedy: "The server is a pip install, so update it from your terminal",
3522
3807
  detail,
3523
3808
  sourceBuild: false
3524
3809
  };
@@ -3526,7 +3811,7 @@ function resolveGlobalInstallPlan(input) {
3526
3811
  return {
3527
3812
  canManageUpdate: false,
3528
3813
  updateCommand: "",
3529
- remedy: "The app could not tell how this server was installed, so update it with the tool you installed it with - uv, pipx or pip:",
3814
+ remedy: "The app could not tell how this server was installed, so update it with the tool you installed it with - uv, pipx or pip",
3530
3815
  detail,
3531
3816
  sourceBuild: false
3532
3817
  };
@@ -3563,9 +3848,11 @@ function parsePipShowVersion(stdout) {
3563
3848
  function didUpgradeLand(input) {
3564
3849
  const { before, after, target: target2 } = input;
3565
3850
  if (after == null) return false;
3566
- if (before == null) {
3567
- return target2 != null && after.trim() === target2.trim();
3851
+ if (target2 != null) {
3852
+ const order = compareVersions$1(after, target2);
3853
+ return order !== null && order >= 0;
3568
3854
  }
3855
+ if (before == null) return false;
3569
3856
  return before.trim() !== after.trim();
3570
3857
  }
3571
3858
  const launchEnv = {
@@ -5491,8 +5778,6 @@ var LocalOperatorStartupMode = /* @__PURE__ */ ((LocalOperatorStartupMode2) => {
5491
5778
  LocalOperatorStartupMode2["NOT_STARTED"] = "NOT_STARTED";
5492
5779
  return LocalOperatorStartupMode2;
5493
5780
  })(LocalOperatorStartupMode || {});
5494
- const CONSOLE_DISCOVERY_TIMEOUT_MS = 5e3;
5495
- const CONSOLE_RESOLUTION_WORST_MS = 2 * CONSOLE_DISCOVERY_TIMEOUT_MS;
5496
5781
  const SHUTDOWN_TIMEOUT_DEFAULTS = {
5497
5782
  restart: 1e4,
5498
5783
  // 10 seconds for restart operations
@@ -5506,7 +5791,6 @@ const READINESS_POLL_INTERVAL_MS = 1e3;
5506
5791
  const DESKTOP_TOKEN_FILENAME = "desktop-token";
5507
5792
  const execPromise = node_util.promisify(node_child_process.exec);
5508
5793
  const ENV_VAR_REGEX = /^([^=]+)=(.*)$/;
5509
- const LINE_BREAK = /\r?\n/;
5510
5794
  function classifyDesktopAnswer(status2) {
5511
5795
  if (status2 >= 200 && status2 < 300) return "accepted";
5512
5796
  if (status2 === 401 || status2 === 403) return "refused";
@@ -6191,59 +6475,75 @@ class BackendServiceManager {
6191
6475
  }
6192
6476
  this.notifyStatus();
6193
6477
  }
6478
+ /**
6479
+ * The global launcher this app ranks its daemons by, spawns from, and reports.
6480
+ *
6481
+ * ONE resolution, read by `checkLocalOperatorExists`, `resolveGlobalConsole`
6482
+ * and `preferredInstallPrefix`, because the three have to name the same
6483
+ * install: the decision to run GLOBAL_INSTALL is only true if the spawn can
6484
+ * resolve that same launcher, and a second resolution through a different
6485
+ * mechanism is exactly how a shell rc file's prepended venv becomes the thing
6486
+ * that gets spawned while the ranking talked about a different one.
6487
+ *
6488
+ * `resolveCommandPath`, NOT `which`. WHY, measured on this host 2026-09-16: the
6489
+ * app is started by LaunchServices, whose PATH is `/usr/bin:/bin:/usr/sbin:/sbin`,
6490
+ * and an app launched from a session that carries its own minimal PATH inherits
6491
+ * that. `~/.local/bin` - where uv and pipx link their console scripts, and where
6492
+ * this machine's `lop-update` install lives - is on neither. So the shell probe
6493
+ * this replaced answered "not found globally" on every ordinary launch, and the
6494
+ * app fell through to its OWN bundled environment: it ran a backend the
6495
+ * operator never updates, watched it diverge from `lop --version` (0.55.9
6496
+ * bundled against 0.55.10 installed), and offered to `pip install` into a uv
6497
+ * tool. `resolveCommandPath` searches the installers' own locations
6498
+ * (`UV_TOOL_BIN_DIR`, `XDG_BIN_HOME`, `$XDG_DATA_HOME/../bin`, `~/.local/bin`,
6499
+ * the Homebrew prefixes and every uv tool environment) as well as the inherited
6500
+ * PATH, so the same install is named with or without a shell - which is the
6501
+ * ranking rule `preferredInstallPrefix` states, and precisely what a
6502
+ * `which`-based check could not honour.
6503
+ *
6504
+ * `local-operator` before `lop`: both are console scripts of the same install
6505
+ * and normally sit in the same bin directory, but the spawn reads the resolved
6506
+ * script's shebang and refuses anything that is not a
6507
+ * `from local_operator.cli import main` launcher (`consoleInterpreter`), so the
6508
+ * name this app has always spawned is tried first and `lop` covers an install
6509
+ * whose older console script is gone.
6510
+ */
6511
+ globalConsoleScript() {
6512
+ return resolveGlobalConsoleScript();
6513
+ }
6194
6514
  /**
6195
6515
  * Check if the local-operator command exists globally
6196
6516
  * @returns Promise resolving to true if the command exists, false otherwise
6197
6517
  */
6198
6518
  async checkLocalOperatorExists() {
6199
- try {
6200
- const command = process.platform === "win32" ? "where local-operator" : "which local-operator";
6519
+ const command = this.globalConsoleScript();
6520
+ if (!command) {
6201
6521
  logger.info(
6202
- `Checking if local-operator command exists: ${command}`,
6522
+ "local-operator command not found globally",
6203
6523
  LogFileType.BACKEND
6204
6524
  );
6205
- const { stdout } = await execPromise(command, {
6206
- // Bounded: a quit that arrives mid-start waits on this same path, and an
6207
- // unbounded child here is a wait with nothing underneath it.
6208
- timeout: CONSOLE_DISCOVERY_TIMEOUT_MS,
6209
- windowsHide: true
6210
- });
6211
- if (stdout.trim()) {
6212
- if (process.platform === "darwin" && isLegacyManagedCommand(
6213
- stdout.trim().split("\n")[0],
6214
- path$1.join(
6215
- electron.app.getPath("home"),
6216
- "Library",
6217
- "Application Support",
6218
- "Local Operator"
6219
- )
6220
- )) {
6221
- logger.info(
6222
- "The PATH command belongs to a legacy managed environment; preparing a separate backend instead",
6223
- LogFileType.BACKEND
6224
- );
6225
- return false;
6226
- }
6227
- logger.info(
6228
- `local-operator command found at: ${stdout.trim()}`,
6229
- LogFileType.BACKEND
6230
- );
6231
- return true;
6232
- }
6233
- } catch (_error) {
6525
+ return false;
6526
+ }
6527
+ if (process.platform === "darwin" && isLegacyManagedCommand(
6528
+ command,
6529
+ path$1.join(
6530
+ electron.app.getPath("home"),
6531
+ "Library",
6532
+ "Application Support",
6533
+ "Local Operator"
6534
+ )
6535
+ )) {
6234
6536
  logger.info(
6235
- "local-operator command not found globally",
6537
+ `The resolved command belongs to a legacy managed environment (${command}); preparing a separate backend instead`,
6236
6538
  LogFileType.BACKEND
6237
6539
  );
6540
+ return false;
6238
6541
  }
6239
- return false;
6240
- }
6241
- /**
6242
- * Check if the local-operator command exists globally
6243
- * @returns Promise resolving to true if the command exists, false otherwise
6244
- */
6245
- canonicalLauncher() {
6246
- return resolveCommandPath("lop") ?? resolveCommandPath("local-operator");
6542
+ logger.info(
6543
+ `local-operator command found at: ${command}`,
6544
+ LogFileType.BACKEND
6545
+ );
6546
+ return true;
6247
6547
  }
6248
6548
  /**
6249
6549
  * `sys.prefix` of the install the user's own `lop` runs, when it can be named.
@@ -6258,7 +6558,7 @@ class BackendServiceManager {
6258
6558
  */
6259
6559
  preferredInstallPrefix() {
6260
6560
  try {
6261
- const identity = readInstallIdentity(this.canonicalLauncher());
6561
+ const identity = readInstallIdentity(this.globalConsoleScript());
6262
6562
  if (identity.uvReceipt) return path$1.dirname(identity.uvReceipt);
6263
6563
  if (identity.venvPrefix) return identity.venvPrefix;
6264
6564
  return null;
@@ -6637,14 +6937,32 @@ class BackendServiceManager {
6637
6937
  * Start the backend service
6638
6938
  * @returns Promise resolving to true if the backend was started successfully, false otherwise
6639
6939
  */
6640
- /** Keep PATH discovery authoritative; `ownedServeLaunch` proves the identity
6641
- * of what it found rather than trusting the name it was resolved under. */
6642
- async resolveGlobalConsole(env) {
6643
- const { stdout } = await execPromise(
6644
- process.platform === "win32" ? "where local-operator" : "which local-operator",
6645
- { env, timeout: CONSOLE_DISCOVERY_TIMEOUT_MS }
6646
- );
6647
- return stdout.trim().split(LINE_BREAK)[0];
6940
+ /** The global console to launch, resolved the one shell-free way.
6941
+ *
6942
+ * `ownedServeLaunch` still proves the identity of what it is handed rather than
6943
+ * trusting the name it was resolved under - that part is unchanged. What
6944
+ * changed is WHERE the name comes from: this method used to re-resolve a bare
6945
+ * `local-operator` through a shell, in the spawn environment that carries the
6946
+ * login shell's PATH. That is the second resolution `globalConsoleScript`
6947
+ * warns about, and it could name a venv a shell rc file prepends while the
6948
+ * decision above had ranked the operator's real install - two answers to one
6949
+ * question, with the spawn using the one the ranking never saw. Reading the
6950
+ * same helper removes the possibility instead of documenting it.
6951
+ *
6952
+ * The throw is reachable only when the resolution succeeded a moment ago and
6953
+ * the install disappeared since. It is deliberately not swallowed into an
6954
+ * empty string: `consoleInterpreter("")` would report that as an ENOENT on a
6955
+ * path nobody printed, and the caller's own failure report is the honest face
6956
+ * of this.
6957
+ */
6958
+ async resolveGlobalConsole() {
6959
+ const command = this.globalConsoleScript();
6960
+ if (!command) {
6961
+ throw new Error(
6962
+ "No global local-operator command could be resolved, though one was found a moment ago"
6963
+ );
6964
+ }
6965
+ return command;
6648
6966
  }
6649
6967
  /**
6650
6968
  * Start a daemon this app owns, or attach to one that already exists.
@@ -6717,7 +7035,7 @@ class BackendServiceManager {
6717
7035
  let interpreters;
6718
7036
  if (globalInstall) {
6719
7037
  this.startupMode = "GLOBAL_INSTALL";
6720
- const executable = await this.resolveGlobalConsole(env);
7038
+ const executable = await this.resolveGlobalConsole();
6721
7039
  interpreters = process.platform === "win32" ? await windowsInterpreterCandidates(executable, env) : [consoleInterpreter(executable)];
6722
7040
  } else {
6723
7041
  this.startupMode = "APP_BUNDLED_VENV";
@@ -7481,6 +7799,20 @@ class BackendServiceManager {
7481
7799
  getOwnedPid() {
7482
7800
  return this.process?.pid ?? null;
7483
7801
  }
7802
+ /**
7803
+ * The address this app is talking to RIGHT NOW.
7804
+ *
7805
+ * Not the configured target: `attachTo` rotates `backendUrl` onto the daemon
7806
+ * discovery adopted, and everything that queries the backend resolves against
7807
+ * it at call time. A consumer that instead read the configured URL described a
7808
+ * daemon the app is not talking to - the update service's `/health` read did
7809
+ * exactly that, so for an adopted daemon every version read failed and the
7810
+ * panel could neither name the build being served nor report the skew after an
7811
+ * install moved (QA Q-2, UX U1).
7812
+ */
7813
+ getBackendUrl() {
7814
+ return this.backendUrl;
7815
+ }
7484
7816
  /**
7485
7817
  * Check if the backend service is using an external backend
7486
7818
  * @returns True if using an external backend, false if we started our own
@@ -15508,6 +15840,9 @@ function runCommand(command, args, options = {}) {
15508
15840
  );
15509
15841
  });
15510
15842
  }
15843
+ function serverUpdateLogPath() {
15844
+ return path$1.join(logger.getLogPath(), LogFileType.UPDATE_SERVICE);
15845
+ }
15511
15846
  function readCommandOutput(command, args) {
15512
15847
  try {
15513
15848
  const output = node_child_process.execFileSync(command, args, {
@@ -15519,6 +15854,11 @@ function readCommandOutput(command, args) {
15519
15854
  return null;
15520
15855
  }
15521
15856
  }
15857
+ const INSTALLER_PROBE_TIMEOUT_MS = 5e3;
15858
+ function stderrTail(stderr, maxLines = 4) {
15859
+ const lines = stderr.split("\n").map((line) => line.trim()).filter((line) => line.length > 0);
15860
+ return lines.slice(-maxLines).join("\n").slice(-600);
15861
+ }
15522
15862
  class UpdateService {
15523
15863
  mainWindow = null;
15524
15864
  isDevMode;
@@ -17364,19 +17704,39 @@ ${heal.removed.join("\n")}`,
17364
17704
  if (v1PreRelease && !v2PreRelease) return false;
17365
17705
  return v1PreRelease > v2PreRelease;
17366
17706
  }
17707
+ /**
17708
+ * The address every backend read in this service goes to.
17709
+ *
17710
+ * The manager's LIVE address rather than the configured one: an adopted daemon
17711
+ * is discovered at a port the configuration never named, and the manager rotates
17712
+ * its own `backendUrl` onto it when it attaches. Reading the configured URL
17713
+ * instead made every health read fail for exactly the shape this branch adds -
17714
+ * a daemon the app did not start - so the skew after an install moved degraded
17715
+ * to "no reading" on the one path where that sentence is the only report the
17716
+ * user gets, while Settings could name the same daemon's version a few clicks
17717
+ * away (QA Q-2, UX U1).
17718
+ *
17719
+ * The configured URL stays as the fallback for the frames where no manager is
17720
+ * attached yet - the update service is constructed before it in the app's start
17721
+ * path.
17722
+ */
17723
+ liveBackendUrl() {
17724
+ return this.backendService?.getBackendUrl() ?? this.backendUrl;
17725
+ }
17367
17726
  /**
17368
17727
  * Get the installed backend version using the health API
17369
17728
  * @returns Promise resolving to the installed version or null if not found
17370
17729
  */
17371
17730
  async getInstalledBackendVersion() {
17372
17731
  try {
17732
+ const backendUrl = this.liveBackendUrl();
17373
17733
  logger.info(
17374
- `Checking backend version from health API at ${this.backendUrl}/health`,
17734
+ `Checking backend version from health API at ${backendUrl}/health`,
17375
17735
  LogFileType.UPDATE_SERVICE
17376
17736
  );
17377
17737
  const controller = new AbortController();
17378
17738
  const timeoutId = setTimeout(() => controller.abort(), 5e3);
17379
- const response = await fetch(`${this.backendUrl}/health`, {
17739
+ const response = await fetch(`${backendUrl}/health`, {
17380
17740
  method: "GET",
17381
17741
  headers: { Accept: "application/json" },
17382
17742
  signal: controller.signal
@@ -17408,9 +17768,10 @@ ${heal.removed.join("\n")}`,
17408
17768
  LogFileType.UPDATE_SERVICE,
17409
17769
  error
17410
17770
  );
17411
- if (this.backendUrl.includes("127.0.0.1")) {
17771
+ const retryUrl = this.liveBackendUrl();
17772
+ if (retryUrl.includes("127.0.0.1")) {
17412
17773
  try {
17413
- const altUrl = this.backendUrl.replace("127.0.0.1", "localhost");
17774
+ const altUrl = retryUrl.replace("127.0.0.1", "localhost");
17414
17775
  logger.info(
17415
17776
  `Trying alternative URL: ${altUrl}/health`,
17416
17777
  LogFileType.UPDATE_SERVICE
@@ -17531,12 +17892,10 @@ ${heal.removed.join("\n")}`,
17531
17892
  */
17532
17893
  async resolveBackendUpdatePlan(startupMode) {
17533
17894
  if (startupMode === LocalOperatorStartupMode.EXISTING_SERVER || startupMode === LocalOperatorStartupMode.GLOBAL_INSTALL) {
17534
- const plan = resolveGlobalInstallPlan({
17535
- identity: this.resolveInstallIdentity(),
17536
- lopUpdatePath: this.resolveLopUpdatePath()
17537
- });
17895
+ const identity = await this.resolveInstallIdentity();
17896
+ const plan = resolveGlobalInstallPlan({ identity });
17538
17897
  logger.info(
17539
- `External backend install (${startupMode}): ${plan.detail}; remedy is \`${plan.updateCommand || "nothing the app can name"}\``,
17898
+ `External backend install (${startupMode}): ${plan.detail}; remedy is \`${plan.updateCommand || "nothing the app can name"}\`; install version ${identity.version ?? "unreadable"}`,
17540
17899
  LogFileType.UPDATE_SERVICE
17541
17900
  );
17542
17901
  return {
@@ -17544,7 +17903,8 @@ ${heal.removed.join("\n")}`,
17544
17903
  updateCommand: plan.updateCommand,
17545
17904
  remedy: plan.remedy,
17546
17905
  detail: plan.detail,
17547
- sourceBuild: plan.sourceBuild
17906
+ sourceBuild: plan.sourceBuild,
17907
+ installedInstallVersion: identity.version ?? null
17548
17908
  };
17549
17909
  }
17550
17910
  return {
@@ -17552,7 +17912,8 @@ ${heal.removed.join("\n")}`,
17552
17912
  updateCommand: "pip install --upgrade local-operator",
17553
17913
  remedy: "Updating the server will improve AI functionality.",
17554
17914
  detail: `The app started this server itself (${startupMode}).`,
17555
- sourceBuild: false
17915
+ sourceBuild: false,
17916
+ installedInstallVersion: null
17556
17917
  };
17557
17918
  }
17558
17919
  /**
@@ -17571,25 +17932,7 @@ ${heal.removed.join("\n")}`,
17571
17932
  * searches the machine's and the user's own install locations.
17572
17933
  */
17573
17934
  resolveLocalOperatorPath() {
17574
- if (process.platform === "win32") {
17575
- const first = readCommandOutput("where", ["local-operator"])?.split("\n")[0]?.trim();
17576
- return first && first.length > 0 ? first : null;
17577
- }
17578
- return resolveCommandPath("local-operator");
17579
- }
17580
- /**
17581
- * Path the `lop-update` shim resolves to, or null when this machine has none.
17582
- *
17583
- * The same problem as the shim above, for the remedy rather than the
17584
- * classification: `lop-update` is what the source-build wording tells the user
17585
- * to run, and it lives in `~/.local/bin`, which a Finder launch cannot see
17586
- * through PATH alone - so the plan fell back to the pip/pipx wording for an
17587
- * install that is neither. It is a shell script, so there is no Windows
17588
- * counterpart to look for.
17589
- */
17590
- resolveLopUpdatePath() {
17591
- if (process.platform === "win32") return null;
17592
- return resolveCommandPath("lop-update");
17935
+ return resolveGlobalConsoleScript();
17593
17936
  }
17594
17937
  /**
17595
17938
  * Everything `classifyGlobalInstall` needs to tell a uv tool install from a
@@ -17601,17 +17944,44 @@ ${heal.removed.join("\n")}`,
17601
17944
  * answered: spawning `uv` and `pipx` on every backend check for a question
17602
17945
  * three string tests settled is work nobody asked for.
17603
17946
  */
17604
- resolveInstallIdentity() {
17947
+ async resolveInstallIdentity() {
17605
17948
  const identity = readInstallIdentity(this.resolveLocalOperatorPath());
17606
17949
  if (identity.path && classifyGlobalInstall(identity) !== "global-unknown") {
17607
17950
  return identity;
17608
17951
  }
17609
17952
  const uv = resolveCommandPath("uv");
17610
17953
  const pipx = resolveCommandPath("pipx");
17611
- identity.uvToolList = uv ? readCommandOutput(uv, ["tool", "list"]) : null;
17612
- identity.pipxList = pipx ? readCommandOutput(pipx, ["list"]) : null;
17954
+ const [uvToolList, pipxList] = await Promise.all([
17955
+ uv ? this.probeInstallerList(uv, ["tool", "list"]) : null,
17956
+ pipx ? this.probeInstallerList(pipx, ["list"]) : null
17957
+ ]);
17958
+ identity.uvToolList = uvToolList;
17959
+ identity.pipxList = pipxList;
17613
17960
  return identity;
17614
17961
  }
17962
+ /**
17963
+ * What an installer's own list command answers, or null.
17964
+ *
17965
+ * ASYNC, and that is the R1-4 fix: this runs on every backend check for an
17966
+ * install the three string tests cannot classify, and the synchronous form of it
17967
+ * blocked the main thread for up to 5 s per probe - 10 s for the pair, with the
17968
+ * window and every IPC channel frozen behind it. Review R1-4 measured it; the
17969
+ * answer is identical, only the waiting is not done on the event loop.
17970
+ *
17971
+ * `runCommand` rather than a second runner, and with `pythonSpawnEnv()`: `uv` and
17972
+ * `pipx` are python programs, so they get the same guarded environment every
17973
+ * other python spawn here passes - which is what keeps bytecode they write out
17974
+ * of the installed app.
17975
+ */
17976
+ async probeInstallerList(command, args) {
17977
+ const result = await runCommand(command, args, {
17978
+ timeoutMs: INSTALLER_PROBE_TIMEOUT_MS,
17979
+ env: this.pythonSpawnEnv()
17980
+ });
17981
+ if (!result.ran) return null;
17982
+ const output = result.stdout.trim();
17983
+ return output.length > 0 ? output : null;
17984
+ }
17615
17985
  /**
17616
17986
  * Check the installed server against the published release.
17617
17987
  *
@@ -17626,6 +17996,7 @@ ${heal.removed.join("\n")}`,
17626
17996
  `Checking for backend updates... (silent mode: ${silent})`,
17627
17997
  LogFileType.UPDATE_SERVICE
17628
17998
  );
17999
+ await this.reportUnattendedServerUpdate();
17629
18000
  try {
17630
18001
  if (this.isDevMode) {
17631
18002
  logger.info(
@@ -17655,9 +18026,12 @@ ${heal.removed.join("\n")}`,
17655
18026
  );
17656
18027
  return { status: "unavailable", info: null };
17657
18028
  }
17658
- const installedVersion = await this.getInstalledBackendVersion();
18029
+ const plan = await this.resolveBackendUpdatePlan(startupMode);
18030
+ const runningVersion = await this.getInstalledBackendVersion();
17659
18031
  const latestVersion = await this.getLatestPypiVersion();
17660
18032
  if (latestVersion) this.lastPublishedBackendVersion = latestVersion;
18033
+ const installVersion = plan.installedInstallVersion;
18034
+ const installedVersion = installVersion && isReadableVersion(installVersion) ? installVersion : runningVersion;
17661
18035
  if (!installedVersion || !latestVersion) {
17662
18036
  logger.error(
17663
18037
  "Unable to determine backend versions.",
@@ -17666,7 +18040,8 @@ ${heal.removed.join("\n")}`,
17666
18040
  if (reportFailure) {
17667
18041
  this.sendToRenderer("backend-update-error", {
17668
18042
  message: "Unable to determine backend version.",
17669
- phase: "check"
18043
+ phase: "check",
18044
+ logPath: serverUpdateLogPath()
17670
18045
  });
17671
18046
  }
17672
18047
  return { status: "unavailable", info: null };
@@ -17679,7 +18054,8 @@ ${heal.removed.join("\n")}`,
17679
18054
  if (reportFailure) {
17680
18055
  this.sendToRenderer("backend-update-error", {
17681
18056
  message: "The installed server version could not be determined, so no update was offered. Restart the app to try again.",
17682
- phase: "check"
18057
+ phase: "check",
18058
+ logPath: serverUpdateLogPath()
17683
18059
  });
17684
18060
  }
17685
18061
  return { status: "unavailable", info: null };
@@ -17698,12 +18074,17 @@ ${heal.removed.join("\n")}`,
17698
18074
  if (reportFailure) {
17699
18075
  this.sendToRenderer("backend-update-error", {
17700
18076
  message: "Unable to determine backend version.",
17701
- phase: "check"
18077
+ phase: "check",
18078
+ logPath: serverUpdateLogPath()
17702
18079
  });
17703
18080
  }
17704
18081
  return { status: "unavailable", info: null };
17705
18082
  }
17706
18083
  const shouldUpdate = this.isNewerVersion(latestVersion, installedVersion);
18084
+ logger.info(
18085
+ `Install on disk reports: ${installVersion ?? "no reading"}, running backend reports: ${runningVersion ?? "no reading"}, Latest: ${latestVersion}, Update needed: ${shouldUpdate}, Startup mode: ${startupMode}`,
18086
+ LogFileType.UPDATE_SERVICE
18087
+ );
17707
18088
  logger.info(
17708
18089
  `Installed backend version: ${installedVersion}, Latest: ${latestVersion}, Update needed: ${shouldUpdate}, Startup mode: ${startupMode}`,
17709
18090
  LogFileType.UPDATE_SERVICE
@@ -17713,16 +18094,29 @@ ${heal.removed.join("\n")}`,
17713
18094
  `New backend version available: ${latestVersion} (installed: ${installedVersion})`,
17714
18095
  LogFileType.UPDATE_SERVICE
17715
18096
  );
17716
- const { canManageUpdate, updateCommand, remedy, detail, sourceBuild } = await this.resolveBackendUpdatePlan(startupMode);
17717
18097
  const updateInfo = {
17718
18098
  currentVersion: installedVersion,
17719
18099
  latestVersion,
17720
- updateCommand,
17721
- canManageUpdate,
18100
+ runningVersion,
18101
+ updateCommand: plan.updateCommand,
18102
+ canManageUpdate: plan.canManageUpdate,
17722
18103
  startupMode,
17723
- remedy,
17724
- detail,
17725
- sourceBuild,
18104
+ remedy: plan.remedy,
18105
+ detail: plan.detail,
18106
+ sourceBuild: plan.sourceBuild,
18107
+ /*
18108
+ * WHO THE APP WOULD RESTART, on the panel where the user decides (UX U9).
18109
+ *
18110
+ * The managed arm's consequence sentence comes from the plan, and the plan is
18111
+ * an INSTALL classification - it cannot know whether the daemon serving this
18112
+ * app is one the app started. On a machine where discovery ADOPTED a server,
18113
+ * the offer therefore promised a restart that cannot happen, and the app's
18114
+ * own completion notice then said the opposite ("Local Operator does not
18115
+ * restart a server it did not start"). The sentence is right for the arm it
18116
+ * was written for; what was missing is the reading that decides it, which the
18117
+ * other two events already carry. It travels here too.
18118
+ */
18119
+ restartable: this.backendIsAppOwned(),
17726
18120
  /* `silent` is the whole difference between the two callers: the periodic
17727
18121
  and start-up checks pass `true`, the IPC handlers behind the buttons
17728
18122
  pass `false`. The renderer needs to know which one it is answering,
@@ -17742,9 +18136,23 @@ ${heal.removed.join("\n")}`,
17742
18136
  `No new backend version available. (installed: ${installedVersion}, latest: ${latestVersion})`,
17743
18137
  LogFileType.UPDATE_SERVICE
17744
18138
  );
17745
- if (!silent && this.mainWindow && !this.mainWindow.isDestroyed() && this.mainWindow.webContents && !this.mainWindow.webContents.isDestroyed()) {
18139
+ const readingsDiffer = installedVersion !== null && runningVersion !== null && isReadableVersion(installedVersion) && isReadableVersion(runningVersion) && installedVersion.trim() !== runningVersion.trim();
18140
+ if ((!silent || readingsDiffer) && this.mainWindow && !this.mainWindow.isDestroyed() && this.mainWindow.webContents && !this.mainWindow.webContents.isDestroyed()) {
17746
18141
  this.mainWindow.webContents.send("backend-update-not-available", {
17747
- version: installedVersion
18142
+ version: installedVersion,
18143
+ /*
18144
+ * The second reading travels on THIS state too, which is the one QA
18145
+ * Q-1 found: an install already at the published version whose daemon
18146
+ * still serves the old build offers nothing, so the offer's detail was
18147
+ * never rendered and the user was told nothing at all - while the log
18148
+ * carried both readings and Settings could name the daemon. It is the
18149
+ * state design §5 exists for (right after a landed install, before the
18150
+ * restart), and no later check re-offers it, because the install itself
18151
+ * is up to date.
18152
+ */
18153
+ runningVersion,
18154
+ /** Whether the app may restart the daemon that is behind. */
18155
+ restartable: this.backendIsAppOwned()
17748
18156
  });
17749
18157
  }
17750
18158
  return { status: "current", info: null };
@@ -17764,7 +18172,8 @@ ${heal.removed.join("\n")}`,
17764
18172
  * keeps that sentence's machine line readable.
17765
18173
  */
17766
18174
  message: stripErrorPrefixes(error.message),
17767
- phase: "check"
18175
+ phase: "check",
18176
+ logPath: serverUpdateLogPath()
17768
18177
  });
17769
18178
  }
17770
18179
  return { status: "unavailable", info: null };
@@ -17833,7 +18242,11 @@ ${heal.removed.join("\n")}`,
17833
18242
  const version = await this.getInstalledBackendVersion();
17834
18243
  if (version && version !== "Unknown") {
17835
18244
  last = version;
17836
- if (target2 == null || version === target2) return version;
18245
+ if (target2 == null) return version;
18246
+ const order = compareVersions$1(version, target2);
18247
+ if (order === null ? version.trim() === target2.trim() : order >= 0) {
18248
+ return version;
18249
+ }
17837
18250
  }
17838
18251
  await new Promise((resolve) => setTimeout(resolve, intervalMs));
17839
18252
  }
@@ -17843,6 +18256,281 @@ ${heal.removed.join("\n")}`,
17843
18256
  );
17844
18257
  return null;
17845
18258
  }
18259
+ /**
18260
+ * Whether the daemon serving this app is one the APP may restart.
18261
+ *
18262
+ * The answer decides what the skew notice can tell the user to do about a server
18263
+ * still on the old build: an owned daemon is the app's to bounce (so the sentence
18264
+ * names restarting Local Operator, which is an action), while an adopted one is
18265
+ * not (so the sentence says the app will not, which is a fact about what the
18266
+ * reader should expect). Getting this wrong is not cosmetic - the notice would
18267
+ * tell a user of an app-owned daemon that nothing will ever move it.
18268
+ */
18269
+ backendIsAppOwned() {
18270
+ return this.backendService !== null && !this.backendService.isUsingExternalBackend();
18271
+ }
18272
+ /**
18273
+ * The version the resolved global install reports on disk, or null.
18274
+ *
18275
+ * Shell-free, and deliberately a different read from the running backend's
18276
+ * `/health`: the install is what an update moves, and immediately after one it
18277
+ * is the only reading that has moved - the daemon lags until it is restarted.
18278
+ * Read through the same resolution the plan classified, because it is half of
18279
+ * the evidence that the install changed at all.
18280
+ */
18281
+ readGlobalInstallVersion() {
18282
+ return readInstallIdentity(this.resolveLocalOperatorPath())?.version ?? null;
18283
+ }
18284
+ /**
18285
+ * Run the install's own front end: `<resolved shim> update`.
18286
+ *
18287
+ * WHY THIS AND NOT AN INSTALLER. The mechanism that owns a uv tool install is
18288
+ * `lop update`: it detects the install kind, honours the `editable`/`unknown`
18289
+ * refusals, writes the `.lop-source` marker runtimes converge on, prunes the
18290
+ * generations nothing refers to, and refreshes the daemons it supervises. An
18291
+ * app that ran `uv` itself would bypass every one of those, and the two
18292
+ * commands this path used to name are the ones the harness documents as
18293
+ * failing (`uv tool upgrade` on a pinned receipt or a git snapshot) or as
18294
+ * belonging to another audience (`lop-update`, the release owner's
18295
+ * build-from-checkout script, which refuses when its ref has diverged).
18296
+ *
18297
+ * THE PATH IS BUILT, NOT INHERITED. `lop update` reaches `uv` by bare name
18298
+ * through the child's environment, and this app is normally launched by Finder
18299
+ * with `/usr/bin:/bin:/usr/sbin:/sbin` - on this machine `uv` is
18300
+ * `/opt/homebrew/bin/uv`. Without `installerSearchPath` the feature fails with
18301
+ * an `installer exited 127` that reads as an installer bug.
18302
+ *
18303
+ * The cwd is the user's home: a neutral directory, never the app's resources
18304
+ * dir, so an installer that resolves a relative path from where it stands
18305
+ * cannot land inside the sealed bundle. `shell: true` is never used - this is a
18306
+ * resolved shim path, not a command line.
18307
+ */
18308
+ async runGlobalUpdate(consolePath) {
18309
+ const updatePath = installerSearchPath(process.env, os.homedir());
18310
+ logger.info(
18311
+ `Executing the install's own updater: ${consolePath} update (PATH: ${updatePath})`,
18312
+ LogFileType.UPDATE_SERVICE
18313
+ );
18314
+ const run = await runCommand(consolePath, ["update"], {
18315
+ timeoutMs: 15 * 60 * 1e3,
18316
+ env: { ...this.pythonSpawnEnv(), PATH: updatePath }
18317
+ });
18318
+ logger.info(
18319
+ `lop update stdout: ${run.stdout.trim() || "(empty)"}`,
18320
+ LogFileType.UPDATE_SERVICE
18321
+ );
18322
+ if (run.stderr.trim().length > 0) {
18323
+ logger.warn(
18324
+ `lop update stderr: ${run.stderr.trim()}`,
18325
+ LogFileType.UPDATE_SERVICE
18326
+ );
18327
+ }
18328
+ return run;
18329
+ }
18330
+ /**
18331
+ * True while an app-driven update of the global install is running.
18332
+ *
18333
+ * The guard is here and not on the surfaces that ask for the update, because
18334
+ * their flags are per-surface: the offer holds `checking` and the run panel holds
18335
+ * `updatingBackend`, so a press on each of them reached `updateGlobalInstall`
18336
+ * twice and the second `lop update` ran beside the first against one install root
18337
+ * (review R1-8). The bundled path never needed one because it stops the backend
18338
+ * first, which serialises it by accident; nothing on this path stops anything.
18339
+ */
18340
+ globalUpdateInFlight = false;
18341
+ /**
18342
+ * Update the resolved global install, then restart the daemon this app owns.
18343
+ *
18344
+ * ORDERING IS THE POINT, and it is the opposite of the bundled path: that one
18345
+ * stops the backend BEFORE pip because pip replaces the environment the daemon
18346
+ * runs from. Under generations the install lands in a new tree, the pointer
18347
+ * flips, and no running process's tree is touched - so the install cannot fail
18348
+ * the daemon, and stopping first would throw away in-flight work for the whole
18349
+ * install to buy nothing. Install, verify, restart. A failed install therefore
18350
+ * leaves a daemon that never stopped serving.
18351
+ *
18352
+ * `landed` is a READING, not an exit code: `lop update` exits 0 in its "already
18353
+ * latest" case too, so the app holds the install's own version to the target
18354
+ * exactly as the pip path does (`didUpgradeLand`).
18355
+ */
18356
+ async updateGlobalInstall(backend, plan, target2) {
18357
+ if (this.globalUpdateInFlight) {
18358
+ logger.warn(
18359
+ "A global install update is already running; refusing to start a second beside it",
18360
+ LogFileType.UPDATE_SERVICE
18361
+ );
18362
+ this.sendToRenderer("backend-update-error", {
18363
+ message: "A server update is already running. Let it finish before starting another.",
18364
+ phase: "update",
18365
+ logPath: serverUpdateLogPath()
18366
+ });
18367
+ return false;
18368
+ }
18369
+ this.globalUpdateInFlight = true;
18370
+ try {
18371
+ return await this.runGlobalUpdateAttempt(backend, plan, target2);
18372
+ } finally {
18373
+ this.globalUpdateInFlight = false;
18374
+ }
18375
+ }
18376
+ /** The attempt itself, under the guard above. */
18377
+ async runGlobalUpdateAttempt(backend, plan, target2) {
18378
+ const consolePath = this.resolveLocalOperatorPath();
18379
+ if (!consolePath) {
18380
+ this.sendToRenderer("backend-update-error", {
18381
+ message: "The global install could not be found on this machine, so the update did not run. See the update service log.",
18382
+ phase: "update",
18383
+ logPath: serverUpdateLogPath()
18384
+ });
18385
+ return false;
18386
+ }
18387
+ const before = this.readGlobalInstallVersion() ?? plan.installedInstallVersion;
18388
+ this.sendToRenderer("backend-update-progress", { phase: "installing" });
18389
+ writePendingServerUpdateMarker(this.markerDir(), {
18390
+ before,
18391
+ target: target2,
18392
+ startedAt: (/* @__PURE__ */ new Date()).toISOString()
18393
+ });
18394
+ const run = await this.runGlobalUpdate(consolePath);
18395
+ const after = this.readGlobalInstallVersion();
18396
+ clearPendingServerUpdateMarker(this.markerDir());
18397
+ const landed = run.exitCode === 0 && didUpgradeLand({ before, after, target: target2 });
18398
+ if (!landed) {
18399
+ const running = await this.getInstalledBackendVersion();
18400
+ const tail = stderrTail(run.stderr);
18401
+ logger.error(
18402
+ `Global install update did not land (exited ${run.exitCode}, ran ${run.ran}): ${before ?? "unknown"} -> ${after ?? "unknown"}, running backend reports ${running ?? "no reading"}`,
18403
+ LogFileType.UPDATE_SERVICE
18404
+ );
18405
+ this.sendToRenderer("backend-update-error", {
18406
+ message: `${!run.ran ? "The server update did not finish: the installer could not be run to a verdict or timed out." : run.exitCode !== 0 ? `The server update did not install: \`lop update\` exited ${run.exitCode}.` : `The server update to ${target2 ?? "the new release"} did not take effect: the install still reports ${after ?? before ?? "its previous version"}.`} The running backend is still serving${running ? ` version ${running}` : ""}; see the update service log for the installer's output.${tail ? `
18407
+
18408
+ ${tail}` : ""}`,
18409
+ phase: "update",
18410
+ logPath: serverUpdateLogPath()
18411
+ });
18412
+ return false;
18413
+ }
18414
+ logger.info(
18415
+ `Global install updated: ${before ?? "unknown"} -> ${after ?? "unknown"}`,
18416
+ LogFileType.UPDATE_SERVICE
18417
+ );
18418
+ if (backend.isUsingExternalBackend()) {
18419
+ const running = await this.getInstalledBackendVersion();
18420
+ logger.info(
18421
+ `Install moved to ${after ?? "unknown"}; the attached backend still reports ${running ?? "no reading"}`,
18422
+ LogFileType.UPDATE_SERVICE
18423
+ );
18424
+ this.sendToRenderer("backend-update-completed", {
18425
+ installVersion: after,
18426
+ runningVersion: running,
18427
+ restarted: false,
18428
+ restartable: this.backendIsAppOwned()
18429
+ });
18430
+ return true;
18431
+ }
18432
+ logger.info(
18433
+ "Restarting backend service onto the updated install...",
18434
+ LogFileType.UPDATE_SERVICE
18435
+ );
18436
+ this.sendToRenderer("backend-update-progress", { phase: "restarting" });
18437
+ backend.setAutoUpdating(true);
18438
+ const restartSuccess = await backend.restart();
18439
+ backend.setAutoUpdating(false);
18440
+ if (!restartSuccess) {
18441
+ logger.error(
18442
+ "Backend service restart failed after the global install update",
18443
+ LogFileType.UPDATE_SERVICE
18444
+ );
18445
+ this.sendToRenderer("backend-update-error", {
18446
+ message: "The server was updated but the backend did not restart. Restart Local Operator to run the new version.",
18447
+ phase: "update",
18448
+ logPath: serverUpdateLogPath()
18449
+ });
18450
+ return false;
18451
+ }
18452
+ const healthy = await this.checkBackendHealth();
18453
+ if (!healthy) {
18454
+ logger.error(
18455
+ "Backend service restarted but the health check failed; retrying once",
18456
+ LogFileType.UPDATE_SERVICE
18457
+ );
18458
+ await backend.start();
18459
+ if (!await this.checkBackendHealth()) {
18460
+ this.sendToRenderer("backend-update-error", {
18461
+ message: "The server was updated but the backend did not restart properly. Restart Local Operator to run the new version.",
18462
+ phase: "update",
18463
+ logPath: serverUpdateLogPath()
18464
+ });
18465
+ return false;
18466
+ }
18467
+ }
18468
+ const reported = await this.waitForBackendVersion(target2);
18469
+ if (reported === null) {
18470
+ logger.error(
18471
+ `Backend restarted onto the updated install but did not report ${target2 ?? "the target version"}`,
18472
+ LogFileType.UPDATE_SERVICE
18473
+ );
18474
+ this.sendToRenderer("backend-update-error", {
18475
+ message: `The server restarted but did not report ${target2 ? `version ${target2}` : "the updated version"}. See the update service log, then restart Local Operator.`,
18476
+ phase: "update",
18477
+ logPath: serverUpdateLogPath()
18478
+ });
18479
+ return false;
18480
+ }
18481
+ logger.info(
18482
+ `Backend reports version ${reported} after the global install update`,
18483
+ LogFileType.UPDATE_SERVICE
18484
+ );
18485
+ this.sendToRenderer("backend-update-completed", {
18486
+ installVersion: after,
18487
+ runningVersion: reported,
18488
+ restarted: true
18489
+ });
18490
+ return true;
18491
+ }
18492
+ /**
18493
+ * Report a server update that landed while no app was supervising it.
18494
+ *
18495
+ * The other half of UX U6: the marker above survives exactly when the app died
18496
+ * mid-attempt, and the installer is a child that outlives its parent, so the
18497
+ * update can land with nothing watching. The end state is coherent on its own -
18498
+ * the app comes up on the new build and reports it - which is why this reports
18499
+ * the fact ONCE rather than treating a stale marker as a failure: there is no
18500
+ * remedy to offer and no version to hold anyone to, only a sentence the user has
18501
+ * never seen about an update they started.
18502
+ *
18503
+ * Cleared either way: a marker whose install did NOT move describes an
18504
+ * interrupted attempt whose reason is in the log, and re-reporting it on every
18505
+ * launch would be a permanent warning about a state the app has since read
18506
+ * correctly (and the failure path keeps no record for the app to re-read).
18507
+ */
18508
+ async reportUnattendedServerUpdate() {
18509
+ const marker = readPendingServerUpdateMarker(this.markerDir());
18510
+ if (!marker) return;
18511
+ const after = this.readGlobalInstallVersion();
18512
+ clearPendingServerUpdateMarker(this.markerDir());
18513
+ if (!after || after === marker.before) {
18514
+ logger.info(
18515
+ `A server update started at ${marker.startedAt} did not move the install (still ${after ?? "no reading"}); nothing to report on this launch`,
18516
+ LogFileType.UPDATE_SERVICE
18517
+ );
18518
+ return;
18519
+ }
18520
+ const running = await this.getInstalledBackendVersion();
18521
+ logger.info(
18522
+ `A server update that no app was supervising landed: ${marker.before ?? "unknown"} -> ${after}; the backend serving this launch reports ${running ?? "no reading"}`,
18523
+ LogFileType.UPDATE_SERVICE
18524
+ );
18525
+ this.sendToRenderer("backend-update-completed", {
18526
+ installVersion: after,
18527
+ runningVersion: running,
18528
+ restarted: false,
18529
+ unattended: true,
18530
+ restartable: this.backendIsAppOwned(),
18531
+ before: marker.before
18532
+ });
18533
+ }
17846
18534
  async updateBackend(targetVersion) {
17847
18535
  logger.info("Updating backend...", LogFileType.UPDATE_SERVICE);
17848
18536
  try {
@@ -17854,7 +18542,8 @@ ${heal.removed.join("\n")}`,
17854
18542
  if (this.mainWindow && !this.mainWindow.isDestroyed() && this.mainWindow.webContents && !this.mainWindow.webContents.isDestroyed()) {
17855
18543
  this.mainWindow.webContents.send("backend-update-error", {
17856
18544
  message: "No backend service reference available, cannot update.",
17857
- phase: "update"
18545
+ phase: "update",
18546
+ logPath: serverUpdateLogPath()
17858
18547
  });
17859
18548
  }
17860
18549
  return false;
@@ -17869,13 +18558,22 @@ ${heal.removed.join("\n")}`,
17869
18558
  if (this.mainWindow && !this.mainWindow.isDestroyed() && this.mainWindow.webContents && !this.mainWindow.webContents.isDestroyed()) {
17870
18559
  this.mainWindow.webContents.send("backend-update-error", {
17871
18560
  message: "No python server is available, nothing to update.",
17872
- phase: "update"
18561
+ phase: "update",
18562
+ logPath: serverUpdateLogPath()
17873
18563
  });
17874
18564
  }
17875
18565
  return false;
17876
18566
  case LocalOperatorStartupMode.EXISTING_SERVER:
17877
18567
  case LocalOperatorStartupMode.GLOBAL_INSTALL: {
17878
18568
  const plan = await this.resolveBackendUpdatePlan(startupMode);
18569
+ const target2 = targetVersion ?? this.lastPublishedBackendVersion ?? null;
18570
+ if (plan.canManageUpdate) {
18571
+ return await this.updateGlobalInstall(
18572
+ this.backendService,
18573
+ plan,
18574
+ target2
18575
+ );
18576
+ }
17879
18577
  logger.info(
17880
18578
  `Cannot manage the update for ${startupMode}: ${plan.detail}`,
17881
18579
  LogFileType.UPDATE_SERVICE
@@ -17885,13 +18583,10 @@ ${heal.removed.join("\n")}`,
17885
18583
  message: plan.remedy,
17886
18584
  command: plan.updateCommand,
17887
18585
  detail: plan.detail,
17888
- // The caller's target when it named one (the "Update server"
17889
- // button does), otherwise the published release the last check
17890
- // read: the compatibility banner calls this with no target at
17891
- // all, and the panel's version sentence then had nothing to
17892
- // render on the path that actually produces it (review U17).
17893
- latestVersion: targetVersion ?? this.lastPublishedBackendVersion ?? null,
18586
+ latestVersion: target2,
17894
18587
  currentVersion: installed && installed !== "Unknown" ? installed : null,
18588
+ installVersion: plan.installedInstallVersion,
18589
+ runningVersion: installed,
17895
18590
  sourceBuild: plan.sourceBuild
17896
18591
  });
17897
18592
  return false;
@@ -17905,7 +18600,8 @@ ${heal.removed.join("\n")}`,
17905
18600
  );
17906
18601
  this.sendToRenderer("backend-update-error", {
17907
18602
  message: "The app could not tell how this server was started, so it did not update it. See the update service log.",
17908
- phase: "update"
18603
+ phase: "update",
18604
+ logPath: serverUpdateLogPath()
17909
18605
  });
17910
18606
  return false;
17911
18607
  }
@@ -17939,7 +18635,8 @@ ${heal.removed.join("\n")}`,
17939
18635
  await this.restartBackendAfterFailedUpgrade();
17940
18636
  this.sendToRenderer("backend-update-error", {
17941
18637
  message: "The bundled server's Python environment could not be found, so the update did not run. Please reinstall the application.",
17942
- phase: "update"
18638
+ phase: "update",
18639
+ logPath: serverUpdateLogPath()
17943
18640
  });
17944
18641
  return false;
17945
18642
  }
@@ -17979,7 +18676,8 @@ ${pipRun.stderr.trim()}`,
17979
18676
  const serverIsBack = await this.restartBackendAfterFailedUpgrade();
17980
18677
  this.sendToRenderer("backend-update-error", {
17981
18678
  message: pipRun.exitCode !== 0 ? serverIsBack ? "The server update failed to install. The previously installed server is running again; see the update service log for pip's output." : "The server update failed to install and the server did not come back up. Restart Local Operator, and see the update service log for pip's output." : `The server update to ${targetVersion ?? "the new release"} did not take effect: the server is still on ${versionAfter ?? "its previous version"}. See the update service log for pip's output, then try again.`,
17982
- phase: "update"
18679
+ phase: "update",
18680
+ logPath: serverUpdateLogPath()
17983
18681
  });
17984
18682
  return false;
17985
18683
  }
@@ -18018,7 +18716,8 @@ ${pipRun.stderr.trim()}`,
18018
18716
  if (this.mainWindow) {
18019
18717
  this.mainWindow.webContents.send("backend-update-error", {
18020
18718
  message: "Backend was updated but failed to restart properly. Please restart the application.",
18021
- phase: "update"
18719
+ phase: "update",
18720
+ logPath: serverUpdateLogPath()
18022
18721
  });
18023
18722
  }
18024
18723
  return false;
@@ -18033,7 +18732,8 @@ ${pipRun.stderr.trim()}`,
18033
18732
  if (this.mainWindow) {
18034
18733
  this.mainWindow.webContents.send("backend-update-error", {
18035
18734
  message: "Backend was updated but failed to restart. Please restart the application.",
18036
- phase: "update"
18735
+ phase: "update",
18736
+ logPath: serverUpdateLogPath()
18037
18737
  });
18038
18738
  }
18039
18739
  return false;
@@ -18048,7 +18748,8 @@ ${pipRun.stderr.trim()}`,
18048
18748
  );
18049
18749
  this.sendToRenderer("backend-update-error", {
18050
18750
  message: `The server restarted but did not report ${targetVersion ? `version ${targetVersion}` : "the updated version"}. Check the update service log, then try again.`,
18051
- phase: "update"
18751
+ phase: "update",
18752
+ logPath: serverUpdateLogPath()
18052
18753
  });
18053
18754
  return false;
18054
18755
  }
@@ -18090,7 +18791,8 @@ ${pipRun.stderr.trim()}`,
18090
18791
  // Cleaned at the source, for the reason written on the check-phase
18091
18792
  // report above.
18092
18793
  message: stripErrorPrefixes(error.message),
18093
- phase: "update"
18794
+ phase: "update",
18795
+ logPath: serverUpdateLogPath()
18094
18796
  });
18095
18797
  }
18096
18798
  return false;
@@ -18121,7 +18823,7 @@ ${pipRun.stderr.trim()}`,
18121
18823
  try {
18122
18824
  const controller = new AbortController();
18123
18825
  const timeoutId = setTimeout(() => controller.abort(), 3e3);
18124
- const response = await fetch(`${this.backendUrl}/health`, {
18826
+ const response = await fetch(`${this.liveBackendUrl()}/health`, {
18125
18827
  method: "GET",
18126
18828
  headers: { Accept: "application/json" },
18127
18829
  signal: controller.signal
@@ -19827,7 +20529,7 @@ electron.app.on("window-all-closed", () => {
19827
20529
  electron.app.quit();
19828
20530
  });
19829
20531
  const QUIT_FAILSAFE_MARGIN_MS = 5e3;
19830
- const QUIT_CLEANUP_FAILSAFE_MS = CONSOLE_RESOLUTION_WORST_MS + INTERPRETER_RESOLUTION_WORST_MS + OWNED_STOP_WORST_MS + READINESS_POLL_INTERVAL_MS + QUIT_FAILSAFE_MARGIN_MS;
20532
+ const QUIT_CLEANUP_FAILSAFE_MS = INTERPRETER_RESOLUTION_WORST_MS + OWNED_STOP_WORST_MS + READINESS_POLL_INTERVAL_MS + QUIT_FAILSAFE_MARGIN_MS;
19831
20533
  let backendQuitPending = false;
19832
20534
  electron.app.on("will-quit", (event) => {
19833
20535
  viewerEndpoint?.close();