local-operator-ui 0.31.28 → 0.31.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 (84) hide show
  1. package/out/main/index.js +931 -31
  2. package/out/renderer/assets/{_basePickBy-D16oopNi.js → _basePickBy-Y9GC34gi.js} +1 -1
  3. package/out/renderer/assets/{_baseUniq-BYKYfwtj.js → _baseUniq-B0peOdjF.js} +1 -1
  4. package/out/renderer/assets/{agent-details-page-CpwkLhdq.js → agent-details-page-DVzsvKvr.js} +1 -1
  5. package/out/renderer/assets/{agent-hub-page-BYeba0zC.js → agent-hub-page-Dp8llcze.js} +1 -1
  6. package/out/renderer/assets/agents-page-DlN3w0Rq.js +7 -0
  7. package/out/renderer/assets/{arc-BYg3WIzZ.js → arc-DQzRvMGZ.js} +1 -1
  8. package/out/renderer/assets/{architectureDiagram-IEHRJDOE-np6HwkQz.js → architectureDiagram-IEHRJDOE-BHLUr3Vi.js} +1 -1
  9. package/out/renderer/assets/{blockDiagram-JOT3LUYC-CaR7YwVl.js → blockDiagram-JOT3LUYC-CVK0U-3j.js} +1 -1
  10. package/out/renderer/assets/{book-open--g1VlVMp.js → book-open-j4JS3yvI.js} +1 -1
  11. package/out/renderer/assets/browser-page-BI7a4ZtV.js +1 -0
  12. package/out/renderer/assets/{browser-webauthn-prompt-Bsk0eXNZ.js → browser-webauthn-prompt-nwdqBWnv.js} +1 -1
  13. package/out/renderer/assets/{c4Diagram-VJAJSXHY-DqBT3rl5.js → c4Diagram-VJAJSXHY-BqLTGVHe.js} +1 -1
  14. package/out/renderer/assets/channel-BP8e6HYH.js +1 -0
  15. package/out/renderer/assets/{chunk-4BMEZGHF-B7-MzFPi.js → chunk-4BMEZGHF-BSPZPecM.js} +1 -1
  16. package/out/renderer/assets/{chunk-A2AXSNBT-B9IqNrNg.js → chunk-A2AXSNBT-CEEG3uIi.js} +1 -1
  17. package/out/renderer/assets/{chunk-AEK57VVT-DFzBJNd9.js → chunk-AEK57VVT-CvyIUWUR.js} +1 -1
  18. package/out/renderer/assets/{chunk-D6G4REZN-DW-XzsAv.js → chunk-D6G4REZN-D4lVOdW7.js} +1 -1
  19. package/out/renderer/assets/{chunk-RZ5BOZE2-C7el6yre.js → chunk-RZ5BOZE2-znegx9P6.js} +1 -1
  20. package/out/renderer/assets/{chunk-XZIHB7SX-BGYrq1Wn.js → chunk-XZIHB7SX-DPz_D8Nf.js} +1 -1
  21. package/out/renderer/assets/classDiagram-GIVACNV2-DVXt0ycE.js +1 -0
  22. package/out/renderer/assets/classDiagram-v2-COTLJTTW-DVXt0ycE.js +1 -0
  23. package/out/renderer/assets/clone-D0eJwRGh.js +1 -0
  24. package/out/renderer/assets/{console-mirror-ffbsozOd.js → console-mirror-GUGtzmyW.js} +1 -1
  25. package/out/renderer/assets/{consoleCapture-CiApa6fi.js → consoleCapture-D8nStIVt.js} +1 -1
  26. package/out/renderer/assets/{conversation-input-sync-DQUYkOfh.js → conversation-input-sync-CGaXAqah.js} +246 -239
  27. package/out/renderer/assets/{dagre-OKDRZEBW-BVScRDvj.js → dagre-OKDRZEBW-ByuMXl-x.js} +1 -1
  28. package/out/renderer/assets/{diagram-SSKATNLV-B0iQ6YY1.js → diagram-SSKATNLV-DrN1Clbm.js} +1 -1
  29. package/out/renderer/assets/{diagram-VNBRO52H-Co6YPtUT.js → diagram-VNBRO52H-BU95qC98.js} +1 -1
  30. package/out/renderer/assets/{erDiagram-Q7BY3M3F-C9XIuSQl.js → erDiagram-Q7BY3M3F-CLttNK5z.js} +1 -1
  31. package/out/renderer/assets/{error-boundary-DH4MhqhL.js → error-boundary-BR1z8pWF.js} +5 -5
  32. package/out/renderer/assets/{flowDiagram-4HSFHLVR-QTHrGPk1.js → flowDiagram-4HSFHLVR-BCsFW1Gf.js} +1 -1
  33. package/out/renderer/assets/{ganttDiagram-APWFNJXF-qC1VnBIz.js → ganttDiagram-APWFNJXF-Cdg1Q_-7.js} +1 -1
  34. package/out/renderer/assets/{gitGraphDiagram-7IBYFJ6S-D7UGP8uH.js → gitGraphDiagram-7IBYFJ6S-0tWQBOZl.js} +1 -1
  35. package/out/renderer/assets/{graph-BIUuz2bp.js → graph-BOvjqm1y.js} +1 -1
  36. package/out/renderer/assets/{index-Ckvy9Zge.js → index-Bbr0EFhm.js} +1 -1
  37. package/out/renderer/assets/index-Cy3MjKxd.css +1 -0
  38. package/out/renderer/assets/{index-vK67PDia.js → index-D09I1LXM.js} +196 -196
  39. package/out/renderer/assets/{index-DTc-sJ8S.js → index-DbuNRNvX.js} +1 -1
  40. package/out/renderer/assets/{infoDiagram-PH2N3AL5-DYtVGJC7.js → infoDiagram-PH2N3AL5-cyj8Lve1.js} +1 -1
  41. package/out/renderer/assets/{installer-BcSiJA6N.js → installer-DDGjYv9v.js} +1 -1
  42. package/out/renderer/assets/{journeyDiagram-U35MCT3I-vVg2sFTp.js → journeyDiagram-U35MCT3I-DWBKDniU.js} +1 -1
  43. package/out/renderer/assets/{kanban-definition-NDS4AKOZ-BE36wSk2.js → kanban-definition-NDS4AKOZ-Cl5Gtazo.js} +1 -1
  44. package/out/renderer/assets/{layout-bOQrw0b8.js → layout-CxuQX-mj.js} +1 -1
  45. package/out/renderer/assets/{legacy-agents-page-BSKz_UrV.js → legacy-agents-page-EmhBvGX8.js} +1 -1
  46. package/out/renderer/assets/{mermaid.core-DpvY6Bhk.js → mermaid.core-eIMT1oSc.js} +5 -5
  47. package/out/renderer/assets/{mesh-page-9Q8Sc9J_.js → mesh-page-B8pGIWvO.js} +1 -1
  48. package/out/renderer/assets/{mindmap-definition-ALO5MXBD-B9PPN6sb.js → mindmap-definition-ALO5MXBD-zPRivaIU.js} +1 -1
  49. package/out/renderer/assets/{mini-ySULdryU.js → mini-D6nPFjTs.js} +1 -1
  50. package/out/renderer/assets/{org-surface-gate-Dl4zcl5q.js → org-surface-gate-BeX5voGq.js} +1 -1
  51. package/out/renderer/assets/{page-header-DazB-GQz.js → page-header-BLBgjVqc.js} +1 -1
  52. package/out/renderer/assets/{pieDiagram-IB7DONF6-DkioNhQY.js → pieDiagram-IB7DONF6-CUTT0oqK.js} +1 -1
  53. package/out/renderer/assets/projects-page-D60z6cvI.js +55 -0
  54. package/out/renderer/assets/{quadrantDiagram-7GDLP6J5-4m3m0N6F.js → quadrantDiagram-7GDLP6J5-B6ksoL9Y.js} +1 -1
  55. package/out/renderer/assets/{radar-MK3ICKWK-DS8YGAOb.js → radar-MK3ICKWK-BpDoqWc2.js} +1 -1
  56. package/out/renderer/assets/{radient-auth-buttons-DZeHRL7u.js → radient-auth-buttons-Ep1e0Z5I.js} +1 -1
  57. package/out/renderer/assets/{requirementDiagram-KVF5MWMF-DsBdUu3C.js → requirementDiagram-KVF5MWMF-DQwmYS7R.js} +1 -1
  58. package/out/renderer/assets/{sankeyDiagram-QLVOVGJD-CnaPYKR6.js → sankeyDiagram-QLVOVGJD-Dc8VJLoV.js} +1 -1
  59. package/out/renderer/assets/{schedules-page-B0dSGpXt.js → schedules-page-DyMkIr1h.js} +1 -1
  60. package/out/renderer/assets/{sequenceDiagram-X6HHIX6F-C3eja1QY.js → sequenceDiagram-X6HHIX6F-SsVDN37O.js} +1 -1
  61. package/out/renderer/assets/{settings-page-CrMsVX3d.js → settings-page-C_1ja3uN.js} +6 -6
  62. package/out/renderer/assets/{settings-section-BGG4XzV0.js → settings-section-BLUoPALO.js} +1 -1
  63. package/out/renderer/assets/{stateDiagram-DGXRK772-CjFKiyXn.js → stateDiagram-DGXRK772-vwQ-TB_f.js} +1 -1
  64. package/out/renderer/assets/stateDiagram-v2-YXO3MK2T-D30uaRbQ.js +1 -0
  65. package/out/renderer/assets/{timeline-definition-BDJGKUSR-CAXy0nQD.js → timeline-definition-BDJGKUSR-CQ_pAZms.js} +1 -1
  66. package/out/renderer/assets/{ui-preferences-store-B6Fr0UX9.js → ui-preferences-store-CxJfZcpf.js} +1 -1
  67. package/out/renderer/assets/{use-download-agent-mutation-LsNre_-6.js → use-download-agent-mutation-DIujvdLj.js} +1 -1
  68. package/out/renderer/assets/{use-memberships-query-BgiSfYgr.js → use-memberships-query-BhO5nUlo.js} +1 -1
  69. package/out/renderer/assets/{xychartDiagram-VJFVF3MP-CfNm8SWG.js → xychartDiagram-VJFVF3MP-Cs3B-KEl.js} +1 -1
  70. package/out/renderer/console-capture.html +5 -5
  71. package/out/renderer/index.html +7 -7
  72. package/out/renderer/installer.html +4 -4
  73. package/out/renderer/mini.html +6 -6
  74. package/package.json +10 -3
  75. package/out/renderer/assets/agents-page-BQdR1fgV.js +0 -7
  76. package/out/renderer/assets/browser-page-BPwZPQY2.js +0 -1
  77. package/out/renderer/assets/channel-mWTr8anL.js +0 -1
  78. package/out/renderer/assets/classDiagram-GIVACNV2-DsNAZ0y6.js +0 -1
  79. package/out/renderer/assets/classDiagram-v2-COTLJTTW-DsNAZ0y6.js +0 -1
  80. package/out/renderer/assets/clone-48M_nlg0.js +0 -1
  81. package/out/renderer/assets/index-C5Ighs_C.css +0 -1
  82. package/out/renderer/assets/projects-page-C37RiUJz.js +0 -35
  83. package/out/renderer/assets/stateDiagram-v2-YXO3MK2T-BsbYpgkp.js +0 -1
  84. /package/out/renderer/assets/{index-D-TXozHA.js → index-CSNQxVPq.js} +0 -0
