local-operator-ui 0.34.32 → 0.34.33

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 (69) hide show
  1. package/out/main/index.js +838 -499
  2. package/out/preload/index.js +4 -0
  3. package/out/renderer/assets/{_basePickBy-B66HBZHq.js → _basePickBy-CNMIid3L.js} +1 -1
  4. package/out/renderer/assets/{_baseUniq-CSRHNS5X.js → _baseUniq-BesnMR63.js} +1 -1
  5. package/out/renderer/assets/{agent-details-page--_gYi2lc.js → agent-details-page-B1wPZHYw.js} +1 -1
  6. package/out/renderer/assets/{agent-hub-page-B4CYHqQ7.js → agent-hub-page-zLDCcJ0-.js} +1 -1
  7. package/out/renderer/assets/{agents-page-Cjo3iaWD.js → agents-page-8cT_dI_3.js} +3 -3
  8. package/out/renderer/assets/{arc-4AcyjvSp.js → arc-MuuzF-kz.js} +1 -1
  9. package/out/renderer/assets/{architectureDiagram-IEHRJDOE-BE7p-gy-.js → architectureDiagram-IEHRJDOE-Cla6EwFi.js} +1 -1
  10. package/out/renderer/assets/{blockDiagram-JOT3LUYC-DgS-ikSj.js → blockDiagram-JOT3LUYC-Bja9j7dE.js} +1 -1
  11. package/out/renderer/assets/{browser-page-ql5R-cDs.js → browser-page-BYjoph3Z.js} +1 -1
  12. package/out/renderer/assets/{browser-webauthn-prompt-CWr4qL8A.js → browser-webauthn-prompt-B3uYCxFo.js} +1 -1
  13. package/out/renderer/assets/{c4Diagram-VJAJSXHY-CpMtpl2f.js → c4Diagram-VJAJSXHY-DVoi6Fpy.js} +1 -1
  14. package/out/renderer/assets/channel-BQiffY-M.js +1 -0
  15. package/out/renderer/assets/{chunk-4BMEZGHF-85Yusluf.js → chunk-4BMEZGHF-Cih6BNjs.js} +1 -1
  16. package/out/renderer/assets/{chunk-A2AXSNBT-D6BmMNue.js → chunk-A2AXSNBT-DJo0efSa.js} +1 -1
  17. package/out/renderer/assets/{chunk-AEK57VVT-C26q1R--.js → chunk-AEK57VVT-DvwXfXNo.js} +1 -1
  18. package/out/renderer/assets/{chunk-D6G4REZN-Dy4HYhmA.js → chunk-D6G4REZN-HuVYXeWr.js} +1 -1
  19. package/out/renderer/assets/{chunk-RZ5BOZE2-Dnxr3jB8.js → chunk-RZ5BOZE2-BPLh7jSP.js} +1 -1
  20. package/out/renderer/assets/{chunk-XZIHB7SX-BGzUtiqV.js → chunk-XZIHB7SX-Di-TZ5J_.js} +1 -1
  21. package/out/renderer/assets/classDiagram-GIVACNV2-DDObgTCR.js +1 -0
  22. package/out/renderer/assets/classDiagram-v2-COTLJTTW-DDObgTCR.js +1 -0
  23. package/out/renderer/assets/clone-C0j0lwrL.js +1 -0
  24. package/out/renderer/assets/{conversation-input-sync-CBw8uiPT.js → conversation-input-sync-Dvqtl3Fr.js} +247 -243
  25. package/out/renderer/assets/{dagre-OKDRZEBW-D6CK2WVv.js → dagre-OKDRZEBW-BRG1M9oi.js} +1 -1
  26. package/out/renderer/assets/{diagram-SSKATNLV-C3TpIKTy.js → diagram-SSKATNLV-DsHLzFSC.js} +1 -1
  27. package/out/renderer/assets/{diagram-VNBRO52H-z0vxgigp.js → diagram-VNBRO52H-DuDF21bn.js} +1 -1
  28. package/out/renderer/assets/{erDiagram-Q7BY3M3F-DBCh3SrN.js → erDiagram-Q7BY3M3F-BS7Xa-1R.js} +1 -1
  29. package/out/renderer/assets/{flowDiagram-4HSFHLVR-BgKCzSfN.js → flowDiagram-4HSFHLVR-A_BvmOjq.js} +1 -1
  30. package/out/renderer/assets/{ganttDiagram-APWFNJXF-BwsHywQN.js → ganttDiagram-APWFNJXF-BQX036BE.js} +1 -1
  31. package/out/renderer/assets/{gitGraphDiagram-7IBYFJ6S-DYgILe4-.js → gitGraphDiagram-7IBYFJ6S-YpfcZ3NA.js} +1 -1
  32. package/out/renderer/assets/{graph-Vw1kbjlt.js → graph-lKMrX7qR.js} +1 -1
  33. package/out/renderer/assets/{index-DYrqbn3V.js → index-BEYFd0J9.js} +90 -90
  34. package/out/renderer/assets/{index-DBZqVg5O.js → index-CYvymE2l.js} +1 -1
  35. package/out/renderer/assets/{index-CBEysjAK.js → index-DGSndO-f.js} +1 -1
  36. package/out/renderer/assets/{infoDiagram-PH2N3AL5-Cmpi3PDq.js → infoDiagram-PH2N3AL5-Dg-ZnTmu.js} +1 -1
  37. package/out/renderer/assets/{journeyDiagram-U35MCT3I-BVMD25G1.js → journeyDiagram-U35MCT3I-DRzUPcQU.js} +1 -1
  38. package/out/renderer/assets/{kanban-definition-NDS4AKOZ-CVijSM-H.js → kanban-definition-NDS4AKOZ-Q474R71W.js} +1 -1
  39. package/out/renderer/assets/{layout-yGyBho08.js → layout-DTC_hgF6.js} +1 -1
  40. package/out/renderer/assets/{legacy-agents-page-C9F6Anar.js → legacy-agents-page-k28FA6cr.js} +1 -1
  41. package/out/renderer/assets/{mermaid.core-eKt0AAEa.js → mermaid.core-C9rYU-0l.js} +5 -5
  42. package/out/renderer/assets/{mesh-page-V7TXOqL8.js → mesh-page-DfZZjDNB.js} +1 -1
  43. package/out/renderer/assets/{mindmap-definition-ALO5MXBD-7OE97AHg.js → mindmap-definition-ALO5MXBD-Bib16-Jn.js} +1 -1
  44. package/out/renderer/assets/{mini-CAwodgo3.js → mini-BJOZ3ClI.js} +1 -1
  45. package/out/renderer/assets/{org-surface-gate-BnAoMqVG.js → org-surface-gate-C3vaLLOm.js} +1 -1
  46. package/out/renderer/assets/{pieDiagram-IB7DONF6-BXLSPmEJ.js → pieDiagram-IB7DONF6-CFKHjx11.js} +1 -1
  47. package/out/renderer/assets/{projects-page-BHtKptz-.js → projects-page-BCQMniR-.js} +1 -1
  48. package/out/renderer/assets/{quadrantDiagram-7GDLP6J5-Csn6f8f3.js → quadrantDiagram-7GDLP6J5-B-9tF5TA.js} +1 -1
  49. package/out/renderer/assets/{radar-MK3ICKWK-BZZUrPnK.js → radar-MK3ICKWK-hPJOSxyO.js} +1 -1
  50. package/out/renderer/assets/{radient-auth-buttons-B0TYTsdi.js → radient-auth-buttons-CGDfKjvU.js} +1 -1
  51. package/out/renderer/assets/{requirementDiagram-KVF5MWMF-CidyP0gb.js → requirementDiagram-KVF5MWMF-DapE7E6j.js} +1 -1
  52. package/out/renderer/assets/{sankeyDiagram-QLVOVGJD-RqzFypY6.js → sankeyDiagram-QLVOVGJD-dQnFf6xh.js} +1 -1
  53. package/out/renderer/assets/{schedules-page-gb6zHM13.js → schedules-page-CTF8k7kQ.js} +1 -1
  54. package/out/renderer/assets/{sequenceDiagram-X6HHIX6F-hoGno_Xs.js → sequenceDiagram-X6HHIX6F-DpQcD4s7.js} +1 -1
  55. package/out/renderer/assets/{settings-page-D7QXHWhp.js → settings-page-Cjj6lVqM.js} +1 -1
  56. package/out/renderer/assets/{stateDiagram-DGXRK772-CGfBWbub.js → stateDiagram-DGXRK772-BH86RKPp.js} +1 -1
  57. package/out/renderer/assets/{stateDiagram-v2-YXO3MK2T-BuXMQAvq.js → stateDiagram-v2-YXO3MK2T-CPbeGHOQ.js} +1 -1
  58. package/out/renderer/assets/{timeline-definition-BDJGKUSR-CS5EDd47.js → timeline-definition-BDJGKUSR-I9WIfb3F.js} +1 -1
  59. package/out/renderer/assets/{use-download-agent-mutation-DSU0LPqD.js → use-download-agent-mutation-DZKciluh.js} +1 -1
  60. package/out/renderer/assets/{use-memberships-query-CN1cjAr2.js → use-memberships-query-CouwB8Jj.js} +1 -1
  61. package/out/renderer/assets/{use-session-snapshot-Da-uFU-c.js → use-session-snapshot-BwZz7qKM.js} +1 -1
  62. package/out/renderer/assets/{xychartDiagram-VJFVF3MP-D44Ss3rC.js → xychartDiagram-VJFVF3MP-RQXd6xeu.js} +1 -1
  63. package/out/renderer/index.html +2 -2
  64. package/out/renderer/mini.html +3 -3
  65. package/package.json +2 -2
  66. package/out/renderer/assets/channel-BdIPJXOo.js +0 -1
  67. package/out/renderer/assets/classDiagram-GIVACNV2-DiGQhWtx.js +0 -1
  68. package/out/renderer/assets/classDiagram-v2-COTLJTTW-DiGQhWtx.js +0 -1
  69. package/out/renderer/assets/clone-BhPlGAQ6.js +0 -1
package/out/main/index.js CHANGED
@@ -4618,6 +4618,55 @@ const serverChannelVerdict = ({
4618
4618
  return { status: "restart-required", state: "restart-required" };
4619
4619
  return { status: "current", state: "current" };
4620
4620
  };
4621
+ const SHA256_HEX = /^[0-9a-f]{64}$/;
4622
+ const isInstallerWheel = (entry, version, platform) => {
4623
+ if (!entry || typeof entry !== "object") return false;
4624
+ const file = entry;
4625
+ if (file.packagetype !== "bdist_wheel" || file.yanked) return false;
4626
+ if (typeof file.url !== "string") return false;
4627
+ let url;
4628
+ try {
4629
+ url = new URL(file.url);
4630
+ } catch {
4631
+ return false;
4632
+ }
4633
+ if (url.protocol !== "https:" || url.hostname !== "files.pythonhosted.org")
4634
+ return false;
4635
+ const digests = file.digests;
4636
+ const sha256 = digests && typeof digests === "object" ? digests.sha256 : null;
4637
+ if (typeof sha256 !== "string" || !SHA256_HEX.test(sha256)) return false;
4638
+ if (typeof file.filename !== "string") return false;
4639
+ if (!file.filename.startsWith(`local_operator-${version}-`)) return false;
4640
+ return platform === "darwin" ? file.filename.endsWith("-py3-none-macosx_11_0_universal2.whl") : file.filename.endsWith("-py3-none-any.whl");
4641
+ };
4642
+ const pypiReleaseReading = (payload, platform) => {
4643
+ const unreadable = {
4644
+ namedVersion: null,
4645
+ readyVersion: null
4646
+ };
4647
+ if (!payload || typeof payload !== "object") return unreadable;
4648
+ const info = payload.info;
4649
+ if (!info || typeof info !== "object") return unreadable;
4650
+ const version = info.version;
4651
+ if (typeof version !== "string" || !version.trim()) return unreadable;
4652
+ const trimmed = version.trim();
4653
+ const downloadable = (files) => Array.isArray(files) && files.some((entry) => isInstallerWheel(entry, trimmed, platform));
4654
+ let ready = downloadable(payload.urls);
4655
+ if (!ready) {
4656
+ const releases = payload.releases;
4657
+ if (releases && typeof releases === "object") {
4658
+ ready = downloadable(releases[trimmed]);
4659
+ }
4660
+ }
4661
+ return { namedVersion: trimmed, readyVersion: ready ? trimmed : null };
4662
+ };
4663
+ const readPypiReleaseBody = (body, platform) => {
4664
+ try {
4665
+ return pypiReleaseReading(JSON.parse(body), platform);
4666
+ } catch {
4667
+ return { namedVersion: null, readyVersion: null };
4668
+ }
4669
+ };
4621
4670
  const updateCheckVerdict = (channels) => ({
4622
4671
  app: channels.app,
4623
4672
  server: channels.server,
@@ -14800,12 +14849,14 @@ class BrowserStateWriter {
14800
14849
  this.onError = options.onError ?? (() => {
14801
14850
  });
14802
14851
  this.appVersion = options.appVersion;
14852
+ this.instanceId = options.instanceId;
14803
14853
  }
14804
14854
  now;
14805
14855
  onError;
14806
14856
  timer = null;
14807
14857
  written = false;
14808
14858
  appVersion;
14859
+ instanceId;
14809
14860
  /** Publish the record and start the heartbeat. The heartbeat is the
14810
14861
  * difference between "the app is running and can open a tab on demand" and a
14811
14862
  * record that names a process that died without cleaning up. */
@@ -14837,6 +14888,7 @@ class BrowserStateWriter {
14837
14888
  proto: PROTO_VERSION,
14838
14889
  host: "ui",
14839
14890
  app_version: this.appVersion,
14891
+ ...this.instanceId && { instance_id: this.instanceId },
14840
14892
  profile_dir: facts.profileDir,
14841
14893
  // Advertised from the ONE list the `/health` route also reads (§6.3), so the
14842
14894
  // record a session reads and the probe Python acquits a stale record with
@@ -24117,6 +24169,57 @@ function restrictMode(path2, log2) {
24117
24169
  );
24118
24170
  }
24119
24171
  }
24172
+ class StartupRollback {
24173
+ owners = [];
24174
+ add(cleanup) {
24175
+ this.owners.push(cleanup);
24176
+ }
24177
+ commit() {
24178
+ this.owners = [];
24179
+ }
24180
+ async run(log2) {
24181
+ for (const cleanup of this.owners.splice(0).reverse()) {
24182
+ try {
24183
+ await cleanup();
24184
+ } catch (error) {
24185
+ log2(`[browser] startup cleanup failed: ${String(error)}`);
24186
+ }
24187
+ }
24188
+ }
24189
+ }
24190
+ const MAX_START_ATTEMPTS = 3;
24191
+ async function startWithRetries(options) {
24192
+ let failure = new Error("The browser window closed before startup.");
24193
+ for (let attempt = 1; attempt <= MAX_START_ATTEMPTS; attempt += 1) {
24194
+ if (options.cancelled()) break;
24195
+ options.publish({
24196
+ status: attempt === 1 ? "starting" : "retrying",
24197
+ attempt,
24198
+ maxAttempts: MAX_START_ATTEMPTS,
24199
+ message: null,
24200
+ canRetry: false
24201
+ });
24202
+ try {
24203
+ return await options.start();
24204
+ } catch (error) {
24205
+ failure = error;
24206
+ options.log(
24207
+ `[browser] startup attempt ${attempt}/${MAX_START_ATTEMPTS} failed: ${String(error)}`
24208
+ );
24209
+ }
24210
+ }
24211
+ options.publish({
24212
+ status: "unavailable",
24213
+ attempt: MAX_START_ATTEMPTS,
24214
+ maxAttempts: MAX_START_ATTEMPTS,
24215
+ message: "The browser could not start. Your saved tabs and site approvals are kept; sessions you were signed into may need signing in again.",
24216
+ // The window is still open and no host is running once the sequence
24217
+ // exhausts, so the renderer may offer a retry. `startBrowserHost` is
24218
+ // idempotent on re-registration, which is what makes the control safe.
24219
+ canRetry: true
24220
+ });
24221
+ throw failure;
24222
+ }
24120
24223
  const BROWSER_IPC_CHANNELS = [
24121
24224
  "browser-state",
24122
24225
  "browser-new-tab",
@@ -24138,19 +24241,53 @@ const BROWSER_IPC_CHANNELS = [
24138
24241
  "browser-clear-data",
24139
24242
  "browser-webauthn-respond",
24140
24243
  "browser-webauthn-pending",
24141
- "browser-reveal-downloads"
24244
+ "browser-reveal-downloads",
24245
+ /** The user's own recovery click on the `unavailable` state (round 1, D1a).
24246
+ * Named here so the namespace stays the one place that lists its channels. */
24247
+ "browser-retry"
24142
24248
  ];
24143
24249
  function registerBrowserIpc(options) {
24144
- function authorize(event) {
24250
+ function authorizeSender(event) {
24145
24251
  const owner = options.window();
24146
24252
  if (!owner || owner.isDestroyed() || event.sender !== owner.webContents || event.senderFrame !== owner.webContents.mainFrame || !trustedDesktopFrame(event.senderFrame.url, options.expectedUrl)) {
24147
24253
  throw new Error("This window cannot use the browser controls.");
24148
24254
  }
24255
+ }
24256
+ function authorize(event) {
24257
+ authorizeSender(event);
24149
24258
  const host = options.host();
24150
- if (!host) throw new Error("The browser host is not running.");
24259
+ if (!host)
24260
+ throw new Error("browser_unavailable: The browser is not ready.");
24151
24261
  return host;
24152
24262
  }
24153
- electron.ipcMain.handle("browser-state", (event) => authorize(event).chromeState());
24263
+ electron.ipcMain.handle("browser-state", (event) => {
24264
+ authorizeSender(event);
24265
+ const host = options.host();
24266
+ const availability = options.availability?.();
24267
+ if (host)
24268
+ return { ...host.chromeState(), ...availability && { availability } };
24269
+ return {
24270
+ tabs: [],
24271
+ activeTabId: null,
24272
+ url: "",
24273
+ title: "",
24274
+ loading: false,
24275
+ canGoBack: false,
24276
+ canGoForward: false,
24277
+ pendingConsent: [],
24278
+ approvals: [],
24279
+ navFailure: null,
24280
+ availability: availability ?? {
24281
+ status: "unavailable",
24282
+ attempt: 0,
24283
+ maxAttempts: MAX_START_ATTEMPTS,
24284
+ message: "The browser is not running.",
24285
+ // No host invokes this fallback (it is the pre-start projection), and
24286
+ // there is nothing to retry before a start was asked for.
24287
+ canRetry: false
24288
+ }
24289
+ };
24290
+ });
24154
24291
  electron.ipcMain.handle("browser-new-tab", async (event, sessionId2) => {
24155
24292
  const host = authorize(event);
24156
24293
  return host.newTab(optionalConversationOrThrow(sessionId2));
@@ -24276,6 +24413,13 @@ function registerBrowserIpc(options) {
24276
24413
  );
24277
24414
  return { opened: message === "", message };
24278
24415
  });
24416
+ electron.ipcMain.handle("browser-retry", async (event) => {
24417
+ authorizeSender(event);
24418
+ if (!options.retry)
24419
+ throw new Error("browser_unavailable: Retry is not available.");
24420
+ await options.retry();
24421
+ return { retried: true };
24422
+ });
24279
24423
  }
24280
24424
  function unregisterBrowserIpc() {
24281
24425
  for (const channel of BROWSER_IPC_CHANNELS) electron.ipcMain.removeHandler(channel);
@@ -24698,7 +24842,7 @@ function installBrowserSessionHandlers(browserSession, hooks = {}) {
24698
24842
  return false;
24699
24843
  }
24700
24844
  );
24701
- browserSession.on("will-download", (event, item, webContents) => {
24845
+ const onDownload = (event, item, webContents) => {
24702
24846
  const webContentsId = webContents?.id ?? -1;
24703
24847
  const decision = hooks.onDownload?.(item, webContentsId) ?? {
24704
24848
  cancel: true,
@@ -24718,7 +24862,11 @@ function installBrowserSessionHandlers(browserSession, hooks = {}) {
24718
24862
  );
24719
24863
  }
24720
24864
  hooks.onDownloadDecided?.(outcome);
24721
- });
24865
+ };
24866
+ browserSession.on("will-download", onDownload);
24867
+ return () => {
24868
+ browserSession.removeListener("will-download", onDownload);
24869
+ };
24722
24870
  }
24723
24871
  async function clearBrowsingData(browserSession, what) {
24724
24872
  browserSession.flushStorageData();
@@ -24769,6 +24917,7 @@ function flushBrowserStorage(browserSession) {
24769
24917
  const RPC_PATH = "/rpc";
24770
24918
  const HEALTH_PATH = "/health";
24771
24919
  const KEY_HEADER = "x-bridge-key";
24920
+ const INSTANCE_HEADER = "x-ui-browser-instance";
24772
24921
  const MAX_BODY_BYTES = 1 << 20;
24773
24922
  const LOOPBACK_HOST = "127.0.0.1";
24774
24923
  function send(res, status2, body, contentType = "application/json") {
@@ -24895,6 +25044,7 @@ async function handle(req, res, options) {
24895
25044
  host: "ui",
24896
25045
  proto: PROTO_VERSION,
24897
25046
  pid: process.pid,
25047
+ ...options.instanceId && { instance_id: options.instanceId },
24898
25048
  // Additive: an older reader requires `host` and `pid` and ignores the
24899
25049
  // rest, and a newer one learns whether the console is up without a
24900
25050
  // second probe (design 10.1). `false` when nobody reported a
@@ -24921,6 +25071,11 @@ async function handle(req, res, options) {
24921
25071
  send(res, 401, { detail: "unauthorized" });
24922
25072
  return;
24923
25073
  }
25074
+ const expectedInstance = req.headers[INSTANCE_HEADER];
25075
+ if (expectedInstance !== void 0 && expectedInstance !== options.instanceId) {
25076
+ send(res, 409, { detail: "host instance changed" });
25077
+ return;
25078
+ }
24924
25079
  let raw;
24925
25080
  try {
24926
25081
  raw = JSON.parse(await readBody(req));
@@ -26040,13 +26195,28 @@ function refusesNavigation(rawUrl) {
26040
26195
  let activeHost = null;
26041
26196
  let stopping = null;
26042
26197
  function browserHostStopPending() {
26043
- return activeHost !== null || stopping !== null;
26198
+ return activeHost !== null || stopping !== null || starting !== null;
26044
26199
  }
26045
26200
  async function stopBrowserHost() {
26046
26201
  if (stopping) return stopping;
26202
+ if (starting) {
26203
+ cancelStartup = true;
26204
+ stopping = starting.catch(() => null).then(async () => {
26205
+ const current = activeHost;
26206
+ activeHost = null;
26207
+ if (current) await current.stop();
26208
+ else unregisterBrowserIpc();
26209
+ }).finally(() => {
26210
+ stopping = null;
26211
+ });
26212
+ return stopping;
26213
+ }
26047
26214
  const handle2 = activeHost;
26048
26215
  activeHost = null;
26049
- if (!handle2) return;
26216
+ if (!handle2) {
26217
+ unregisterBrowserIpc();
26218
+ return;
26219
+ }
26050
26220
  stopping = handle2.stop().finally(() => {
26051
26221
  stopping = null;
26052
26222
  });
@@ -26056,524 +26226,650 @@ function browserHostEnabled(env = process.env) {
26056
26226
  const flag = env.LOCAL_OPERATOR_UI_BROWSER_HOST?.trim().toLowerCase();
26057
26227
  return !(flag === "0" || flag === "false" || flag === "off");
26058
26228
  }
26059
- async function startBrowserHost(options) {
26060
- const { log: log2 } = options;
26061
- const browserSession = resolveBrowserSession();
26062
- let registryForDownloads = null;
26063
- const downloads = new DownloadArmer({
26064
- tabForWebContents: (webContentsId) => registryForDownloads?.byWebContents(webContentsId)?.tabId ?? null,
26065
- // WHO OWNED THE TAB, recorded on the decision (review round 2, U10). Read from
26066
- // the registry at the moment the note is written, because the row may render it
26067
- // after the tab is closed — and "· on another tab" about a tab that no longer
26068
- // exists is a marker pointing at nothing. `null` when the lookup cannot answer
26069
- // (a tab already gone), which the row renders the old way rather than guessing.
26070
- ownerKindFor: (tabId) => registryForDownloads?.get(tabId)?.owner ?? null,
26071
- log: log2,
26072
- onActivity: () => {
26073
- if (options.window.isDestroyed()) return;
26074
- options.window.webContents.send("browser-state-changed");
26075
- }
26076
- });
26077
- installBrowserSessionHandlers(browserSession, {
26078
- onPermissionRequested: (details) => {
26079
- log2(
26080
- `[browser] denied a ${details.permission} permission request from ${details.origin || "(unknown origin)"}`
26081
- );
26082
- },
26083
- onDownload: (item, webContentsId) => downloads.decide(item, webContentsId),
26084
- onDownloadDecided: (outcome) => {
26085
- if (outcome.savePath) {
26086
- log2(
26087
- `[browser] saved a download from ${outcome.url} to ${outcome.savePath}`
26088
- );
26089
- }
26090
- },
26091
- log: log2
26092
- });
26093
- configurePslRules(PSL_RULES);
26094
- await installWebauthn({ log: log2 });
26095
- const describeSource = (frame) => {
26096
- if (!frame || typeof frame !== "object") {
26097
- return { tabId: null, pageTitle: null };
26098
- }
26099
- const contents = electron.webContents.fromFrame(frame);
26100
- const record = contents === void 0 ? void 0 : registry.byWebContentsId(contents.id);
26101
- if (!record) return { tabId: null, pageTitle: null };
26102
- const title = record.view.webContents.isDestroyed() ? "" : record.view.webContents.getTitle();
26103
- return { tabId: record.tabId, pageTitle: title || null };
26104
- };
26105
- const webauthn = new WebauthnChooser({
26106
- notify: (request) => {
26107
- if (options.window.isDestroyed()) return;
26108
- options.window.webContents.send("browser-webauthn-request", request);
26109
- },
26110
- /*
26111
- * AND THE OTHER DIRECTION: every settle is pushed, not only every arrival.
26112
- *
26113
- * Without this the renderer cannot tell a live request from one main has
26114
- * already cancelled, so a chooser whose request expired while the user was
26115
- * away stays on screen offering rows that can no longer do anything (design
26116
- * round 1, D2) — and the click is then discarded silently. The outcome travels
26117
- * with the push because the two cases differ for the user: their own answer is
26118
- * already off the screen, an expiry has to be explained.
26119
- */
26120
- onSettled: (requestId2, outcome) => {
26121
- if (options.window.isDestroyed()) return;
26122
- options.window.webContents.send("browser-webauthn-settled", {
26123
- requestId: requestId2,
26124
- outcome
26125
- });
26126
- },
26127
- describeSource,
26128
- log: log2
26129
- });
26130
- const detachWebauthn = attachWebauthnChooser(browserSession, webauthn, log2);
26131
- const cookieJar = createHiddenCookieJarTarget(
26132
- new electron.WebContentsView({
26133
- webPreferences: { ...HIDDEN_COOKIE_JAR_WEB_PREFERENCES }
26134
- })
26135
- );
26136
- const sessionCookies = new SessionCookieVault({
26137
- jar: cookieJar.jar,
26138
- cipher: browserProfileCipher(),
26139
- ...sessionCookiePaths(options.userDataDir),
26140
- flushStore: () => browserSession.cookies.flushStore(),
26141
- clearSessionData: (what) => clearBrowsingData(browserSession, what),
26142
- log: log2
26143
- });
26144
- let restoreReport = null;
26145
- try {
26146
- restoreReport = await sessionCookies.restore();
26147
- } catch (error) {
26148
- log2(`[browser] session cookies: the restore failed (${String(error)})`);
26149
- }
26150
- const cdp = new CdpPool({ log: log2 });
26151
- const views = /* @__PURE__ */ new Map();
26152
- let stateWriter = null;
26153
- const sessionStore = new BrowserSessionStore({
26154
- dir: path$1.join(options.userDataDir, "browser"),
26155
- log: log2
26156
- });
26157
- const consentNotifier = new ConsentNotifier({
26158
- show: options.windowShow,
26159
- /*
26160
- * The click's own rule lives in `consentClickHandler` (see it: the delivered
26161
- * payload, the order, and the raise are all asserted there), and this is the
26162
- * wiring that gives it the real window and the real plan. `windowShow` is the
26163
- * plan the click comes forward under — this module still never decides whether a
26164
- * window comes forward (design 11.4), it hands the plan to `window-raise.ts`.
26165
- */
26166
- onAttention: consentClickHandler({
26167
- /*
26168
- * THE WINDOW IS ASKED FOR, NOT CAPTURED, and the difference is the U2 fix:
26169
- * this host is stopped the moment its window closes (`src/main/index.ts`'s
26170
- * `closed` hook), but a banner already raised is still in Notification
26171
- * Center and its click still lands here. A captured window would be a
26172
- * destroyed one by then, and reading it throws.
26173
- */
26174
- window: () => options.window,
26175
- show: options.windowShow,
26176
- report: options.reportRaise,
26177
- reopen: options.reopenConsent
26178
- }),
26179
- log: log2
26180
- });
26181
- const captureSession = () => {
26182
- sessionStore.record(
26183
- captureTabs(
26184
- registry.list(),
26185
- registry.activeTab?.tabId ?? null,
26186
- host.failedTabIds()
26187
- )
26188
- );
26189
- };
26190
- const notifyChanged = () => {
26191
- stateWriter?.publishNow();
26192
- options.window.webContents.send("browser-state-changed");
26193
- captureSession();
26194
- };
26195
- const registry = new TabRegistry(
26196
- (_createOptions, tabId) => {
26197
- const view = buildView(options.window);
26198
- views.set(tabId, view);
26199
- wireView(view, tabId);
26200
- return view;
26201
- },
26202
- releaseTab,
26203
- notifyChanged
26204
- );
26205
- registryForDownloads = registry;
26206
- function releaseTab(tabId, webContentsId) {
26207
- const view = views.get(tabId);
26208
- views.delete(tabId);
26209
- downloads.forget(tabId);
26210
- void cdp.detach(webContentsId).finally(() => {
26211
- releaseView(options.window, view);
26229
+ const hostInstanceId = node_crypto.randomUUID();
26230
+ let starting = null;
26231
+ let startingWindow = null;
26232
+ let cancelStartup = false;
26233
+ function startBrowserHost(options) {
26234
+ if (starting) {
26235
+ if (startingWindow === options.window) return starting;
26236
+ return starting.catch(() => null).then(async () => {
26237
+ await stopBrowserHost();
26238
+ return startBrowserHost(options);
26212
26239
  });
26213
26240
  }
26214
- const ownership = new OwnershipLedger({
26215
- mayAdopt: (token, requester) => {
26216
- const record = registry.requireSurface(token);
26217
- return record.handedTo === requester.slice("session:".length) && !record.allocationId;
26218
- },
26219
- closeTab: async (token) => {
26220
- try {
26221
- const record = registry.requireSurface(token);
26222
- approvals.forgetDocument(token);
26223
- registry.destroy(record.tabId);
26224
- } catch {
26225
- }
26226
- return true;
26227
- },
26228
- isLive: (token) => {
26229
- try {
26230
- registry.requireSurface(token);
26231
- return true;
26232
- } catch {
26233
- return false;
26234
- }
26235
- }
26236
- });
26237
- const approvals = new ApprovalStore({
26238
- dir: path$1.join(options.userDataDir, "browser"),
26239
- onChanged: () => {
26240
- options.window.webContents.send("browser-consent-changed");
26241
- consentNotifier.announce(approvals.pendingEntries());
26242
- },
26243
- log: log2
26244
- });
26245
- const profileDir = browserStoragePath(browserSession) ?? "";
26246
- const facts = {
26247
- proto: 1,
26248
- appVersion: options.appVersion,
26249
- profileDir,
26250
- profilePersistent: browserSession.isPersistent(),
26251
- userAgent: browserUserAgent(),
26252
- domainScope: domainScopeAvailable()
26241
+ if (stopping) return stopping.then(() => startBrowserHost(options));
26242
+ if (activeHost) return Promise.resolve(activeHost);
26243
+ cancelStartup = false;
26244
+ startingWindow = options.window;
26245
+ let availability = {
26246
+ status: "starting",
26247
+ attempt: 1,
26248
+ maxAttempts: MAX_START_ATTEMPTS,
26249
+ message: null,
26250
+ canRetry: false
26253
26251
  };
26254
- const host = new BrowserHost({
26255
- registry,
26256
- cdp,
26257
- approvals,
26258
- ownership,
26259
- downloads,
26260
- log: log2,
26261
- onChanged: notifyChanged,
26262
- facts: () => facts,
26263
- forgetSiteData: (origin) => clearOriginData(browserSession, origin)
26264
- });
26265
- const recorded = readSession(sessionStore.filePath, log2);
26266
- host.restoreTabs(recorded);
26267
- if (recorded.length) {
26268
- log2(
26269
- `[browser] restored ${recorded.length} tab(s) from ${SESSION_FILENAME}; every restored tab is user-owned and holds no handle (design 7.3)`
26270
- );
26271
- }
26272
- const consoleStartup = await startConsoleHost({
26273
- window: options.window,
26274
- expectedUrl: options.expectedUrl,
26275
- appVersion: options.appVersion,
26276
- notifier: options.notifier,
26277
- /*
26278
- * The capture view's document and preload, forwarded rather than re-derived:
26279
- * only the app knows where its own bundle lives, and a console host that
26280
- * guessed would answer a screenshot with a blank frame rather than with its
26281
- * own typed refusal. WITHOUT THESE TWO the host builds no capture view at all,
26282
- * which the live proof rig caught as `capture_unavailable` on a surface with
26283
- * no displayed pane — the exact shape of a seam that is wired in tests and
26284
- * forgotten in the one call site that matters.
26285
- */
26286
- consoleCaptureUrl: options.consoleCaptureUrl,
26287
- preloadPath: options.preloadPath,
26288
- userShellPath: options.userShellPath,
26289
- log: log2
26290
- });
26291
- const sessionKey = mintSessionKey();
26292
- const server = await startRpcServer({
26293
- key: sessionKey,
26294
- dispatch: async (method, params, requestId2) => {
26295
- if (!isConsoleDispatchMethod(method)) {
26296
- return host.dispatch(method, params, requestId2);
26297
- }
26298
- if (!consoleStartup.ok) {
26299
- throw new ConsoleError(
26300
- "console_unavailable",
26301
- `this app's console is not available (${consoleStartup.reason}): ${consoleStartup.detail}`,
26302
- { reason: consoleStartup.reason }
26303
- );
26304
- }
26305
- return consoleStartup.dispatch(method, params);
26306
- },
26307
- log: log2,
26308
- capabilities: () => ({ console: consoleStartup.ok })
26309
- });
26310
- stateWriter = new BrowserStateWriter(
26311
- stateFilePath(),
26312
- () => {
26313
- const consoleCounts = consoleStartup.ok ? consoleStartup.counts() : { total: 0, agent: 0 };
26314
- return {
26315
- tabs: registry.count(),
26316
- agentTabs: registry.agentTabCount(),
26317
- profileDir,
26318
- console: consoleStartup.ok,
26319
- consoleSurfaces: consoleCounts.total,
26320
- consoleAgentSurfaces: consoleCounts.agent
26321
- };
26322
- },
26323
- {
26324
- appVersion: options.appVersion,
26325
- onError: (error) => log2(`[browser] state file: ${String(error)}`)
26326
- }
26327
- );
26328
- stateWriter.start(server.port, sessionKey);
26252
+ const bindings = {};
26253
+ const notify = () => {
26254
+ if (!options.window.isDestroyed())
26255
+ options.window.webContents.send("browser-state-changed");
26256
+ };
26257
+ unregisterBrowserIpc();
26329
26258
  registerBrowserIpc({
26330
26259
  window: () => options.window,
26331
26260
  expectedUrl: options.expectedUrl,
26332
- host: () => host,
26333
- webauthn: () => webauthn,
26334
- clearData: (what) => sessionCookies.clearBrowsingData(what),
26335
- // THE REVEAL TAKES NO PATH FROM THE RENDERER (§16.4): it opens the directory the
26336
- // host is actually writing into, so the one place a page-derived string could
26337
- // have become a path stays out of the IPC surface as well. `shell.openPath`
26338
- // answers "" on success and a message on failure, which is returned rather than
26339
- // thrown: a reveal that fails is a Finder problem, not a fault in the agent's
26340
- // download.
26261
+ host: () => bindings.host?.() ?? null,
26262
+ webauthn: () => bindings.webauthn?.() ?? null,
26263
+ availability: () => availability,
26264
+ clearData: async (what) => {
26265
+ if (!bindings.clearData)
26266
+ throw new Error("browser_unavailable: The browser is not ready.");
26267
+ return bindings.clearData(what);
26268
+ },
26341
26269
  revealDownloads: async () => {
26342
- const dir = downloads.downloadDir();
26343
- if (!dir) return "no download directory yet";
26344
- return electron.shell.openPath(dir);
26270
+ if (!bindings.revealDownloads)
26271
+ throw new Error("browser_unavailable: The browser is not ready.");
26272
+ return bindings.revealDownloads();
26273
+ },
26274
+ // The recovery click on the `unavailable` state (round 1, D1a). It restarts
26275
+ // THIS module's host for the same window — the same call `createWindow`
26276
+ // makes — so the retry travels the one lifecycle path rather than a second,
26277
+ // parallel start. A doubled click needs no guard of its own:
26278
+ // `startBrowserHost` is already single-flight (a second call for the same
26279
+ // window returns the in-flight `starting` promise), which QA's concurrent
26280
+ // probe confirmed — three simultaneous retries produced ONE startup
26281
+ // sequence. Adding a local guard beside that would be a second mechanism
26282
+ // for a rule the module already owns (round 2, Q-R2-2).
26283
+ retry: async () => {
26284
+ await startBrowserHost(options).catch(() => {
26285
+ });
26345
26286
  },
26346
- log: log2
26287
+ log: options.log
26347
26288
  });
26348
- log2(
26349
- `[browser] host on ${server.address}:${server.port} (proto ${facts.proto}), profile ${profileDir || "(in-memory)"}, agent tabs ${registry.agentTabCount()}/${8}, console ${consoleStartup.ok ? `on (${consoleStartup.counts().total} surface(s))` : `off (${consoleStartup.reason})`}`
26350
- );
26351
- const handle2 = {
26352
- host,
26353
- port: server.port,
26354
- profileDir,
26355
- agentTabs: () => registry.agentTabCount(),
26356
- sessionCookies: () => restoreReport,
26357
- stop: async () => {
26358
- const atStop = captureTabs(
26359
- registry.list(),
26360
- registry.activeTab?.tabId ?? null,
26361
- host.failedTabIds()
26362
- );
26363
- const decision = sessionStore.commitStopCapture(atStop);
26364
- if (!decision.write) {
26365
- log2(
26366
- `[browser] not overwriting ${SESSION_FILENAME} at stop: ${decision.reason}`
26367
- );
26368
- }
26369
- sessionStore.flush();
26370
- registry.destroyAll();
26371
- await cdp.close();
26372
- await server.close();
26373
- await consoleStartup.stop();
26374
- unregisterBrowserIpc();
26375
- detachWebauthn();
26376
- webauthn.dispose();
26377
- stateWriter?.clear();
26378
- approvals.resetPending();
26379
- ownership.clear();
26380
- flushBrowserStorage(browserSession);
26381
- try {
26382
- await sessionCookies.snapshot();
26383
- } catch (error) {
26384
- log2(
26385
- `[browser] session cookies: the snapshot failed (${String(error)})`
26386
- );
26387
- }
26388
- cookieJar.dispose();
26389
- log2("[browser] host stopped");
26289
+ starting = startWithRetries({
26290
+ cancelled: () => cancelStartup || options.window.isDestroyed(),
26291
+ publish: (next) => {
26292
+ availability = next;
26293
+ notify();
26294
+ },
26295
+ log: options.log,
26296
+ start: async () => {
26297
+ const handle2 = await initializeBrowserHost(options, bindings);
26298
+ availability = {
26299
+ ...availability,
26300
+ status: "ready",
26301
+ message: null,
26302
+ canRetry: false
26303
+ };
26304
+ notify();
26305
+ return handle2;
26390
26306
  }
26307
+ }).finally(() => {
26308
+ starting = null;
26309
+ startingWindow = null;
26310
+ });
26311
+ return starting;
26312
+ }
26313
+ async function initializeBrowserHost(options, bindings) {
26314
+ const { log: log2 } = options;
26315
+ const rollback = new StartupRollback();
26316
+ let published = false;
26317
+ const checkpoint = () => {
26318
+ if (cancelStartup || options.window.isDestroyed())
26319
+ throw new Error("The browser window closed during startup.");
26391
26320
  };
26392
- activeHost = handle2;
26393
- return handle2;
26394
- function buildView(window) {
26395
- const view = new electron.WebContentsView({
26396
- webPreferences: {
26397
- partition: BROWSER_PARTITION,
26398
- contextIsolation: true,
26399
- nodeIntegration: false,
26400
- webSecurity: true,
26401
- allowRunningInsecureContent: false,
26402
- sandbox: true
26403
- // No `preload`: a driven page has no bridge to anything.
26404
- }
26405
- });
26406
- window.contentView.addChildView(view);
26407
- view.setBounds(BACKGROUND_VIEWPORT);
26408
- view.setVisible(false);
26409
- return view;
26410
- }
26411
- function wireView(view, tabId) {
26412
- const contents = view.webContents;
26413
- const popupChildren = /* @__PURE__ */ new Set();
26414
- contents.setWindowOpenHandler((details) => {
26415
- const decision = decidePopup(
26416
- { url: details.url, disposition: details.disposition },
26417
- { mode: options.windowShow, live: popupChildren.size }
26418
- );
26419
- if (!decision.allow) {
26321
+ try {
26322
+ let releaseTab = function(tabId, webContentsId) {
26323
+ const view = views.get(tabId);
26324
+ views.delete(tabId);
26325
+ downloads.forget(tabId);
26326
+ const release = cdp.detach(webContentsId).finally(() => releaseView(options.window, view));
26327
+ releases.add(release);
26328
+ void release.catch(() => {
26329
+ }).finally(() => releases.delete(release));
26330
+ }, buildView = function(window) {
26331
+ const view = new electron.WebContentsView({
26332
+ webPreferences: {
26333
+ partition: BROWSER_PARTITION,
26334
+ contextIsolation: true,
26335
+ nodeIntegration: false,
26336
+ webSecurity: true,
26337
+ allowRunningInsecureContent: false,
26338
+ sandbox: true
26339
+ // No `preload`: a driven page has no bridge to anything.
26340
+ }
26341
+ });
26342
+ window.contentView.addChildView(view);
26343
+ view.setBounds(BACKGROUND_VIEWPORT);
26344
+ view.setVisible(false);
26345
+ return view;
26346
+ }, wireView = function(view, tabId) {
26347
+ const contents = view.webContents;
26348
+ const popupChildren = /* @__PURE__ */ new Set();
26349
+ contents.setWindowOpenHandler((details) => {
26350
+ const decision = decidePopup(
26351
+ { url: details.url, disposition: details.disposition },
26352
+ { mode: options.windowShow, live: popupChildren.size }
26353
+ );
26354
+ if (!decision.allow) {
26355
+ log2(
26356
+ `[browser] refused a popup from a driven page: ${details.url} (${decision.reason})`
26357
+ );
26358
+ return { action: "deny" };
26359
+ }
26420
26360
  log2(
26421
- `[browser] refused a popup from a driven page: ${details.url} (${decision.reason})`
26361
+ `[browser] tab ${tabId} opened a popup: ${details.url} (disposition=${details.disposition}, presentation=${decision.presentation})`
26422
26362
  );
26423
- return { action: "deny" };
26424
- }
26425
- log2(
26426
- `[browser] tab ${tabId} opened a popup: ${details.url} (disposition=${details.disposition}, presentation=${decision.presentation})`
26427
- );
26428
- return {
26429
- action: "allow",
26430
- overrideBrowserWindowOptions: {
26431
- width: 640,
26432
- height: 720,
26433
- minWidth: 480,
26434
- minHeight: 560,
26435
- center: true,
26436
- frame: true,
26437
- autoHideMenuBar: true,
26438
- // Nothing shows itself: presentation happens only through
26439
- // `presentPopupWindow` (window-raise.ts), under the launch plan's own
26440
- // gate. Under `never` the window exists hidden and drivable over CDP.
26441
- show: false,
26442
- // Defence in depth beside the plan gate: a window that will never be
26443
- // shown must also not be focusable — the same pairing `window-mode.ts`
26444
- // applies to the main window in `headless`.
26445
- focusable: decision.presentation !== "never",
26446
- backgroundColor: "#FFFFFF",
26447
- webPreferences: {
26448
- /*
26449
- * EXPLICIT, and load-bearing (design 2.3): `partition` is NOT among the
26450
- * security-related prefs Electron merges from the parent, and a popup
26451
- * created in a different session is not RELATED to its opener —
26452
- * Chromium severs `window.opener`, which is the entire contract a
26453
- * popup-based sign-in needs. The `about:blank` case lands in this same
26454
- * session by prefs-copy instead of by this line.
26455
- */
26456
- partition: BROWSER_PARTITION,
26457
- sandbox: true,
26458
- contextIsolation: true,
26459
- nodeIntegration: false,
26460
- webSecurity: true,
26461
- allowRunningInsecureContent: false,
26462
- spellcheck: false,
26463
- navigateOnDragDrop: false,
26464
- // Not security-inherited either: a hidden popup must render like a
26465
- // shown one so a capture of it is evidence (design 2.5).
26466
- backgroundThrottling: options.backgroundThrottling
26467
- // No `preload` — a popup has no bridge to anything, like the view.
26363
+ return {
26364
+ action: "allow",
26365
+ overrideBrowserWindowOptions: {
26366
+ width: 640,
26367
+ height: 720,
26368
+ minWidth: 480,
26369
+ minHeight: 560,
26370
+ center: true,
26371
+ frame: true,
26372
+ autoHideMenuBar: true,
26373
+ // Nothing shows itself: presentation happens only through
26374
+ // `presentPopupWindow` (window-raise.ts), under the launch plan's own
26375
+ // gate. Under `never` the window exists hidden and drivable over CDP.
26376
+ show: false,
26377
+ // Defence in depth beside the plan gate: a window that will never be
26378
+ // shown must also not be focusable — the same pairing `window-mode.ts`
26379
+ // applies to the main window in `headless`.
26380
+ focusable: decision.presentation !== "never",
26381
+ backgroundColor: "#FFFFFF",
26382
+ webPreferences: {
26383
+ /*
26384
+ * EXPLICIT, and load-bearing (design 2.3): `partition` is NOT among the
26385
+ * security-related prefs Electron merges from the parent, and a popup
26386
+ * created in a different session is not RELATED to its opener —
26387
+ * Chromium severs `window.opener`, which is the entire contract a
26388
+ * popup-based sign-in needs. The `about:blank` case lands in this same
26389
+ * session by prefs-copy instead of by this line.
26390
+ */
26391
+ partition: BROWSER_PARTITION,
26392
+ sandbox: true,
26393
+ contextIsolation: true,
26394
+ nodeIntegration: false,
26395
+ webSecurity: true,
26396
+ allowRunningInsecureContent: false,
26397
+ spellcheck: false,
26398
+ navigateOnDragDrop: false,
26399
+ // Not security-inherited either: a hidden popup must render like a
26400
+ // shown one so a capture of it is evidence (design 2.5).
26401
+ backgroundThrottling: options.backgroundThrottling
26402
+ // No `preload` — a popup has no bridge to anything, like the view.
26403
+ }
26468
26404
  }
26405
+ };
26406
+ });
26407
+ contents.on("did-create-window", (child, details) => {
26408
+ wirePopup(child, details, popupChildren);
26409
+ });
26410
+ const onWillNavigate = (event, rawUrl) => {
26411
+ if (refusesNavigation(rawUrl)) {
26412
+ event.preventDefault();
26413
+ log2(`[browser] refused a navigation to ${rawUrl}`);
26469
26414
  }
26470
26415
  };
26471
- });
26472
- contents.on("did-create-window", (child, details) => {
26473
- wirePopup(child, details, popupChildren);
26474
- });
26475
- const onWillNavigate = (event, rawUrl) => {
26476
- if (refusesNavigation(rawUrl)) {
26416
+ contents.on("will-navigate", onWillNavigate);
26417
+ contents.on("will-redirect", onWillNavigate);
26418
+ contents.on("before-input-event", (event, input) => {
26419
+ const direction = paneNavigationDirection(input);
26420
+ if (!direction) return;
26421
+ event.preventDefault();
26422
+ host.historyActive(direction);
26423
+ });
26424
+ const notifyChrome = () => {
26425
+ options.window.webContents.send("browser-state-changed");
26426
+ };
26427
+ contents.on("did-start-loading", () => {
26428
+ host.clearLoadFailure(tabId);
26429
+ notifyChrome();
26430
+ });
26431
+ contents.on("did-stop-loading", notifyChrome);
26432
+ contents.on("page-title-updated", notifyChrome);
26433
+ contents.on(
26434
+ "did-fail-load",
26435
+ (_event, code, description, url, isMainFrame) => {
26436
+ if (!isMainFrame) return;
26437
+ if (isReportableLoadFailure(code)) {
26438
+ host.recordLoadFailure(tabId, {
26439
+ code,
26440
+ description,
26441
+ url
26442
+ });
26443
+ }
26444
+ notifyChrome();
26445
+ }
26446
+ );
26447
+ contents.on("will-attach-webview", (event) => {
26477
26448
  event.preventDefault();
26478
- log2(`[browser] refused a navigation to ${rawUrl}`);
26449
+ log2("[browser] refused a webview attachment inside a driven page");
26450
+ });
26451
+ contents.on("did-navigate", (_event, url) => {
26452
+ registry.bumpEpoch(tabId);
26453
+ host.clearLoadFailure(tabId);
26454
+ stopLogCapture(contents.id);
26455
+ startLogCapture(contents.id);
26456
+ log2(`[browser] tab ${tabId} navigated to ${url}`);
26457
+ notifyChanged();
26458
+ });
26459
+ contents.on("did-navigate-in-page", (_event, url, isMainFrame) => {
26460
+ if (!isMainFrame) return;
26461
+ registry.bumpEpoch(tabId, false);
26462
+ log2(`[browser] tab ${tabId} navigated in page to ${url}`);
26463
+ notifyChanged();
26464
+ });
26465
+ contents.on("destroyed", () => {
26466
+ const record = registry.get(tabId);
26467
+ if (record) {
26468
+ const token = surfaceToken(record);
26469
+ if (token) approvals.forgetDocument(token);
26470
+ registry.forget(tabId);
26471
+ }
26472
+ host.clearLoadFailure(tabId);
26473
+ releaseTab(tabId, contents.id);
26474
+ });
26475
+ }, wirePopup = function(child, details, popupChildren) {
26476
+ popupChildren.add(child);
26477
+ child.on("closed", () => popupChildren.delete(child));
26478
+ const childContents = child.webContents;
26479
+ childContents.setWindowOpenHandler((childDetails) => {
26480
+ log2(`[browser] refused a popup from a popup: ${childDetails.url}`);
26481
+ return { action: "deny" };
26482
+ });
26483
+ const onWillNavigate = (event, rawUrl) => {
26484
+ if (refusesNavigation(rawUrl)) {
26485
+ event.preventDefault();
26486
+ log2(`[browser] refused a popup navigation to ${rawUrl}`);
26487
+ }
26488
+ };
26489
+ childContents.on("will-navigate", onWillNavigate);
26490
+ childContents.on("will-redirect", onWillNavigate);
26491
+ childContents.on("will-attach-webview", (event) => {
26492
+ event.preventDefault();
26493
+ log2("[browser] refused a webview attachment inside a popup");
26494
+ });
26495
+ childContents.on("before-input-event", (event, input) => {
26496
+ const direction = paneNavigationDirection(input);
26497
+ if (!direction) return;
26498
+ event.preventDefault();
26499
+ const navigation = childContents.navigationHistory;
26500
+ if (direction === "back") navigation.goBack();
26501
+ else navigation.goForward();
26502
+ });
26503
+ if (!options.backgroundThrottling) {
26504
+ childContents.setBackgroundThrottling(false);
26479
26505
  }
26506
+ presentPopupWindow(
26507
+ child,
26508
+ effectivePresentation(options.windowShow, details.disposition),
26509
+ { trigger: "popup-open", report: options.reportRaise }
26510
+ );
26480
26511
  };
26481
- contents.on("will-navigate", onWillNavigate);
26482
- contents.on("will-redirect", onWillNavigate);
26483
- contents.on("before-input-event", (event, input) => {
26484
- const direction = paneNavigationDirection(input);
26485
- if (!direction) return;
26486
- event.preventDefault();
26487
- host.historyActive(direction);
26512
+ const browserSession = resolveBrowserSession();
26513
+ let registryForDownloads = null;
26514
+ const downloads = new DownloadArmer({
26515
+ tabForWebContents: (webContentsId) => registryForDownloads?.byWebContents(webContentsId)?.tabId ?? null,
26516
+ // WHO OWNED THE TAB, recorded on the decision (review round 2, U10). Read from
26517
+ // the registry at the moment the note is written, because the row may render it
26518
+ // after the tab is closed — and "· on another tab" about a tab that no longer
26519
+ // exists is a marker pointing at nothing. `null` when the lookup cannot answer
26520
+ // (a tab already gone), which the row renders the old way rather than guessing.
26521
+ ownerKindFor: (tabId) => registryForDownloads?.get(tabId)?.owner ?? null,
26522
+ log: log2,
26523
+ onActivity: () => {
26524
+ if (options.window.isDestroyed()) return;
26525
+ options.window.webContents.send("browser-state-changed");
26526
+ }
26527
+ });
26528
+ const detachSessionHandlers = installBrowserSessionHandlers(
26529
+ browserSession,
26530
+ {
26531
+ onPermissionRequested: (details) => {
26532
+ log2(
26533
+ `[browser] denied a ${details.permission} permission request from ${details.origin || "(unknown origin)"}`
26534
+ );
26535
+ },
26536
+ onDownload: (item, webContentsId) => downloads.decide(item, webContentsId),
26537
+ onDownloadDecided: (outcome) => {
26538
+ if (outcome.savePath) {
26539
+ log2(
26540
+ `[browser] saved a download from ${outcome.url} to ${outcome.savePath}`
26541
+ );
26542
+ }
26543
+ },
26544
+ log: log2
26545
+ }
26546
+ );
26547
+ rollback.add(detachSessionHandlers);
26548
+ configurePslRules(PSL_RULES);
26549
+ await installWebauthn({ log: log2 });
26550
+ checkpoint();
26551
+ const describeSource = (frame) => {
26552
+ if (!frame || typeof frame !== "object") {
26553
+ return { tabId: null, pageTitle: null };
26554
+ }
26555
+ const contents = electron.webContents.fromFrame(frame);
26556
+ const record = contents === void 0 ? void 0 : registry.byWebContentsId(contents.id);
26557
+ if (!record) return { tabId: null, pageTitle: null };
26558
+ const title = record.view.webContents.isDestroyed() ? "" : record.view.webContents.getTitle();
26559
+ return { tabId: record.tabId, pageTitle: title || null };
26560
+ };
26561
+ const webauthn = new WebauthnChooser({
26562
+ notify: (request) => {
26563
+ if (options.window.isDestroyed()) return;
26564
+ options.window.webContents.send("browser-webauthn-request", request);
26565
+ },
26566
+ /*
26567
+ * AND THE OTHER DIRECTION: every settle is pushed, not only every arrival.
26568
+ *
26569
+ * Without this the renderer cannot tell a live request from one main has
26570
+ * already cancelled, so a chooser whose request expired while the user was
26571
+ * away stays on screen offering rows that can no longer do anything (design
26572
+ * round 1, D2) — and the click is then discarded silently. The outcome travels
26573
+ * with the push because the two cases differ for the user: their own answer is
26574
+ * already off the screen, an expiry has to be explained.
26575
+ */
26576
+ onSettled: (requestId2, outcome) => {
26577
+ if (options.window.isDestroyed()) return;
26578
+ options.window.webContents.send("browser-webauthn-settled", {
26579
+ requestId: requestId2,
26580
+ outcome
26581
+ });
26582
+ },
26583
+ describeSource,
26584
+ log: log2
26488
26585
  });
26489
- const notifyChrome = () => {
26586
+ rollback.add(() => webauthn.dispose());
26587
+ const detachWebauthn = attachWebauthnChooser(browserSession, webauthn, log2);
26588
+ rollback.add(detachWebauthn);
26589
+ const cookieJar = createHiddenCookieJarTarget(
26590
+ new electron.WebContentsView({
26591
+ webPreferences: { ...HIDDEN_COOKIE_JAR_WEB_PREFERENCES }
26592
+ })
26593
+ );
26594
+ rollback.add(() => cookieJar.dispose());
26595
+ const sessionCookies = new SessionCookieVault({
26596
+ jar: cookieJar.jar,
26597
+ cipher: browserProfileCipher(),
26598
+ ...sessionCookiePaths(options.userDataDir),
26599
+ flushStore: () => browserSession.cookies.flushStore(),
26600
+ clearSessionData: (what) => clearBrowsingData(browserSession, what),
26601
+ log: log2
26602
+ });
26603
+ let restoreReport = null;
26604
+ try {
26605
+ restoreReport = await sessionCookies.restore();
26606
+ } catch (error) {
26607
+ log2(`[browser] session cookies: the restore failed (${String(error)})`);
26608
+ }
26609
+ checkpoint();
26610
+ const cdp = new CdpPool({ log: log2 });
26611
+ rollback.add(() => cdp.close());
26612
+ const views = /* @__PURE__ */ new Map();
26613
+ const releases = /* @__PURE__ */ new Set();
26614
+ let stateWriter = null;
26615
+ const sessionStore = new BrowserSessionStore({
26616
+ dir: path$1.join(options.userDataDir, "browser"),
26617
+ log: log2
26618
+ });
26619
+ const consentNotifier = new ConsentNotifier({
26620
+ show: options.windowShow,
26621
+ /*
26622
+ * The click's own rule lives in `consentClickHandler` (see it: the delivered
26623
+ * payload, the order, and the raise are all asserted there), and this is the
26624
+ * wiring that gives it the real window and the real plan. `windowShow` is the
26625
+ * plan the click comes forward under — this module still never decides whether a
26626
+ * window comes forward (design 11.4), it hands the plan to `window-raise.ts`.
26627
+ */
26628
+ onAttention: consentClickHandler({
26629
+ /*
26630
+ * THE WINDOW IS ASKED FOR, NOT CAPTURED, and the difference is the U2 fix:
26631
+ * this host is stopped the moment its window closes (`src/main/index.ts`'s
26632
+ * `closed` hook), but a banner already raised is still in Notification
26633
+ * Center and its click still lands here. A captured window would be a
26634
+ * destroyed one by then, and reading it throws.
26635
+ */
26636
+ window: () => options.window,
26637
+ show: options.windowShow,
26638
+ report: options.reportRaise,
26639
+ reopen: options.reopenConsent
26640
+ }),
26641
+ log: log2
26642
+ });
26643
+ const captureSession = () => {
26644
+ if (!published) return;
26645
+ sessionStore.record(
26646
+ captureTabs(
26647
+ registry.list(),
26648
+ registry.activeTab?.tabId ?? null,
26649
+ host.failedTabIds()
26650
+ )
26651
+ );
26652
+ };
26653
+ const notifyChanged = () => {
26654
+ stateWriter?.publishNow();
26490
26655
  options.window.webContents.send("browser-state-changed");
26656
+ captureSession();
26491
26657
  };
26492
- contents.on("did-start-loading", () => {
26493
- host.clearLoadFailure(tabId);
26494
- notifyChrome();
26658
+ const registry = new TabRegistry(
26659
+ (_createOptions, tabId) => {
26660
+ const view = buildView(options.window);
26661
+ views.set(tabId, view);
26662
+ wireView(view, tabId);
26663
+ return view;
26664
+ },
26665
+ releaseTab,
26666
+ notifyChanged
26667
+ );
26668
+ registryForDownloads = registry;
26669
+ rollback.add(async () => {
26670
+ published = false;
26671
+ sessionStore.discard();
26672
+ registry.destroyAll();
26673
+ await Promise.allSettled([...releases]);
26495
26674
  });
26496
- contents.on("did-stop-loading", notifyChrome);
26497
- contents.on("page-title-updated", notifyChrome);
26498
- contents.on(
26499
- "did-fail-load",
26500
- (_event, code, description, url, isMainFrame) => {
26501
- if (!isMainFrame) return;
26502
- if (isReportableLoadFailure(code)) {
26503
- host.recordLoadFailure(tabId, {
26504
- code,
26505
- description,
26506
- url
26507
- });
26675
+ const ownership = new OwnershipLedger({
26676
+ mayAdopt: (token, requester) => {
26677
+ const record = registry.requireSurface(token);
26678
+ return record.handedTo === requester.slice("session:".length) && !record.allocationId;
26679
+ },
26680
+ closeTab: async (token) => {
26681
+ try {
26682
+ const record = registry.requireSurface(token);
26683
+ approvals.forgetDocument(token);
26684
+ registry.destroy(record.tabId);
26685
+ } catch {
26686
+ }
26687
+ return true;
26688
+ },
26689
+ isLive: (token) => {
26690
+ try {
26691
+ registry.requireSurface(token);
26692
+ return true;
26693
+ } catch {
26694
+ return false;
26508
26695
  }
26509
- notifyChrome();
26510
26696
  }
26511
- );
26512
- contents.on("will-attach-webview", (event) => {
26513
- event.preventDefault();
26514
- log2("[browser] refused a webview attachment inside a driven page");
26515
26697
  });
26516
- contents.on("did-navigate", (_event, url) => {
26517
- registry.bumpEpoch(tabId);
26518
- host.clearLoadFailure(tabId);
26519
- stopLogCapture(contents.id);
26520
- startLogCapture(contents.id);
26521
- log2(`[browser] tab ${tabId} navigated to ${url}`);
26522
- notifyChanged();
26698
+ const approvals = new ApprovalStore({
26699
+ dir: path$1.join(options.userDataDir, "browser"),
26700
+ onChanged: () => {
26701
+ options.window.webContents.send("browser-consent-changed");
26702
+ consentNotifier.announce(approvals.pendingEntries());
26703
+ },
26704
+ log: log2
26523
26705
  });
26524
- contents.on("did-navigate-in-page", (_event, url, isMainFrame) => {
26525
- if (!isMainFrame) return;
26526
- registry.bumpEpoch(tabId, false);
26527
- log2(`[browser] tab ${tabId} navigated in page to ${url}`);
26528
- notifyChanged();
26706
+ const profileDir = browserStoragePath(browserSession) ?? "";
26707
+ const facts = {
26708
+ proto: 1,
26709
+ appVersion: options.appVersion,
26710
+ profileDir,
26711
+ profilePersistent: browserSession.isPersistent(),
26712
+ userAgent: browserUserAgent(),
26713
+ domainScope: domainScopeAvailable()
26714
+ };
26715
+ const host = new BrowserHost({
26716
+ registry,
26717
+ cdp,
26718
+ approvals,
26719
+ ownership,
26720
+ downloads,
26721
+ log: log2,
26722
+ onChanged: notifyChanged,
26723
+ facts: () => facts,
26724
+ forgetSiteData: (origin) => clearOriginData(browserSession, origin)
26529
26725
  });
26530
- contents.on("destroyed", () => {
26531
- const record = registry.get(tabId);
26532
- if (record) {
26533
- const token = surfaceToken(record);
26534
- if (token) approvals.forgetDocument(token);
26535
- registry.forget(tabId);
26536
- }
26537
- host.clearLoadFailure(tabId);
26538
- releaseTab(tabId, contents.id);
26726
+ const recorded = readSession(sessionStore.filePath, log2);
26727
+ host.restoreTabs(recorded);
26728
+ if (recorded.length) {
26729
+ log2(
26730
+ `[browser] restored ${recorded.length} tab(s) from ${SESSION_FILENAME}; every restored tab is user-owned and holds no handle (design 7.3)`
26731
+ );
26732
+ }
26733
+ const consoleStartup = await startConsoleHost({
26734
+ window: options.window,
26735
+ expectedUrl: options.expectedUrl,
26736
+ appVersion: options.appVersion,
26737
+ notifier: options.notifier,
26738
+ /*
26739
+ * The capture view's document and preload, forwarded rather than re-derived:
26740
+ * only the app knows where its own bundle lives, and a console host that
26741
+ * guessed would answer a screenshot with a blank frame rather than with its
26742
+ * own typed refusal. WITHOUT THESE TWO the host builds no capture view at all,
26743
+ * which the live proof rig caught as `capture_unavailable` on a surface with
26744
+ * no displayed pane — the exact shape of a seam that is wired in tests and
26745
+ * forgotten in the one call site that matters.
26746
+ */
26747
+ consoleCaptureUrl: options.consoleCaptureUrl,
26748
+ preloadPath: options.preloadPath,
26749
+ userShellPath: options.userShellPath,
26750
+ log: log2
26539
26751
  });
26540
- }
26541
- function wirePopup(child, details, popupChildren) {
26542
- popupChildren.add(child);
26543
- child.on("closed", () => popupChildren.delete(child));
26544
- const childContents = child.webContents;
26545
- childContents.setWindowOpenHandler((childDetails) => {
26546
- log2(`[browser] refused a popup from a popup: ${childDetails.url}`);
26547
- return { action: "deny" };
26752
+ rollback.add(() => consoleStartup.stop());
26753
+ checkpoint();
26754
+ const sessionKey = mintSessionKey();
26755
+ const server = await startRpcServer({
26756
+ key: sessionKey,
26757
+ instanceId: hostInstanceId,
26758
+ dispatch: async (method, params, requestId2) => {
26759
+ if (!isConsoleDispatchMethod(method)) {
26760
+ return host.dispatch(method, params, requestId2);
26761
+ }
26762
+ if (!consoleStartup.ok) {
26763
+ throw new ConsoleError(
26764
+ "console_unavailable",
26765
+ `this app's console is not available (${consoleStartup.reason}): ${consoleStartup.detail}`,
26766
+ { reason: consoleStartup.reason }
26767
+ );
26768
+ }
26769
+ return consoleStartup.dispatch(method, params);
26770
+ },
26771
+ log: log2,
26772
+ capabilities: () => ({ console: consoleStartup.ok })
26548
26773
  });
26549
- const onWillNavigate = (event, rawUrl) => {
26550
- if (refusesNavigation(rawUrl)) {
26551
- event.preventDefault();
26552
- log2(`[browser] refused a popup navigation to ${rawUrl}`);
26774
+ rollback.add(() => server.close());
26775
+ checkpoint();
26776
+ stateWriter = new BrowserStateWriter(
26777
+ stateFilePath(),
26778
+ () => {
26779
+ const consoleCounts = consoleStartup.ok ? consoleStartup.counts() : { total: 0, agent: 0 };
26780
+ return {
26781
+ tabs: registry.count(),
26782
+ agentTabs: registry.agentTabCount(),
26783
+ profileDir,
26784
+ console: consoleStartup.ok,
26785
+ consoleSurfaces: consoleCounts.total,
26786
+ consoleAgentSurfaces: consoleCounts.agent
26787
+ };
26788
+ },
26789
+ {
26790
+ appVersion: options.appVersion,
26791
+ instanceId: hostInstanceId,
26792
+ onError: (error) => log2(`[browser] state file: ${String(error)}`)
26553
26793
  }
26554
- };
26555
- childContents.on("will-navigate", onWillNavigate);
26556
- childContents.on("will-redirect", onWillNavigate);
26557
- childContents.on("will-attach-webview", (event) => {
26558
- event.preventDefault();
26559
- log2("[browser] refused a webview attachment inside a popup");
26560
- });
26561
- childContents.on("before-input-event", (event, input) => {
26562
- const direction = paneNavigationDirection(input);
26563
- if (!direction) return;
26564
- event.preventDefault();
26565
- const navigation = childContents.navigationHistory;
26566
- if (direction === "back") navigation.goBack();
26567
- else navigation.goForward();
26794
+ );
26795
+ rollback.add(() => stateWriter?.clear());
26796
+ stateWriter.start(server.port, sessionKey);
26797
+ Object.assign(bindings, {
26798
+ window: () => options.window,
26799
+ expectedUrl: options.expectedUrl,
26800
+ host: () => host,
26801
+ webauthn: () => webauthn,
26802
+ clearData: (what) => sessionCookies.clearBrowsingData(what),
26803
+ // THE REVEAL TAKES NO PATH FROM THE RENDERER (§16.4): it opens the directory the
26804
+ // host is actually writing into, so the one place a page-derived string could
26805
+ // have become a path stays out of the IPC surface as well. `shell.openPath`
26806
+ // answers "" on success and a message on failure, which is returned rather than
26807
+ // thrown: a reveal that fails is a Finder problem, not a fault in the agent's
26808
+ // download.
26809
+ revealDownloads: async () => {
26810
+ const dir = downloads.downloadDir();
26811
+ if (!dir) return "no download directory yet";
26812
+ return electron.shell.openPath(dir);
26813
+ },
26814
+ log: log2
26568
26815
  });
26569
- if (!options.backgroundThrottling) {
26570
- childContents.setBackgroundThrottling(false);
26571
- }
26572
- presentPopupWindow(
26573
- child,
26574
- effectivePresentation(options.windowShow, details.disposition),
26575
- { trigger: "popup-open", report: options.reportRaise }
26816
+ log2(
26817
+ `[browser] host on ${server.address}:${server.port} (proto ${facts.proto}), profile ${profileDir || "(in-memory)"}, agent tabs ${registry.agentTabCount()}/${8}, console ${consoleStartup.ok ? `on (${consoleStartup.counts().total} surface(s))` : `off (${consoleStartup.reason})`}`
26576
26818
  );
26819
+ const handle2 = {
26820
+ host,
26821
+ port: server.port,
26822
+ profileDir,
26823
+ agentTabs: () => registry.agentTabCount(),
26824
+ sessionCookies: () => restoreReport,
26825
+ stop: async () => {
26826
+ const atStop = captureTabs(
26827
+ registry.list(),
26828
+ registry.activeTab?.tabId ?? null,
26829
+ host.failedTabIds()
26830
+ );
26831
+ const decision = sessionStore.commitStopCapture(atStop);
26832
+ if (!decision.write) {
26833
+ log2(
26834
+ `[browser] not overwriting ${SESSION_FILENAME} at stop: ${decision.reason}`
26835
+ );
26836
+ }
26837
+ sessionStore.flush();
26838
+ registry.destroyAll();
26839
+ await Promise.allSettled([...releases]);
26840
+ await cdp.close();
26841
+ await server.close();
26842
+ await consoleStartup.stop();
26843
+ unregisterBrowserIpc();
26844
+ detachSessionHandlers();
26845
+ detachWebauthn();
26846
+ webauthn.dispose();
26847
+ stateWriter?.clear();
26848
+ approvals.resetPending();
26849
+ ownership.clear();
26850
+ flushBrowserStorage(browserSession);
26851
+ try {
26852
+ await sessionCookies.snapshot();
26853
+ } catch (error) {
26854
+ log2(
26855
+ `[browser] session cookies: the snapshot failed (${String(error)})`
26856
+ );
26857
+ }
26858
+ cookieJar.dispose();
26859
+ log2("[browser] host stopped");
26860
+ }
26861
+ };
26862
+ checkpoint();
26863
+ published = true;
26864
+ activeHost = handle2;
26865
+ rollback.commit();
26866
+ return handle2;
26867
+ } catch (error) {
26868
+ published = false;
26869
+ for (const key of Object.keys(bindings))
26870
+ delete bindings[key];
26871
+ await rollback.run(log2);
26872
+ throw error;
26577
26873
  }
26578
26874
  }
26579
26875
  function releaseView(window, view) {
@@ -37741,6 +38037,15 @@ class UpdateService {
37741
38037
  * so it is kept rather than fetched again on the click (review U17).
37742
38038
  */
37743
38039
  lastPublishedBackendVersion = null;
38040
+ /**
38041
+ * The version PyPI most recently NAMED whose files were not yet
38042
+ * downloadable, or null. Kept beside the published reading because the
38043
+ * manual check's sentence depends on the distinction (UX round 1, U1):
38044
+ * "a release is still publishing" and "the version could not be
38045
+ * determined" are different facts with different next steps, and the
38046
+ * gated read alone collapses them into one null.
38047
+ */
38048
+ pendingPypiVersion = null;
37744
38049
  /** Path of the artifact this run downloaded, when the updater reported one. */
37745
38050
  downloadedArtifactPath = null;
37746
38051
  /**
@@ -40550,8 +40855,17 @@ ${heal.removed.join("\n")}`,
40550
40855
  return (await this.readRunningBackend()).version;
40551
40856
  }
40552
40857
  /**
40553
- * Get the latest version from PyPI
40554
- * @returns Promise resolving to the latest version string or null if unable to fetch
40858
+ * Get the latest READY version from PyPI: the newest release whose
40859
+ * platform wheel an installer can actually download, per
40860
+ * `readPypiReleaseBody`. A version PyPI has named but whose files have
40861
+ * not finished uploading resolves null — the same answer as an
40862
+ * unreadable response, because both mean do not offer yet; the next
40863
+ * scheduled check re-reads. The named-but-not-ready version is kept on
40864
+ * `pendingPypiVersion` so the manual check can say which state it is
40865
+ * in. Reading only `info.version` here was the 2026-10-12 race: the
40866
+ * offer appeared minutes after publication and `lop update` exited 1
40867
+ * against artifacts that were not on PyPI yet.
40868
+ * @returns Promise resolving to the latest ready version string or null if unable to fetch
40555
40869
  */
40556
40870
  getLatestPypiVersion() {
40557
40871
  return new Promise((resolve) => {
@@ -40567,14 +40881,26 @@ ${heal.removed.join("\n")}`,
40567
40881
  { timeout: PYPI_VERSION_READ_TIMEOUT_MS },
40568
40882
  (res) => {
40569
40883
  let data = "";
40884
+ const settleAborted = () => {
40885
+ this.pendingPypiVersion = null;
40886
+ resolve(null);
40887
+ };
40888
+ res.on("error", settleAborted);
40889
+ res.on("aborted", settleAborted);
40570
40890
  res.on("data", (chunk) => {
40571
40891
  data += chunk;
40572
40892
  });
40573
40893
  res.on("end", () => {
40574
40894
  try {
40575
- const packageInfo = JSON.parse(data);
40576
- const latestVersion = packageInfo.info?.version;
40577
- resolve(latestVersion || null);
40895
+ const reading = readPypiReleaseBody(data, process.platform);
40896
+ this.pendingPypiVersion = reading.readyVersion === null ? reading.namedVersion : null;
40897
+ if (reading.readyVersion === null && reading.namedVersion) {
40898
+ logger.info(
40899
+ `PyPI names version ${reading.namedVersion} but its release has no downloadable wheel for this platform yet; not offering it. The next check will re-read.`,
40900
+ LogFileType.UPDATE_SERVICE
40901
+ );
40902
+ }
40903
+ resolve(reading.readyVersion);
40578
40904
  } catch (error) {
40579
40905
  logger.error(
40580
40906
  "Error parsing PyPI response:",
@@ -40594,6 +40920,7 @@ ${heal.removed.join("\n")}`,
40594
40920
  );
40595
40921
  });
40596
40922
  request.on("error", (error) => {
40923
+ this.pendingPypiVersion = null;
40597
40924
  logger.error(
40598
40925
  "Error fetching from PyPI:",
40599
40926
  LogFileType.UPDATE_SERVICE,
@@ -41028,7 +41355,19 @@ ${heal.removed.join("\n")}`,
41028
41355
  );
41029
41356
  if (reportFailure) {
41030
41357
  this.sendToRenderer("backend-update-error", {
41031
- message: "Unable to determine backend version.",
41358
+ /*
41359
+ * The not-ready state earns its own sentence (UX round 1, U1):
41360
+ * PyPI answered and named a release whose files are still
41361
+ * publishing, which is a wait, not a fault — the generic
41362
+ * sentence below reads as an installation problem and sends
41363
+ * the reader to debug one. Same producer-sentence shape as
41364
+ * the unreadable-install branch below. No retry action is
41365
+ * attached: the shared classifier gives authored sentences
41366
+ * no control (it names its own next step), and this sentence
41367
+ * does — check again in a few minutes, from the same control
41368
+ * the reader just used.
41369
+ */
41370
+ message: installedVersion && this.pendingPypiVersion ? `A new server release (${this.pendingPypiVersion}) is still publishing, so no update was offered yet. Check again in a few minutes.` : "Unable to determine backend version.",
41032
41371
  phase: "check",
41033
41372
  logPath: serverUpdateLogPath()
41034
41373
  });