package/out/main/index.js CHANGED
@@ -102,7 +102,17 @@ const profileFields = zod.z.object({
102
102
  instructions: zod.z.string().max(8e3).optional(),
103
103
  tools: zod.z.array(zod.z.string().min(1).max(128)).max(256).optional(),
104
104
  effort: zod.z.string().max(64).optional(),
105
- delegate: zod.z.boolean().optional()
105
+ delegate: zod.z.boolean().optional(),
106
+ /*
107
+ * The action class (`reactive` | `proactive`), the field the Class control
108
+ * writes. An ENUM rather than a free string, because the route takes a
109
+ * `Literal` and a spelling this schema let through would be a 422 the
110
+ * caller could not tell from a bug — the same reasoning `scheduleUnit`
111
+ * above states. Optional like every other field here: an omitted key means
112
+ * "leave the class alone" on an update, which is what lets the control send
113
+ * the class and nothing else.
114
+ */
115
+ action_class: zod.z.enum(["reactive", "proactive"]).optional()
106
116
  }).strict();
107
117
  const teamFields = zod.z.object({
108
118
  name: zod.z.string().min(1).max(64).optional(),
@@ -268,6 +278,25 @@ const PROJECT_MILESTONE_NAME_MAX_CHARS = 80;
268
278
  const hubItemKind = zod.z.enum(["agent", "team"]);
269
279
  const desktopRequestUnion = zod.z.discriminatedUnion("op", [
270
280
  zod.z.object({ op: zod.z.literal("capabilities") }).strict(),
281
+ /*
282
+ * THE MACHINE'S RUNTIME ROSTER, read for the straggler census a completed
283
+ * server update reports (`GET /v1/desktop/runtimes`). A GET with a fixed
284
+ * shape, so the whole request is the op.
285
+ *
286
+ * It reads the INVENTORY (`probe=false` in the path composer): the census
287
+ * asks every row what build it booted with, and the route's per-row loopback
288
+ * connects answer a different question ("did it answer?") that nothing here
289
+ * acts on. Skipping them also keeps the response inside the control budget
290
+ * without depending on the probe pool's own deadline logic.
291
+ *
292
+ * THE CONTROL BUDGET IS THE RIGHT ONE, and it is a bound rather than a hope:
293
+ * the route's own ceiling is the two external reads in front of the
294
+ * composition (5 s process table + 3 s socket table, stated in `roster.py`),
295
+ * and `probe=false` skips the per-row connects entirely - so the worst case
296
+ * is ~8 s, well under the 20 s control deadline. It is NOT on the long-read
297
+ * list: nothing here fans out to a provider or walks a ledger.
298
+ */
299
+ zod.z.object({ op: zod.z.literal("runtimes.list") }).strict(),
271
300
  zod.z.object({ op: zod.z.literal("profiles.list") }).strict(),
272
301
  zod.z.object({ op: zod.z.literal("profiles.get"), name: profileName }).strict(),
273
302
  zod.z.object({ op: zod.z.literal("profiles.install"), name: profileName, requestId }).strict(),
@@ -757,11 +786,38 @@ const desktopRequestUnion = zod.z.discriminatedUnion("op", [
757
786
  zod.z.object({
758
787
  op: zod.z.literal("sessions.answer"),
759
788
  sessionId,
760
- epoch: id$1,
761
- requestId: id$1,
789
+ /**
790
+ * The owner epoch, for the two GATE shapes. Optional here because a queued
791
+ * ask has no epoch to give — `ask_id` is the other selector and the checks
792
+ * below require exactly one of the pair.
793
+ */
794
+ epoch: id$1.optional(),
795
+ requestId: id$1.optional(),
762
796
  value: zod.z.string().max(32768).optional(),
763
797
  approved: zod.z.boolean().optional(),
764
- questionIndex: zod.z.number().int().min(0).optional()
798
+ questionIndex: zod.z.number().int().min(0).optional(),
799
+ /**
800
+ * The queued ask's id (`a-3f9c`). Its presence selects the THIRD body shape —
801
+ * `{ask_id, answers}` or `{ask_id, decline}` — and, per the backend's own
802
+ * contract, makes `epoch` irrelevant rather than optional-but-checked: an ask
803
+ * outlives the owner that queued it, so requiring an epoch would refuse
804
+ * exactly the case the feature exists for (a cold session whose asks are
805
+ * still open). The single-winner rule that the epoch check used to provide
806
+ * now lives on the ask log, where the first `answered` event wins.
807
+ */
808
+ askId: id$1.optional(),
809
+ /**
810
+ * The WHOLE ask's answers, keyed by question id, each a list because a
811
+ * question may be multi-select.
812
+ *
813
+ * One atomic body rather than a per-question stream: the blocking path
814
+ * answered one question at a time over the wire, and a client that died
815
+ * part-way left an ask half-settled — the exact state the atomic submit
816
+ * exists to make unrepresentable.
817
+ */
818
+ answers: zod.z.record(zod.z.string(), zod.z.array(zod.z.string().max(32768))).optional(),
819
+ /** "No answer — decide yourself", the explicit form of today's Esc. */
820
+ decline: zod.z.boolean().optional()
765
821
  }).strict(),
766
822
  zod.z.object({
767
823
  op: zod.z.literal("sessions.seen"),
@@ -1236,7 +1292,25 @@ const desktopRequestUnion = zod.z.discriminatedUnion("op", [
1236
1292
  sessionId,
1237
1293
  requestId,
1238
1294
  message: zod.z.string().max(2e5).optional(),
1239
- boundary: zod.z.literal("next_safe").optional()
1295
+ /*
1296
+ * WHERE THE COPY STOPS. `next_safe` is the historical form and the default
1297
+ * (the whole committed conversation, waiting for a turn boundary);
1298
+ * `at_entry` cuts through `entryId` and needs no boundary, because a named
1299
+ * point is already committed.
1300
+ *
1301
+ * `entryId` is bounded rather than patterned for the route's reason -
1302
+ * entry ids are minted in more than one shape and existence in the
1303
+ * CONVERSATION is what decides whether one is real (the route answers an
1304
+ * unknown or foreign id with a refusal, never a fork of the wrong
1305
+ * history) - so this client validates the shape it can and lets the
1306
+ * backend adjudicate the rest.
1307
+ *
1308
+ * BOTH ARE OPTIONAL AND ABSENT IS THE OLD CALL, key for key: a typed
1309
+ * `/fork` and the palette's fork send neither, and the body builder below
1310
+ * omits `entry_id` entirely rather than sending it as null.
1311
+ */
1312
+ boundary: zod.z.enum(["next_safe", "at_entry"]).optional(),
1313
+ entryId: zod.z.string().max(128).optional()
1240
1314
  }).strict(),
1241
1315
  zod.z.object({
1242
1316
  op: zod.z.literal("sessions.stop"),
@@ -1760,6 +1834,20 @@ const desktopRequestUnion = zod.z.discriminatedUnion("op", [
1760
1834
  key: projectKey,
1761
1835
  name: zod.z.string().min(1).max(PROJECT_MILESTONE_NAME_MAX_CHARS)
1762
1836
  }).strict(),
1837
+ /*
1838
+ * The check-in fan-out (`POST /v1/desktop/projects/{project}/request-update`):
1839
+ * ask every linked session to post a progress update. APPENDED to the union
1840
+ * like its siblings, and gated by its OWN capability key
1841
+ * (`features.projects_request_update`) rather than a bump of `projects`: the
1842
+ * tab renders perfectly well against a backend that cannot ask its sessions
1843
+ * for anything, so the version would hide a working surface behind an update
1844
+ * it does not need (the `session_search` rule above). The key is the row's
1845
+ * address (id or name) - the same shape every other projects op takes.
1846
+ */
1847
+ zod.z.object({
1848
+ op: zod.z.literal("projects.request_update"),
1849
+ key: projectKey
1850
+ }).strict(),
1763
1851
  /*
1764
1852
  * AIDA'S CONTROL PLANE: one read and one control op on the same route
1765
1853
  * (`/v1/desktop/aida`), because the rail's row and the composer's `/aida`
@@ -1787,6 +1875,48 @@ const desktopRequestUnion = zod.z.discriminatedUnion("op", [
1787
1875
  ]);
1788
1876
  const desktopRequestSchema = desktopRequestUnion.superRefine(
1789
1877
  (request, ctx) => {
1878
+ if (request.op === "sessions.answer") {
1879
+ if (request.askId !== void 0) {
1880
+ if (!request.askId)
1881
+ ctx.addIssue({
1882
+ code: zod.z.ZodIssueCode.custom,
1883
+ message: "ask_id must be a non-empty string",
1884
+ path: ["askId"]
1885
+ });
1886
+ if (request.decline !== true && !request.answers)
1887
+ ctx.addIssue({
1888
+ code: zod.z.ZodIssueCode.custom,
1889
+ message: "A queued-ask answer needs answers or decline",
1890
+ path: ["answers"]
1891
+ });
1892
+ else if (request.decline === true && request.answers !== void 0)
1893
+ ctx.addIssue({
1894
+ code: zod.z.ZodIssueCode.custom,
1895
+ message: "Supply either answers or decline",
1896
+ path: ["decline"]
1897
+ });
1898
+ else if (request.answers !== void 0 && Object.keys(request.answers).length === 0)
1899
+ ctx.addIssue({
1900
+ code: zod.z.ZodIssueCode.custom,
1901
+ message: "answers must name at least one question",
1902
+ path: ["answers"]
1903
+ });
1904
+ return;
1905
+ }
1906
+ if (!request.epoch)
1907
+ ctx.addIssue({
1908
+ code: zod.z.ZodIssueCode.custom,
1909
+ message: "An epoch is required to answer a gate",
1910
+ path: ["epoch"]
1911
+ });
1912
+ if (!request.requestId)
1913
+ ctx.addIssue({
1914
+ code: zod.z.ZodIssueCode.custom,
1915
+ message: "A request id is required to answer a gate",
1916
+ path: ["requestId"]
1917
+ });
1918
+ return;
1919
+ }
1790
1920
  if (request.op === "sessions.create" && request.purpose === void 0) {
1791
1921
  if (request.cwd === void 0) {
1792
1922
  ctx.addIssue({
@@ -1982,6 +2112,9 @@ const READ_ONLY_OPS = /* @__PURE__ */ new Set([
1982
2112
  "profiles.get",
1983
2113
  "profiles.list",
1984
2114
  "providers.list",
2115
+ // A reader that changes nothing (the route's own docstring): the straggler
2116
+ // census, and the app re-reads it rather than caching a stale count.
2117
+ "runtimes.list",
1985
2118
  "sessions.aside.get",
1986
2119
  "sessions.checkpoints",
1987
2120
  "sessions.failovers",
@@ -2073,6 +2206,8 @@ function desktopEndpoint(request) {
2073
2206
  switch (request.op) {
2074
2207
  case "capabilities":
2075
2208
  return { path: "/v1/capabilities", method: "GET" };
2209
+ case "runtimes.list":
2210
+ return { path: "/v1/desktop/runtimes?probe=false", method: "GET" };
2076
2211
  /*
2077
2212
  * The mesh reads. Both are plain GETs with no parameters at all - the backend
2078
2213
  * reads THIS device's own relay, so there is nothing for the client to scope it
@@ -2406,6 +2541,16 @@ function desktopEndpoint(request) {
2406
2541
  }
2407
2542
  };
2408
2543
  case "sessions.answer":
2544
+ if (request.askId !== void 0)
2545
+ return {
2546
+ path: `/v1/desktop/sessions/${request.sessionId}/answers`,
2547
+ method: "POST",
2548
+ body: {
2549
+ ask_id: request.askId,
2550
+ answers: request.answers,
2551
+ decline: request.decline
2552
+ }
2553
+ };
2409
2554
  return {
2410
2555
  path: `/v1/desktop/sessions/${request.sessionId}/answers`,
2411
2556
  method: "POST",
@@ -2854,7 +2999,17 @@ function desktopEndpoint(request) {
2854
2999
  body: {
2855
3000
  request_id: request.requestId,
2856
3001
  message: request.message,
2857
- boundary: request.boundary
3002
+ boundary: request.boundary,
3003
+ /*
3004
+ * The route validates the two TOGETHER: `entry_id` with `next_safe`
3005
+ * is a 422, and `at_entry` without one is a 422. This builder does
3006
+ * not police that pairing (the schema above is what a renderer may
3007
+ * send; the route is what adjudicates it), but it does keep the
3008
+ * absent case byte-identical to the call that shipped before the
3009
+ * cut existed - `undefined` is dropped by `JSON.stringify`, so a
3010
+ * `next_safe` fork sends no `entry_id` key at all.
3011
+ */
3012
+ entry_id: request.entryId
2858
3013
  }
2859
3014
  };
2860
3015
  case "sessions.stop":
@@ -3088,6 +3243,11 @@ function desktopEndpoint(request) {
3088
3243
  path: `/v1/desktop/projects/${encodeURIComponent(request.key)}/milestones/${encodeURIComponent(request.name)}`,
3089
3244
  method: "DELETE"
3090
3245
  };
3246
+ case "projects.request_update":
3247
+ return {
3248
+ path: `/v1/desktop/projects/${encodeURIComponent(request.key)}/request-update`,
3249
+ method: "POST"
3250
+ };
3091
3251
  case "aida.status":
3092
3252
  return { path: "/v1/desktop/aida", method: "GET" };
3093
3253
  case "aida.control":
@@ -5733,9 +5893,12 @@ function resolveGlobalInstallPlan(input) {
5733
5893
  * no cost at all before the click (review U3). It named the restart as its
5734
5894
  * cost for the same reason - it was the one destructive thing this path
5735
5895
  * did. It no longer is: on the generation layout the install lands beside
5736
- * the running build and the server keeps serving the build it loaded until
5737
- * its own next idle, so the cost is the install and the wait, and no
5738
- * sentence here may describe a turn being dropped.
5896
+ * the running build and the server MOVES ONTO IT as part of the update -
5897
+ * the in-place serve reload, verified by the serving process's own record
5898
+ * (a restart remains only the fallback for a reload that cannot be proven
5899
+ * to land, and even that cuts no turn) - so the cost is the install and
5900
+ * the wait, and no sentence here may describe a turn being dropped or
5901
+ * defer the move to some later idle.
5739
5902
  *
5740
5903
  * Source build - the same disclosure with the one difference that matters:
5741
5904
  * this route DOES rewrite a tree a live runtime is reading, so its sentence
@@ -5754,7 +5917,7 @@ function resolveGlobalInstallPlan(input) {
5754
5917
  * panels, and in the by-hand panel the next line was the version sentence,
5755
5918
  * so the promise landed on the wrong line (review D5).
5756
5919
  */
5757
- remedy: managed ? "The app installs the new build beside the one in use and leaves the server you are using on the build it loaded, so nothing in flight is cut off. The server moves onto the new build when it is next idle." : sourceRebuildRoute ? "Rebuilds this checkout with `lop-update`. The app waits for the turns running on this machine to finish first, and the rebuild then reinstalls this install in place - so a turn started while it runs can still be interrupted - and it can take up to half an hour. This install keeps reporting the checkout's version, not the release the app offered." : "This install predates the non-disruptive installer, so update it once from your terminal; that updater rewrites the shared environment in place, which can interrupt sessions mid-turn. The app manages updates after that.",
5920
+ remedy: managed ? "The app installs the new build beside the one in use and moves the server onto it immediately, so nothing in flight is cut off." : sourceRebuildRoute ? "Rebuilds this checkout with `lop-update`. The app waits for the turns running on this machine to finish first, and the rebuild then reinstalls this install in place - so a turn started while it runs can still be interrupted - and it can take up to half an hour. This install keeps reporting the checkout's version, not the release the app offered." : "This install predates the non-disruptive installer, so update it once from your terminal; that updater rewrites the shared environment in place, which can interrupt sessions mid-turn. The app manages updates after that.",
5758
5921
  detail: `${detail}${managed ? provenance : sourceRebuildRoute ? " source build of this machine's checkout; an in-place rebuild." : provenance}`,
5759
5922
  sourceBuild,
5760
5923
  managedRoute: managed ? "entry-point" : sourceRebuildRoute ? "source-build" : null,
@@ -5859,8 +6022,10 @@ function serveRecord(pid, dir) {
5859
6022
  }
5860
6023
  function servingInstallReadings(record) {
5861
6024
  const version = record?.version?.trim() ?? "";
6025
+ const instanceId = record?.instance_id?.trim() ?? "";
5862
6026
  return {
5863
6027
  bootVersion: version === "" ? null : version,
6028
+ instanceId: instanceId === "" ? null : instanceId,
5864
6029
  prefix: record?.prefix?.trim() ?? "",
5865
6030
  installKind: record?.install_kind?.trim() ?? "",
5866
6031
  startedByApp: record?.desktop === true && (record.claim_key ?? "") === ""
@@ -10771,13 +10936,14 @@ class BackendServiceManager {
10771
10936
  * Stop only the captured serve generation, including failed startups. A port
10772
10937
  * or process name is not ownership. Unconfirmed exit blocks replacement.
10773
10938
  */
10774
- async stop(isRestart = false) {
10939
+ async stop(isRestart = false, options = {}) {
10775
10940
  this.startEpoch++;
10776
10941
  if (!isRestart) this.isAppClosing = true;
10777
10942
  this.disposeStreamRelay();
10778
10943
  this.stopHealthCheck();
10779
10944
  const generation = this.ownedServe;
10780
- if (generation) await this.stopGeneration(generation, isRestart);
10945
+ if (generation)
10946
+ await this.stopGeneration(generation, isRestart, options.graceMs);
10781
10947
  await this.startPromise;
10782
10948
  }
10783
10949
  captureServe(child) {
@@ -10835,8 +11001,17 @@ class BackendServiceManager {
10835
11001
  * "we signalled it and moved on" would authorise exactly the double-serve
10836
11002
  * this class exists to prevent. An unconfirmed exit keeps ownership, and the
10837
11003
  * caller reports failure instead of proceeding.
11004
+ *
11005
+ * `graceMs` OVERRIDES THE RESTART GRACE FOR ONE CALL (2026-09-30), added for
11006
+ * the update fallback: a serve being stopped because its reload could not be
11007
+ * proven drains for as long as uvicorn waits for its connections (the
11008
+ * server's own wait has no deadline), and the caller that knows the process
11009
+ * is about to be replaced may bound that wait. The ESCALATION is unchanged -
11010
+ * SIGTERM, the (possibly shorter) grace, SIGKILL, the force wait - so an
11011
+ * unresponsive process still fails closed, and a caller that passes nothing
11012
+ * gets the same grace it always got.
10838
11013
  */
10839
- stopGeneration(generation, isRestart) {
11014
+ stopGeneration(generation, isRestart, graceMs) {
10840
11015
  if (generation.stop) return generation.stop;
10841
11016
  generation.stop = (async () => {
10842
11017
  const waitForExit = (ms) => new Promise((resolve) => {
@@ -10856,7 +11031,7 @@ class BackendServiceManager {
10856
11031
  });
10857
11032
  });
10858
11033
  if (this.canSignal(generation)) generation.child.kill("SIGTERM");
10859
- const grace = isRestart ? this.shutdownTimeoutMs.restart : this.shutdownTimeoutMs.normal;
11034
+ const grace = graceMs ?? (isRestart ? this.shutdownTimeoutMs.restart : this.shutdownTimeoutMs.normal);
10860
11035
  if (!await waitForExit(grace)) {
10861
11036
  if (this.canSignal(generation)) generation.child.kill("SIGKILL");
10862
11037
  if (!await waitForExit(this.shutdownTimeoutMs.force)) {
@@ -11798,13 +11973,18 @@ class BackendServiceManager {
11798
11973
  * Restart the backend service
11799
11974
  * This method properly handles the restart process to ensure that any pending
11800
11975
  * timeouts from the stop operation don't affect the newly started process
11976
+ *
11977
+ * `options.stopGraceMs` bounds ONE restart's SIGTERM grace (see
11978
+ * `stopGeneration`); it exists for the update fallback, whose process is
11979
+ * already known to be replaced, and callers that pass nothing keep the
11980
+ * default grace exactly as before.
11801
11981
  * @returns Promise resolving to true if the restart was successful, false otherwise
11802
11982
  */
11803
- restart() {
11983
+ restart(options = {}) {
11804
11984
  if (this.restartPromise) return this.restartPromise;
11805
11985
  this.restartPromise = (async () => {
11806
11986
  try {
11807
- await this.stop(true);
11987
+ await this.stop(true, { graceMs: options.stopGraceMs });
11808
11988
  if (this.isAppClosing) return false;
11809
11989
  return await this.start();
11810
11990
  } catch (error) {
@@ -12264,14 +12444,22 @@ function reportParksAtQuit(sessions, report) {
12264
12444
  ].join(" ")
12265
12445
  );
12266
12446
  }
12267
- function reportSkippedWhileQuitting(context, requested) {
12447
+ function reportSkippedWhileQuitting(context, requested, deferredReopen = false) {
12268
12448
  context.report?.(
12269
12449
  [
12270
12450
  `trigger=${context.trigger}`,
12271
12451
  `mode=${MODE_OF_SHOW[requested]}`,
12272
12452
  `requested=${requested}`,
12273
12453
  ...requesterFields(context),
12274
- "applied=skipped+quitting"
12454
+ "applied=skipped+quitting",
12455
+ /*
12456
+ * THE COMPLETION TOKEN (#755): this refusal was recorded and will be
12457
+ * completed by one successor instance at the quit's terminal. Present
12458
+ * exactly when the recorder took the record — the create-gate refusals
12459
+ * (banner click, consent reopen, viewer verbs) are not completed and say
12460
+ * nothing extra, and neither does a `never` request.
12461
+ */
12462
+ ...deferredReopen ? ["reopen=deferred"] : []
12275
12463
  ].join(" ")
12276
12464
  );
12277
12465
  }
@@ -12314,13 +12502,15 @@ function readSecondLaunchRequest(input) {
12314
12502
  }
12315
12503
  function applySecondLaunch(request, target2) {
12316
12504
  if (target2.quitting) {
12505
+ const deferredReopen = target2.onRefusedWhileQuitting?.(request) === true;
12317
12506
  reportSkippedWhileQuitting(
12318
12507
  {
12319
12508
  trigger: "second-instance",
12320
12509
  requester: request.requester ?? void 0,
12321
12510
  report: target2.report
12322
12511
  },
12323
- request.show
12512
+ request.show,
12513
+ deferredReopen
12324
12514
  );
12325
12515
  return;
12326
12516
  }
@@ -13634,6 +13824,14 @@ const METHODS$1 = [
13634
13824
  "tabs",
13635
13825
  "scroll",
13636
13826
  "logs",
13827
+ // The structured read actions (`styles`, `hit_test`, `ancestors`), in
13828
+ // `protocol.py`'s tuple order: page-geometry reads answered by fixed
13829
+ // self-contained page functions (the vendored `driver/geometry-read.ts`),
13830
+ // capped and rounded in the page. This array is the wire's closed gate, so
13831
+ // it must stay byte-comparable with the Python source it mirrors.
13832
+ "styles",
13833
+ "hit_test",
13834
+ "ancestors",
13637
13835
  "request_access",
13638
13836
  "await_access",
13639
13837
  "cancel_access",
@@ -13692,7 +13890,13 @@ const ERROR_CODES = [
13692
13890
  // a command that hangs rather than one that fails.
13693
13891
  ...CONSOLE_ERROR_CODES
13694
13892
  ];
13695
- const HOST_CAPABILITIES = ["download", "upload"];
13893
+ const HOST_CAPABILITIES = [
13894
+ "styles",
13895
+ "hit_test",
13896
+ "ancestors",
13897
+ "download",
13898
+ "upload"
13899
+ ];
13696
13900
  const RUN_DIRNAME = path$1.join("run", "ui-browser");
13697
13901
  const STATE_FILENAME = "host.json";
13698
13902
  const STATE_DIR_MODE = 448;
@@ -19314,6 +19518,135 @@ function clampTimeout(requested) {
19314
19518
  if (!Number.isFinite(wanted) || wanted <= 0) return CAPS.downloadTimeoutS;
19315
19519
  return Math.min(wanted, CAPS.downloadTimeoutMaxS);
19316
19520
  }
19521
+ const READ_STYLES_SOURCE = '(selector, properties) => {\n const DEFAULTS = [\n "display",\n "position",\n "visibility",\n "opacity",\n "z-index",\n "transform",\n "transform-origin",\n "top",\n "right",\n "bottom",\n "left",\n "width",\n "height",\n "contain",\n "filter",\n "will-change",\n "overflow",\n "overflow-x",\n "overflow-y",\n "pointer-events"\n ];\n const MAX_MATCHES = 5;\n const MAX_STYLES = 30;\n const MAX_INLINE = 30;\n const MAX_IDENTITY = 120;\n const MAX_VALUE = 200;\n const all = document.querySelectorAll(selector);\n if (!all || all.length === 0) return null;\n const names = DEFAULTS.slice();\n if (Array.isArray(properties)) {\n for (let i = 0; i < properties.length && names.length < MAX_STYLES; i++) {\n const name = String(properties[i] ?? "").trim();\n if (name && names.indexOf(name) === -1) names.push(name);\n }\n }\n const round2 = (value) => {\n const rounded = Math.round(value * 100) / 100;\n return rounded === 0 ? 0 : rounded;\n };\n const clip = (value, limit) => value.length > limit ? value.slice(0, limit - 1) + "\\u2026" : value;\n const rectOf = (el) => {\n const r = el.getBoundingClientRect();\n return {\n x: round2(r.x),\n y: round2(r.y),\n top: round2(r.top),\n right: round2(r.right),\n bottom: round2(r.bottom),\n width: round2(r.width),\n height: round2(r.height)\n };\n };\n const attrOf = (el, name) => clip(el.getAttribute(name) || "", MAX_IDENTITY);\n const matches = [];\n const first = Array.from(all).slice(0, MAX_MATCHES);\n for (const el of first) {\n const computed = getComputedStyle(el);\n const styles = {};\n for (const name of names) {\n styles[name] = clip(String(computed.getPropertyValue(name) || "").trim(), MAX_VALUE);\n }\n const inline = {};\n const declaration = el.style;\n let inlineCount = 0;\n for (let j = 0; j < declaration.length && inlineCount < MAX_INLINE; j++) {\n const name = declaration.item(j);\n if (name && name.indexOf("--") === 0) {\n const key = clip(name, MAX_IDENTITY);\n if (!(key in inline)) {\n inline[key] = clip(\n String(declaration.getPropertyValue(name) || "").trim(),\n MAX_VALUE\n );\n inlineCount += 1;\n }\n }\n }\n matches.push({\n tag: clip(el.tagName.toLowerCase(), MAX_IDENTITY),\n id: attrOf(el, "id"),\n role: attrOf(el, "role"),\n className: attrOf(el, "class"),\n rect: rectOf(el),\n styles,\n inline\n });\n }\n return { count: matches.length, matches, truncated: all.length > matches.length };\n}';
19522
+ const HIT_TEST_SOURCE = '(x, y) => {\n const PROPS = ["display", "position", "visibility", "opacity", "z-index", "pointer-events"];\n const MAX_ELEMENTS = 8;\n const MAX_IDENTITY = 120;\n const MAX_VALUE = 200;\n const px = Number(x);\n const py = Number(y);\n if (!Number.isFinite(px) || !Number.isFinite(py)) return null;\n const stack = document.elementsFromPoint(px, py);\n if (!stack || stack.length === 0) return null;\n const round2 = (value) => {\n const rounded = Math.round(value * 100) / 100;\n return rounded === 0 ? 0 : rounded;\n };\n const clip = (value, limit) => value.length > limit ? value.slice(0, limit - 1) + "\\u2026" : value;\n const attrOf = (el, name) => clip(el.getAttribute(name) || "", MAX_IDENTITY);\n const elements = [];\n for (const el of stack.slice(0, MAX_ELEMENTS)) {\n const computed = getComputedStyle(el);\n const styles = {};\n for (const name of PROPS) {\n styles[name] = clip(String(computed.getPropertyValue(name) || "").trim(), MAX_VALUE);\n }\n const r = el.getBoundingClientRect();\n elements.push({\n tag: clip(el.tagName.toLowerCase(), MAX_IDENTITY),\n id: attrOf(el, "id"),\n role: attrOf(el, "role"),\n className: attrOf(el, "class"),\n rect: {\n x: round2(r.x),\n y: round2(r.y),\n top: round2(r.top),\n right: round2(r.right),\n bottom: round2(r.bottom),\n width: round2(r.width),\n height: round2(r.height)\n },\n styles\n });\n }\n return { count: elements.length, elements };\n}';
19523
+ const ANCESTORS_SOURCE = '(selector, depth) => {\n const PROPS = [\n "display",\n "position",\n "visibility",\n "overflow",\n "overflow-x",\n "overflow-y",\n "transform",\n "transform-origin",\n "contain",\n "filter",\n "will-change",\n "z-index",\n "isolation",\n "top",\n "left"\n ];\n const DEFAULT_DEPTH = 12;\n const MAX_DEPTH = 16;\n const MAX_IDENTITY = 120;\n const MAX_VALUE = 200;\n const el = document.querySelector(selector);\n if (!el) return null;\n const wanted = typeof depth === "number" && Number.isFinite(depth) ? Math.floor(depth) : DEFAULT_DEPTH;\n const bound = Math.max(1, Math.min(MAX_DEPTH, wanted));\n const round2 = (value) => {\n const rounded = Math.round(value * 100) / 100;\n return rounded === 0 ? 0 : rounded;\n };\n const clip = (value, limit) => value.length > limit ? value.slice(0, limit - 1) + "\\u2026" : value;\n const attrOf = (el2, name) => clip(el2.getAttribute(name) || "", MAX_IDENTITY);\n const chain = [];\n let node = el;\n while (node && chain.length < bound) {\n const computed = getComputedStyle(node);\n const styles = {};\n for (const name of PROPS) {\n styles[name] = clip(String(computed.getPropertyValue(name) || "").trim(), MAX_VALUE);\n }\n const r = node.getBoundingClientRect();\n chain.push({\n tag: clip(node.tagName.toLowerCase(), MAX_IDENTITY),\n id: attrOf(node, "id"),\n role: attrOf(node, "role"),\n className: attrOf(node, "class"),\n rect: {\n x: round2(r.x),\n y: round2(r.y),\n top: round2(r.top),\n right: round2(r.right),\n bottom: round2(r.bottom),\n width: round2(r.width),\n height: round2(r.height)\n },\n styles\n });\n if (node === document.documentElement) break;\n node = node.parentElement;\n }\n return { count: chain.length, chain };\n}';
19524
+ const MAX_REQUESTED_PROPERTIES = 20;
19525
+ const DEFAULT_DEPTH = 12;
19526
+ const MAX_DEPTH = 16;
19527
+ async function runFixed(record, source, args, label) {
19528
+ const code = `(${source})(${args.map((arg) => JSON.stringify(arg)).join(", ")})`;
19529
+ return await deadline(
19530
+ record.view.webContents.executeJavaScriptInIsolatedWorld(
19531
+ ISOLATED_WORLD_ID,
19532
+ [{ code }]
19533
+ ),
19534
+ SCRIPTING_DEADLINE_MS,
19535
+ label
19536
+ );
19537
+ }
19538
+ function selectorError(selector, error) {
19539
+ if (error instanceof Error && INVALID_SELECTOR.test(error.message)) {
19540
+ return new BridgeCommandError(
19541
+ "element_not_found",
19542
+ `selector ${selector} is not valid`
19543
+ );
19544
+ }
19545
+ return error;
19546
+ }
19547
+ function structuredResult(name, result) {
19548
+ if (typeof result !== "object" || result === null) {
19549
+ throw new BridgeCommandError(
19550
+ "internal",
19551
+ `the page returned no structured ${name} result`
19552
+ );
19553
+ }
19554
+ return result;
19555
+ }
19556
+ function requiredSelector(params) {
19557
+ const selector = stringParam(params, "selector");
19558
+ if (!selector) {
19559
+ throw new BridgeCommandError("element_not_found", "selector is required");
19560
+ }
19561
+ return selector;
19562
+ }
19563
+ function requestedProperties(params) {
19564
+ if (!Array.isArray(params.properties)) return [];
19565
+ return params.properties.filter((entry) => typeof entry === "string").slice(0, MAX_REQUESTED_PROPERTIES);
19566
+ }
19567
+ async function styles(ctx, params) {
19568
+ const record = ctx.registry.requireSurface(params.tab);
19569
+ const selector = requiredSelector(params);
19570
+ const properties = requestedProperties(params);
19571
+ let result;
19572
+ try {
19573
+ result = await runFixed(
19574
+ record,
19575
+ READ_STYLES_SOURCE,
19576
+ [selector, properties],
19577
+ `styles(${selector})`
19578
+ );
19579
+ } catch (error) {
19580
+ throw selectorError(selector, error);
19581
+ }
19582
+ if (result === null || result === void 0) {
19583
+ throw new BridgeCommandError(
19584
+ "element_not_found",
19585
+ `selector ${selector} matched nothing`
19586
+ );
19587
+ }
19588
+ ctx.registry.touch(record);
19589
+ return { ...structuredResult("styles", result), ...pageOf(record.view) };
19590
+ }
19591
+ async function hitTest(ctx, params) {
19592
+ const record = ctx.registry.requireSurface(params.tab);
19593
+ const x = numberParam(params, "x");
19594
+ const y = numberParam(params, "y");
19595
+ if (x === void 0 || y === void 0) {
19596
+ throw new BridgeCommandError(
19597
+ "internal",
19598
+ "'hit_test' needs numeric x and y (viewport coordinates)"
19599
+ );
19600
+ }
19601
+ const result = await runFixed(
19602
+ record,
19603
+ HIT_TEST_SOURCE,
19604
+ [x, y],
19605
+ `hit_test(${x}, ${y})`
19606
+ );
19607
+ if (result === null || result === void 0) {
19608
+ throw new BridgeCommandError(
19609
+ "element_not_found",
19610
+ `no element at point (${x}, ${y})`
19611
+ );
19612
+ }
19613
+ ctx.registry.touch(record);
19614
+ return {
19615
+ ...structuredResult("hit_test", result),
19616
+ ...pageOf(record.view)
19617
+ };
19618
+ }
19619
+ async function ancestors(ctx, params) {
19620
+ const record = ctx.registry.requireSurface(params.tab);
19621
+ const selector = requiredSelector(params);
19622
+ const raw = numberParam(params, "depth");
19623
+ const depth = Math.max(
19624
+ 1,
19625
+ Math.min(MAX_DEPTH, raw === void 0 ? DEFAULT_DEPTH : Math.floor(raw))
19626
+ );
19627
+ let result;
19628
+ try {
19629
+ result = await runFixed(
19630
+ record,
19631
+ ANCESTORS_SOURCE,
19632
+ [selector, depth],
19633
+ `ancestors(${selector})`
19634
+ );
19635
+ } catch (error) {
19636
+ throw selectorError(selector, error);
19637
+ }
19638
+ if (result === null || result === void 0) {
19639
+ throw new BridgeCommandError(
19640
+ "element_not_found",
19641
+ `selector ${selector} matched nothing`
19642
+ );
19643
+ }
19644
+ ctx.registry.touch(record);
19645
+ return {
19646
+ ...structuredResult("ancestors", result),
19647
+ ...pageOf(record.view)
19648
+ };
19649
+ }
19317
19650
  const MAX_AGENT_TABS = 8;
19318
19651
  const BACKGROUND_VIEWPORT = {
19319
19652
  x: 0,
@@ -20278,6 +20611,12 @@ function factOf(path2) {
20278
20611
  }
20279
20612
  const DOCUMENT_SCOPED = /* @__PURE__ */ new Set([
20280
20613
  "read",
20614
+ // The structured reads (`styles`, `hit_test`, `ancestors`) describe the
20615
+ // CURRENT document exactly as `read` does, so a result that raced a
20616
+ // navigation it did not cause must be discarded the same way.
20617
+ "styles",
20618
+ "hit_test",
20619
+ "ancestors",
20281
20620
  "snapshot",
20282
20621
  "screenshot",
20283
20622
  "click",
@@ -20305,6 +20644,9 @@ const NAVIGATION_ACTIONS = /* @__PURE__ */ new Set(["click", "type"]);
20305
20644
  const TAB_SCOPED = /* @__PURE__ */ new Set([
20306
20645
  "goto",
20307
20646
  "read",
20647
+ "styles",
20648
+ "hit_test",
20649
+ "ancestors",
20308
20650
  "snapshot",
20309
20651
  "screenshot",
20310
20652
  "click",
@@ -20482,6 +20824,12 @@ class BrowserHost {
20482
20824
  return retitle();
20483
20825
  case "read":
20484
20826
  return read(this, params);
20827
+ case "styles":
20828
+ return styles(this, params);
20829
+ case "hit_test":
20830
+ return hitTest(this, params);
20831
+ case "ancestors":
20832
+ return ancestors(this, params);
20485
20833
  case "snapshot":
20486
20834
  return snapshot(this, params);
20487
20835
  case "scroll":
@@ -24238,6 +24586,7 @@ async function startBrowserHost(options) {
24238
24586
  }
24239
24587
  });
24240
24588
  window.contentView.addChildView(view);
24589
+ view.setBounds(BACKGROUND_VIEWPORT);
24241
24590
  view.setVisible(false);
24242
24591
  return view;
24243
24592
  }
@@ -33564,6 +33913,49 @@ function createQuitState() {
33564
33913
  isQuitting: () => quitting
33565
33914
  };
33566
33915
  }
33916
+ function successorArgs(entry, facts) {
33917
+ if (entry.kind === "activate") {
33918
+ if (!facts.defaultApp) return [];
33919
+ return withModePin(facts.argv.slice(1), entry.show);
33920
+ }
33921
+ if (entry.argv === null) return [];
33922
+ return withModePin(entry.argv.slice(1), entry.show);
33923
+ }
33924
+ function withModePin(args, show) {
33925
+ const namesAMode = args.some(
33926
+ (argument) => argument === WINDOW_MODE_FLAG || argument.startsWith(`${WINDOW_MODE_FLAG}=`)
33927
+ );
33928
+ return namesAMode ? [...args] : [...args, `${WINDOW_MODE_FLAG}=${MODE_OF_SHOW[show]}`];
33929
+ }
33930
+ function defaultSuccessorFacts() {
33931
+ return { defaultApp: process.defaultApp === true, argv: process.argv };
33932
+ }
33933
+ function createRelaunchPending(options = {}) {
33934
+ const facts = options.facts ?? defaultSuccessorFacts;
33935
+ let pending = null;
33936
+ let scheduled = false;
33937
+ return {
33938
+ hasPending: () => pending !== null,
33939
+ record: (entry) => {
33940
+ if (!canCreateWindowFor(entry.show)) return false;
33941
+ pending = {
33942
+ ...entry,
33943
+ argv: entry.argv === null ? null : [...entry.argv]
33944
+ };
33945
+ return true;
33946
+ },
33947
+ clear: () => {
33948
+ pending = null;
33949
+ },
33950
+ scheduleOnExit: ({ relaunch }) => {
33951
+ if (scheduled) return;
33952
+ const entry = pending;
33953
+ if (entry === null) return;
33954
+ scheduled = true;
33955
+ relaunch(successorArgs(entry, facts()));
33956
+ }
33957
+ };
33958
+ }
33567
33959
  function titlebarOptions(platform, chrome) {
33568
33960
  if (platform === "darwin") {
33569
33961
  return { titleBarStyle: "hidden" };
@@ -33599,6 +33991,123 @@ function titlebarOptions(platform, chrome) {
33599
33991
  }
33600
33992
  };
33601
33993
  }
33994
+ const UPDATE_REPORT_KEY = "update_report";
33995
+ const UPDATE_REPORT_SCHEMA_PREFIX = "update.report.";
33996
+ const DAEMON_STATUSES = /* @__PURE__ */ new Set([
33997
+ "refreshed",
33998
+ "already",
33999
+ "failed",
34000
+ "unsupervised"
34001
+ ]);
34002
+ const isRecord = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
34003
+ function parseUpdateReport(stdout) {
34004
+ let found = null;
34005
+ for (const line of stdout.split("\n")) {
34006
+ const trimmed = line.trim();
34007
+ if (!trimmed.startsWith("{") || !trimmed.includes(UPDATE_REPORT_KEY))
34008
+ continue;
34009
+ let payload;
34010
+ try {
34011
+ payload = JSON.parse(trimmed);
34012
+ } catch {
34013
+ continue;
34014
+ }
34015
+ const report = reportFromPayload(payload);
34016
+ if (report) found = report;
34017
+ }
34018
+ return found;
34019
+ }
34020
+ function reportFromPayload(payload) {
34021
+ if (!isRecord(payload)) return null;
34022
+ const raw = payload[UPDATE_REPORT_KEY];
34023
+ if (!isRecord(raw)) return null;
34024
+ const schema2 = raw.schema;
34025
+ if (typeof schema2 !== "string" || !schema2.startsWith(UPDATE_REPORT_SCHEMA_PREFIX))
34026
+ return null;
34027
+ const installVersion = raw.install_version;
34028
+ const target2 = raw.target;
34029
+ if (typeof installVersion !== "string" || typeof target2 !== "string")
34030
+ return null;
34031
+ const serve = raw.serve;
34032
+ const daemons = raw.daemons;
34033
+ if (!Array.isArray(serve) || !Array.isArray(daemons)) return null;
34034
+ const serveEntries = [];
34035
+ for (const entry of serve) {
34036
+ if (!isRecord(entry)) return null;
34037
+ const { pid, from, to, moved, instance } = entry;
34038
+ if (typeof pid !== "number" || typeof from !== "string" || typeof to !== "string" || typeof moved !== "boolean" || typeof instance !== "string")
34039
+ return null;
34040
+ serveEntries.push({ pid, from, to, moved, instance });
34041
+ }
34042
+ const daemonEntries = [];
34043
+ for (const entry of daemons) {
34044
+ if (!isRecord(entry)) return null;
34045
+ const { name, status: status2, version } = entry;
34046
+ if (typeof name !== "string" || typeof status2 !== "string" || !DAEMON_STATUSES.has(status2) || typeof version !== "string")
34047
+ return null;
34048
+ daemonEntries.push({
34049
+ name,
34050
+ status: status2,
34051
+ version
34052
+ });
34053
+ }
34054
+ return {
34055
+ schema: schema2,
34056
+ install_version: installVersion,
34057
+ target: target2,
34058
+ serve: serveEntries,
34059
+ daemons: daemonEntries
34060
+ };
34061
+ }
34062
+ function serveMovedOntoBuild(input) {
34063
+ if (!input.healthOk) return false;
34064
+ const before = input.instanceBefore?.trim() ?? "";
34065
+ if (before === "") return false;
34066
+ const after = input.recordAfter.instanceId?.trim() || input.reportServe?.instance.trim() || "";
34067
+ if (after === "" || after === before) return false;
34068
+ return serveOnTarget(
34069
+ input.recordAfter.bootVersion,
34070
+ input.installVersion,
34071
+ input.target
34072
+ );
34073
+ }
34074
+ function serveOnTarget(bootVersion, installVersion, target2) {
34075
+ const boot = bootVersion?.trim() ?? "";
34076
+ if (boot === "") return false;
34077
+ const wanted = target2?.trim() ?? "";
34078
+ if (wanted !== "") {
34079
+ const order2 = compareVersions(boot, wanted);
34080
+ return order2 !== null && order2 >= 0;
34081
+ }
34082
+ const installed = installVersion?.trim() ?? "";
34083
+ if (installed === "") return false;
34084
+ const order = compareVersions(boot, installed);
34085
+ return order === null ? boot === installed : order >= 0;
34086
+ }
34087
+ function runtimeStragglerCount(body, current) {
34088
+ const currentBuild = current?.trim() ?? "";
34089
+ if (currentBuild === "") return null;
34090
+ if (!isRecord(body)) return null;
34091
+ const result = body.result;
34092
+ if (!isRecord(result)) return null;
34093
+ const runtimes = result.runtimes;
34094
+ if (!Array.isArray(runtimes)) return null;
34095
+ let count = 0;
34096
+ for (const row of runtimes) {
34097
+ if (!isRecord(row)) continue;
34098
+ const build = typeof row.build_version === "string" ? row.build_version.trim() : "";
34099
+ if (build === "") continue;
34100
+ if (build !== currentBuild) count += 1;
34101
+ }
34102
+ return count;
34103
+ }
34104
+ function daemonMoveOutcome(report) {
34105
+ if (report === null) return null;
34106
+ const failed = report.daemons.filter(
34107
+ (daemon) => daemon.status === "failed" || daemon.status === "unsupervised"
34108
+ ).map((daemon) => daemon.name);
34109
+ return { source: "report", moved: failed.length === 0, failed };
34110
+ }
33602
34111
  const CHROMIUM_TRANSPORT_CODES = [
33603
34112
  "ERR_INTERNET_DISCONNECTED",
33604
34113
  "ERR_NETWORK_CHANGED",
@@ -34388,6 +34897,7 @@ const BETA_VERSION_REGEX = /v\d+\.\d+\.\d+\.beta\.\d+/;
34388
34897
  const ARCHITECTURE_SEPARATOR_REGEX = /\s+/;
34389
34898
  const GLOBAL_UPDATE_TIMEOUT_MS = 15 * 60 * 1e3;
34390
34899
  const SOURCE_REBUILD_TIMEOUT_MS = 30 * 60 * 1e3;
34900
+ const UPDATE_RESTART_STOP_GRACE_MS = 5e3;
34391
34901
  const BUNDLE_ID_REGEX = /^[\w.-]+$/;
34392
34902
  const INSTALL_IN_FLIGHT_RECHECK_MS = 1e4;
34393
34903
  const LAUNCHCTL_NOT_LOADED_REGEX = /could not find|no such process/i;
@@ -35847,6 +36357,21 @@ class UpdateService {
35847
36357
  this.ensureWatchdogAfterInFlightQuit(marker);
35848
36358
  return true;
35849
36359
  }
36360
+ /**
36361
+ * Whether this quit's RELAUNCH is already owned by the update lane, as a
36362
+ * read-only question for the quit's own terminal.
36363
+ *
36364
+ * The same fact `quitForInFlightInstall` decides: when a live pending install
36365
+ * exists, the app quits so Squirrel's swap can proceed and the relaunch
36366
+ * watchdog is what brings it back. A second return path arriving at that swap
36367
+ * is exactly the failure both lanes guard against — Squirrel's last check
36368
+ * aborts an install when any instance of the target app is running — so #755's
36369
+ * successor launch (a reopen refused during the quit, completed with
36370
+ * `app.relaunch` at the quit terminal) must stand down while this is true.
36371
+ */
36372
+ inFlightInstallOwnsRelaunch() {
36373
+ return this.inFlightQuitWatchdogEnsured;
36374
+ }
35850
36375
  /**
35851
36376
  * Kill a watchdog still running from the recorded install attempt.
35852
36377
  *
@@ -38877,6 +39402,219 @@ ${heal.removed.join("\n")}`,
38877
39402
  async readFleetSnapshot(backend) {
38878
39403
  return backend.servingSessionFleet();
38879
39404
  }
39405
+ /**
39406
+ * The serving process's own record, both fields from ONE read.
39407
+ *
39408
+ * The post-swap move proof compares an instance token against the pre-swap
39409
+ * one, and a pair assembled from two reads can straddle a republish (a new
39410
+ * instance under the OLD version string, or the reverse) - so the two fields
39411
+ * come out of a single `servingInstall()` call, which reads the record file
39412
+ * once. Absence is both-fields-null, never a papered-over reading: a record
39413
+ * that could not be read proves nothing and the verdict says so.
39414
+ */
39415
+ servingRecordReadings() {
39416
+ const readings = this.backendService?.servingInstall().readings ?? servingInstallReadings(null);
39417
+ return {
39418
+ bootVersion: readings.bootVersion,
39419
+ instanceId: readings.instanceId
39420
+ };
39421
+ }
39422
+ /**
39423
+ * The pre-swap rows still resident in a fresh read, or null when unreadable.
39424
+ *
39425
+ * The liveness count's own arithmetic, in one place because two callers need
39426
+ * it in different shapes (the restart fallback counts from this, the moved
39427
+ * path's fallback counts THROUGH it). A read that could not be taken is null
39428
+ * rather than an empty set: "nothing resident" would be a measured zero, and
39429
+ * an unanswered roster is not a measurement. A cold row (an empty
39430
+ * `liveState`, the catalogue's spelling for a session with no runtime) is not
39431
+ * resident - `isLiveRow` owns that rule, the same one the engagement diff
39432
+ * uses.
39433
+ */
39434
+ stillResidentRows(before, after) {
39435
+ if (after === null) return null;
39436
+ const liveNow = new Set(
39437
+ after.filter((row) => isLiveRow(row)).map((row) => row.sessionId)
39438
+ );
39439
+ return before.filter((row) => isLiveRow(row) && liveNow.has(row.sessionId));
39440
+ }
39441
+ /**
39442
+ * The BUILD-accurate straggler count: runtimes not on the build the server
39443
+ * is on, from the machine's runtime roster, or null when it did not answer.
39444
+ *
39445
+ * THIS IS THE PRIMARY CENSUS since 2026-09-30 (design §5): `GET
39446
+ * /v1/desktop/runtimes?probe=false` returns one row per live runtime with the
39447
+ * `build_version` the runtime's own record (or boot record) carries, so the
39448
+ * count answers the question the notice's sentence makes - "still on the old
39449
+ * version" - from a build reading rather than from a liveness proxy. An empty
39450
+ * `build_version` is UNKNOWN and is not counted (the roster journal's own
39451
+ * rule), and a row whose build equals the current one has already rolled.
39452
+ *
39453
+ * Null (unreadable body, non-200, a transport failure, or no current build to
39454
+ * compare against) sends the caller to the liveness fallback; the failure is
39455
+ * logged here, where the transport error is still in hand. The read is
39456
+ * deliberately per-call rather than cached: it is the one reading whose whole
39457
+ * point is that it moves as runtimes roll, so every completion asks the
39458
+ * machine again. A notice-reopen re-read is NOT wired (review round 1's A1):
39459
+ * no renderer->main census channel exists yet, so the count travels with the
39460
+ * completion it was measured for - recorded as deferred on the PR rather
39461
+ * than claimed here.
39462
+ */
39463
+ async readRuntimeCensus(backend, current) {
39464
+ try {
39465
+ const response = await backend.requestDesktop({ op: "runtimes.list" });
39466
+ if (response.status !== 200) {
39467
+ logger.info(
39468
+ `Runtime roster did not answer (${response.status}); the straggler count falls back to the liveness reading`,
39469
+ LogFileType.UPDATE_SERVICE
39470
+ );
39471
+ return null;
39472
+ }
39473
+ const count = runtimeStragglerCount(response.body, current);
39474
+ if (count === null) {
39475
+ logger.info(
39476
+ "Runtime roster answered with no usable rows; the straggler count falls back to the liveness reading",
39477
+ LogFileType.UPDATE_SERVICE
39478
+ );
39479
+ }
39480
+ return count;
39481
+ } catch (error) {
39482
+ logger.info(
39483
+ `Runtime roster read failed (${error instanceof Error ? error.message : String(error)}); the straggler count falls back to the liveness reading`,
39484
+ LogFileType.UPDATE_SERVICE
39485
+ );
39486
+ return null;
39487
+ }
39488
+ }
39489
+ /**
39490
+ * The straggler count and where it came from: build first, liveness second.
39491
+ *
39492
+ * The build count (`readRuntimeCensus`) is the primary. When the roster does
39493
+ * not answer, the fallback is the EXISTING liveness reading, in whichever
39494
+ * shape the path already has it: the restart fallback passes the rows its own
39495
+ * post-restart read found resident (`resident`), while the moved path passes
39496
+ * the pre-update snapshot (`before`) and this takes one fresh fleet read to
39497
+ * count it. A path with neither - no snapshot, or a fleet read that failed -
39498
+ * answers null/null, which the notice renders as the numberless sentence: a
39499
+ * count that cannot be measured is not a zero.
39500
+ *
39501
+ * The source travels with the count for the RECORD, and for any later reader
39502
+ * that needs to know how it was measured - it does not choose the notice's
39503
+ * words. The notice keeps ONE session-family sentence for both sources
39504
+ * (designer copy consult, 2026-09-30: the producer's vocabulary belongs in
39505
+ * the payload field, never in the reader's sentence), so a build count is not
39506
+ * printed as "N runtimes" and the liveness reading's documented over-count
39507
+ * rides the field beside the count instead of changing the sentence.
39508
+ */
39509
+ async readStragglerCensus(input) {
39510
+ const build = await this.readRuntimeCensus(input.backend, input.current);
39511
+ if (build !== null) return { count: build, source: "build" };
39512
+ let resident;
39513
+ if (input.resident !== void 0) {
39514
+ resident = input.resident;
39515
+ } else if (input.before != null) {
39516
+ resident = this.stillResidentRows(
39517
+ input.before,
39518
+ await this.readFleetSnapshot(input.backend)
39519
+ );
39520
+ } else {
39521
+ resident = null;
39522
+ }
39523
+ if (resident === null) return { count: null, source: null };
39524
+ logger.info(
39525
+ `Straggler count from the liveness reading: ${resident.length} pre-swap session(s) still resident`,
39526
+ LogFileType.UPDATE_SERVICE
39527
+ );
39528
+ return { count: resident.length, source: "liveness" };
39529
+ }
39530
+ /**
39531
+ * The restart fallback's tail for a generation install: ONE read, the
39532
+ * engage, and the resident set - no retire wait (2026-09-30).
39533
+ *
39534
+ * WHY THERE IS NO WAIT HERE. The rebuild route's `reengageDisplacedSessions`
39535
+ * waits for the pre-swap set to retire because a rebuild rewrites the tree a
39536
+ * runtime is reading, so runtimes really do die under it and the wave takes
39537
+ * ~35 s to pass. A serve restart displaces nothing by itself - session
39538
+ * runtimes are detached and survived the bounce (they retire on their own
39539
+ * build check, whenever their next idle is) - and the measured ordinary shape
39540
+ * was an idle-gated fleet that never moved, so the wait burned its full 60 s
39541
+ * grace for an engage it never performed. The re-engage opportunity it
39542
+ * represented is idle's job now (the 2026-09-29 directive), and the honest
39543
+ * statement replaces the wait: engage only the sessions THIS read shows
39544
+ * without a runtime, and count the rest as still resident.
39545
+ *
39546
+ * IDEMPOTENT BY THE SAME READ: the engage set is the difference the one read
39547
+ * shows, so a runtime that came back on its own is never spawned twice.
39548
+ * Returns null when the read could not be taken - not measured, which the
39549
+ * census and the caller's log both carry as such.
39550
+ */
39551
+ async engageDisplacedAfterFallbackRestart(backend, snapshot2) {
39552
+ if (snapshot2 === null) return null;
39553
+ if (snapshot2.length === 0) return [];
39554
+ const after = await this.readFleetSnapshot(backend);
39555
+ const displaced = displacedSessions(snapshot2, after);
39556
+ const engaged = [];
39557
+ const failed = [];
39558
+ for (const row of displaced) {
39559
+ try {
39560
+ if (await this.engageSessionRuntime(backend, row))
39561
+ engaged.push(row.sessionId);
39562
+ else
39563
+ failed.push({
39564
+ sessionId: row.sessionId,
39565
+ reason: "the server did not take the engage"
39566
+ });
39567
+ } catch (error) {
39568
+ failed.push({
39569
+ sessionId: row.sessionId,
39570
+ reason: error instanceof Error ? error.message : String(error)
39571
+ });
39572
+ }
39573
+ }
39574
+ logger.info(
39575
+ `Re-engaged ${engaged.length} of ${displaced.length} session(s) displaced by the restart${failed.length > 0 ? `; ${failed.length} did not answer (${failed.map((row) => row.sessionId).join(", ")})` : ""}`,
39576
+ LogFileType.UPDATE_SERVICE
39577
+ );
39578
+ if (after === null) return null;
39579
+ return this.stillResidentRows(snapshot2, after);
39580
+ }
39581
+ /**
39582
+ * Say the rollover's evidence, once, where a post-mortem reads it.
39583
+ *
39584
+ * THE HONEST DEGRADE IS THE POINT (task §3): with the updater's structured
39585
+ * report absent - any core build before `update.report.v1` - serve is proven
39586
+ * by its own record and the daemons are BEST-EFFORT. That is said in exactly
39587
+ * those words rather than faked from a line that is not there, because the
39588
+ * alternative (treating an absent report as "daemons fine") is how a
39589
+ * user-visible claim gets made with no evidence behind it. A report that NAMES a
39590
+ * failed daemon is surfaced as a warning beside the same line.
39591
+ *
39592
+ * Names the three inputs the verdict read - the instance pair, the boot
39593
+ * reading, the `/health` answer - so a wrong verdict can be reconstructed
39594
+ * from the log without re-running anything.
39595
+ */
39596
+ logRolloverEvidence(input) {
39597
+ const verdict = input.moved ? "proven moved" : "not proven moved";
39598
+ const facts = `instance ${input.instanceBefore ?? "no reading"} -> ${input.recordAfter.instanceId ?? "no reading"}, boot ${input.recordAfter.bootVersion ?? "no reading"}, /health ${input.healthOk ? "answered" : "did not answer"}`;
39599
+ const daemons = daemonMoveOutcome(input.report);
39600
+ if (daemons === null) {
39601
+ logger.info(
39602
+ `Rollover evidence: serve ${verdict} (${facts}); the updater printed no update.report.v1 line, so the daemons are best-effort with no evidence either way`,
39603
+ LogFileType.UPDATE_SERVICE
39604
+ );
39605
+ return;
39606
+ }
39607
+ logger.info(
39608
+ `Rollover evidence: serve ${verdict} (${facts}); the updater's report says ${daemons.moved ? "every supervised daemon is refreshed or already current" : `these daemons did not refresh: ${daemons.failed.join(", ")}`}`,
39609
+ LogFileType.UPDATE_SERVICE
39610
+ );
39611
+ if (!daemons.moved) {
39612
+ logger.warn(
39613
+ `Daemon refresh incomplete after the update: ${daemons.failed.join(", ")} (named by the update.report.v1 line; serve's own move is a separate reading)`,
39614
+ LogFileType.UPDATE_SERVICE
39615
+ );
39616
+ }
39617
+ }
38880
39618
  /**
38881
39619
  * The build the daemon serving this app BOOTED with, or null when its record
38882
39620
  * carries no reading.
@@ -38898,7 +39636,7 @@ ${heal.removed.join("\n")}`,
38898
39636
  * the same pair rather than about two different questions.
38899
39637
  */
38900
39638
  servingBootVersion() {
38901
- return this.backendService?.servingInstall().readings.bootVersion ?? null;
39639
+ return this.servingRecordReadings().bootVersion;
38902
39640
  }
38903
39641
  /**
38904
39642
  * The running reading an update event carries to the renderer.
@@ -39269,6 +40007,7 @@ ${heal.removed.join("\n")}`,
39269
40007
  sourceRebuild: rebuildPath
39270
40008
  });
39271
40009
  const rebuildRoute = freshPlan.managedRoute === "source-build" && rebuildPath !== null;
40010
+ const generationInstall = freshPlan.managedRoute === "entry-point";
39272
40011
  if (!consolePath && !rebuildRoute) {
39273
40012
  this.sendToRenderer("backend-update-error", {
39274
40013
  message: "The global install could not be found on this machine, so the update did not run. See the update service log.",
@@ -39281,6 +40020,8 @@ ${heal.removed.join("\n")}`,
39281
40020
  const before = this.readInstallVersionAt(installPath) ?? plan.installedInstallVersion;
39282
40021
  const markerBefore = rebuildRoute ? this.readRebuildMarkerState(installPath) : null;
39283
40022
  const fleetBeforeInstall = rebuildRoute ? await this.readFleetSnapshot(backend) : null;
40023
+ const serveInstanceBefore = generationInstall ? this.servingRecordReadings().instanceId : null;
40024
+ const fleetBeforeUpdate = generationInstall ? await this.readFleetSnapshot(backend) : null;
39284
40025
  if (rebuildRoute && !await this.drainFleetForUpdate({
39285
40026
  backend,
39286
40027
  command: freshPlan.updateCommand ?? null
@@ -39379,9 +40120,63 @@ ${heal.removed.join("\n")}`,
39379
40120
  `Global install updated: ${before ?? "unknown"} -> ${after ?? "unknown"}`,
39380
40121
  LogFileType.UPDATE_SERVICE
39381
40122
  );
39382
- const generationInstall = freshPlan.managedRoute === "entry-point";
39383
- const installAlreadyCurrent = !rebuildRoute && before !== null && after !== null && before === after;
39384
- if (backend.isUsingExternalBackend() || generationInstall && !installAlreadyCurrent) {
40123
+ const updateReport = generationInstall ? parseUpdateReport(run.stdout) : null;
40124
+ let serveRecordAfter = { bootVersion: null, instanceId: null };
40125
+ let serveHealthOk = null;
40126
+ let serveMovedWithoutRestart = false;
40127
+ if (generationInstall) {
40128
+ serveRecordAfter = this.servingRecordReadings();
40129
+ serveHealthOk = await this.checkBackendHealth();
40130
+ serveMovedWithoutRestart = serveMovedOntoBuild({
40131
+ instanceBefore: serveInstanceBefore,
40132
+ recordAfter: serveRecordAfter,
40133
+ installVersion: after,
40134
+ target: target2,
40135
+ reportServe: updateReport?.serve[0] ?? null,
40136
+ healthOk: serveHealthOk
40137
+ });
40138
+ this.logRolloverEvidence({
40139
+ report: updateReport,
40140
+ moved: serveMovedWithoutRestart,
40141
+ instanceBefore: serveInstanceBefore,
40142
+ recordAfter: serveRecordAfter,
40143
+ healthOk: serveHealthOk
40144
+ });
40145
+ }
40146
+ if (serveMovedWithoutRestart) {
40147
+ const census = await this.readStragglerCensus({
40148
+ backend,
40149
+ current: serveRecordAfter.bootVersion ?? after ?? target2,
40150
+ before: fleetBeforeUpdate
40151
+ });
40152
+ logger.info(
40153
+ `The server serving this app moved onto ${serveRecordAfter.bootVersion ?? "the new build"} without a restart (instance changed, /health answered, ${census.count ?? "no"} straggler(s) counted from ${census.source ?? "no"} reading); completing without bouncing it`,
40154
+ LogFileType.UPDATE_SERVICE
40155
+ );
40156
+ this.sendToRenderer("backend-update-completed", {
40157
+ installVersion: after,
40158
+ /*
40159
+ * THE RECORD's reading, not `/health`'s (round 2, T1): the panel
40160
+ * compares these two strings, and only the process's own record
40161
+ * names the build it actually loaded. The moved verdict requires
40162
+ * a boot reading, so this arm cannot send a null it proved.
40163
+ */
40164
+ runningVersion: serveRecordAfter.bootVersion,
40165
+ moved: true,
40166
+ restarted: false,
40167
+ restartable: this.backendIsAppOwned(),
40168
+ /*
40169
+ * FALSE BY CONSTRUCTION, and stated rather than left absent: this arm is
40170
+ * the global install's own updater, so the environment it moved is never
40171
+ * the app's managed one (design D5).
40172
+ */
40173
+ appOwnedEnvironment: false,
40174
+ sessionsOnOldBuild: census.count,
40175
+ sessionsOnOldBuildSource: census.source
40176
+ });
40177
+ return true;
40178
+ }
40179
+ if (backend.isUsingExternalBackend()) {
39385
40180
  const health = await this.getInstalledBackendVersion();
39386
40181
  const running2 = this.eventRunningVersion(health);
39387
40182
  logger.info(
@@ -39392,6 +40187,7 @@ ${heal.removed.join("\n")}`,
39392
40187
  installVersion: after,
39393
40188
  runningVersion: running2,
39394
40189
  restarted: false,
40190
+ moved: false,
39395
40191
  restartable: this.backendIsAppOwned(),
39396
40192
  /*
39397
40193
  * FALSE BY CONSTRUCTION, and stated rather than left absent: this arm is
@@ -39402,13 +40198,21 @@ ${heal.removed.join("\n")}`,
39402
40198
  });
39403
40199
  return true;
39404
40200
  }
40201
+ if (generationInstall) {
40202
+ logger.info(
40203
+ `Install moved to ${after ?? "unknown"}; the serving process's own record does not prove it moved onto the new build (instance ${serveInstanceBefore ?? "no reading"} -> ${serveRecordAfter.instanceId ?? "no reading"}, boot ${serveRecordAfter.bootVersion ?? "no reading"}, /health ${serveHealthOk ? "answered" : "did not answer"}); restarting the daemon this app owns onto it`,
40204
+ LogFileType.UPDATE_SERVICE
40205
+ );
40206
+ }
39405
40207
  logger.info(
39406
40208
  "Restarting backend service onto the updated install...",
39407
40209
  LogFileType.UPDATE_SERVICE
39408
40210
  );
39409
40211
  this.sendToRenderer("backend-update-progress", { phase: "restarting" });
39410
40212
  const fleetBeforeRestart = await this.readFleetSnapshot(backend);
39411
- const restartSuccess = await backend.restart();
40213
+ const restartSuccess = await backend.restart({
40214
+ stopGraceMs: UPDATE_RESTART_STOP_GRACE_MS
40215
+ });
39412
40216
  if (!restartSuccess) {
39413
40217
  logger.error(
39414
40218
  "Backend service restart failed after the global install update",
@@ -39465,12 +40269,43 @@ ${heal.removed.join("\n")}`,
39465
40269
  fleetBeforeInstall,
39466
40270
  fleetBeforeRestart
39467
40271
  );
39468
- const reengage = reengageSnapshot !== null ? await this.reengageFleetAfterRestart(backend, reengageSnapshot) : null;
40272
+ let sessionsOnOldBuild;
40273
+ let sessionsOnOldBuildSource;
40274
+ if (rebuildRoute) {
40275
+ const reengage = reengageSnapshot !== null ? await this.reengageFleetAfterRestart(backend, reengageSnapshot) : null;
40276
+ const census = await this.readStragglerCensus({
40277
+ backend,
40278
+ current: after ?? target2,
40279
+ resident: reengage !== null ? reengage.stillResident : null
40280
+ });
40281
+ sessionsOnOldBuild = census.count;
40282
+ sessionsOnOldBuildSource = census.source;
40283
+ } else {
40284
+ const resident = await this.engageDisplacedAfterFallbackRestart(
40285
+ backend,
40286
+ reengageSnapshot
40287
+ );
40288
+ const census = await this.readStragglerCensus({
40289
+ backend,
40290
+ current: reported ?? after ?? target2,
40291
+ resident
40292
+ });
40293
+ sessionsOnOldBuild = census.count;
40294
+ sessionsOnOldBuildSource = census.source;
40295
+ }
39469
40296
  this.sendToRenderer("backend-update-completed", {
39470
40297
  installVersion: after,
39471
40298
  runningVersion: reported,
39472
40299
  restarted: true,
39473
- sessionsOnOldBuild: reengage?.stillResident.length ?? null
40300
+ /*
40301
+ * The app moved this daemon onto the new build with the restart, and the
40302
+ * distinction is stated where it was proven (`reported` was held
40303
+ * at-or-past the target above). Absent on the rebuild route, which keeps
40304
+ * its version and its own sentence.
40305
+ */
40306
+ ...rebuildRoute ? {} : { moved: true },
40307
+ sessionsOnOldBuild,
40308
+ sessionsOnOldBuildSource
39474
40309
  });
39475
40310
  return true;
39476
40311
  }
@@ -40655,6 +41490,7 @@ function armHeadlessExitDeadline(why) {
40655
41490
  const line = `${why}; the quit did not finish within ${LAUNCHER_EXIT_DEADLINE_MS} ms, exiting`;
40656
41491
  logger.error(`[window-mode] ${line}`, LogFileType.BACKEND);
40657
41492
  console.log(`[window-mode] ${line}`);
41493
+ scheduleReopenAtQuitTerminal();
40658
41494
  electron.app.exit(0);
40659
41495
  }, LAUNCHER_EXIT_DEADLINE_MS);
40660
41496
  deadline2.unref();
@@ -41110,10 +41946,17 @@ const userShellPath = createUserShellPath({
41110
41946
  });
41111
41947
  const backendService = new BackendServiceManager({ userShellPath });
41112
41948
  const quitState = createQuitState();
41949
+ const relaunchPending = createRelaunchPending();
41113
41950
  const backendInstaller = new BackendInstaller({
41114
41951
  // The installer's one route back from a cancelled quit: the declined dialog
41115
- // answer (`./quit-state` owns why there is exactly one such site).
41116
- onQuitCancelled: () => quitState.cancel()
41952
+ // answer (`./quit-state` owns why there is exactly one such site). The
41953
+ // refused reopen's record is let go BESIDE that release: a cancelled quit
41954
+ // must not leak a spawn into some later, unrelated exit
41955
+ // (`./relaunch-pending` owns why scheduling happens only at the terminal).
41956
+ onQuitCancelled: () => {
41957
+ quitState.cancel();
41958
+ relaunchPending.clear();
41959
+ }
41117
41960
  });
41118
41961
  const reportBackendFailure = (message, fileType) => {
41119
41962
  if (backendService.isShuttingDown()) {
@@ -41338,6 +42181,20 @@ if (!gotTheLock) {
41338
42181
  * `window-raise.ts` owns the refusal and its line).
41339
42182
  */
41340
42183
  quitting: quitState.isQuitting(),
42184
+ /*
42185
+ * #755: A REFUSED SECOND LAUNCH LEAVES A RECORD. The request that would have been
42186
+ * refused whole is one the user made, so beside the refusal it is recorded and
42187
+ * the quit's terminal completes it with one successor instance
42188
+ * (`./relaunch-pending` owns the shape and the exactly-once rule). `commandLine`
42189
+ * is THIS request's own, so what the successor replays cannot drift from what
42190
+ * was refused, and the recorder's answer is what makes the refusal line say
42191
+ * `reopen=deferred` (`window-raise.ts` owns the token).
42192
+ */
42193
+ onRefusedWhileQuitting: (request) => relaunchPending.record({
42194
+ kind: "second-instance",
42195
+ show: request.show,
42196
+ argv: commandLine
42197
+ }),
41341
42198
  openConversation: openConversationInWindow ? (session, request) => openConversationInWindow?.(
41342
42199
  session,
41343
42200
  secondInstanceRequest(request)
@@ -42317,9 +43174,15 @@ electron.app.whenReady().then(async () => {
42317
43174
  reassertDockParticipation();
42318
43175
  if (electron.BrowserWindow.getAllWindows().length === 0) {
42319
43176
  if (quitState.isQuitting()) {
43177
+ const deferredReopen = relaunchPending.record({
43178
+ kind: "activate",
43179
+ show: OPERATOR_SHOW,
43180
+ argv: null
43181
+ });
42320
43182
  reportSkippedWhileQuitting(
42321
43183
  { trigger: "activate", report: reportRaise },
42322
- OPERATOR_SHOW
43184
+ OPERATOR_SHOW,
43185
+ deferredReopen
42323
43186
  );
42324
43187
  return;
42325
43188
  }
@@ -42383,9 +43246,40 @@ electron.app.on("window-all-closed", () => {
42383
43246
  const QUIT_FAILSAFE_MARGIN_MS = 5e3;
42384
43247
  const QUIT_CLEANUP_FAILSAFE_MS = LAUNCHER_PROBE_WORST_MS + INTERPRETER_RESOLUTION_WORST_MS + OWNED_STOP_WORST_MS + READINESS_POLL_INTERVAL_MS + QUIT_FAILSAFE_MARGIN_MS;
42385
43248
  let backendQuitPending = false;
43249
+ let relaunchStandDownLogged = false;
43250
+ const scheduleReopenAtQuitTerminal = () => {
43251
+ if (activeUpdateService?.inFlightInstallOwnsRelaunch() === true) {
43252
+ if (relaunchPending.hasPending() && !relaunchStandDownLogged) {
43253
+ relaunchStandDownLogged = true;
43254
+ logger.info(
43255
+ "[relaunch] not scheduling a successor: an in-flight update install owns this quit's relaunch",
43256
+ LogFileType.BACKEND
43257
+ );
43258
+ }
43259
+ return;
43260
+ }
43261
+ relaunchPending.scheduleOnExit({
43262
+ relaunch: (args) => {
43263
+ try {
43264
+ electron.app.relaunch({ args: [...args] });
43265
+ logger.info(
43266
+ `[relaunch] successor scheduled to complete the deferred reopen: ${args.length === 0 ? "(plain reopen)" : args.join(" ")}`,
43267
+ LogFileType.BACKEND
43268
+ );
43269
+ } catch (error) {
43270
+ logger.error(
43271
+ "[relaunch] scheduling the successor failed; the quit continues",
43272
+ LogFileType.BACKEND,
43273
+ error
43274
+ );
43275
+ }
43276
+ }
43277
+ });
43278
+ };
42386
43279
  electron.app.on("will-quit", (event) => {
42387
43280
  viewerEndpoint?.close();
42388
43281
  viewerRecord?.stop();
43282
+ scheduleReopenAtQuitTerminal();
42389
43283
  if (backendService.isOwnedCleanupComplete()) return;
42390
43284
  event.preventDefault();
42391
43285
  if (backendQuitPending) return;
@@ -42395,11 +43289,13 @@ electron.app.on("will-quit", (event) => {
42395
43289
  `Owned backend cleanup did not finish within ${QUIT_CLEANUP_FAILSAFE_MS} ms; exiting with failure`,
42396
43290
  LogFileType.BACKEND
42397
43291
  );
43292
+ scheduleReopenAtQuitTerminal();
42398
43293
  electron.app.exit(1);
42399
43294
  }, QUIT_CLEANUP_FAILSAFE_MS);
42400
43295
  failsafe.unref();
42401
43296
  void backendService.stop(false).then(() => {
42402
43297
  clearTimeout(failsafe);
43298
+ scheduleReopenAtQuitTerminal();
42403
43299
  electron.app.exit(0);
42404
43300
  }).catch((error) => {
42405
43301
  clearTimeout(failsafe);
@@ -42408,6 +43304,7 @@ electron.app.on("will-quit", (event) => {
42408
43304
  LogFileType.BACKEND,
42409
43305
  error
42410
43306
  );
43307
+ scheduleReopenAtQuitTerminal();
42411
43308
  electron.app.exit(1);
42412
43309
  });
42413
43310
  });
@@ -42469,5 +43366,8 @@ process.on("uncaughtException", (error) => {
42469
43366
  LogFileType.BACKEND,
42470
43367
  stopError
42471
43368
  );
42472
- }).finally(() => process.exit(1));
43369
+ }).finally(() => {
43370
+ scheduleReopenAtQuitTerminal();
43371
+ process.exit(1);
43372
+ });
42473
43373
  });