local-operator-ui 0.26.10 → 0.27.0

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 (70) hide show
  1. package/out/main/index.js +569 -58
  2. package/out/preload/index.js +15 -1
  3. package/out/renderer/assets/{_basePickBy-BdydKoij.js → _basePickBy-Bc8mqN6i.js} +1 -1
  4. package/out/renderer/assets/{_baseUniq-BpVbhhNL.js → _baseUniq-CJFnmQVQ.js} +1 -1
  5. package/out/renderer/assets/{agent-details-page-DAXdL2AC.js → agent-details-page-DkshJZak.js} +1 -1
  6. package/out/renderer/assets/{agent-hub-page-DX3woSzq.js → agent-hub-page-BNgJSDD-.js} +1 -1
  7. package/out/renderer/assets/{agents-page-Bi73jo2W.js → agents-page-CZhbc7M_.js} +2 -2
  8. package/out/renderer/assets/{arc-BuGUakcY.js → arc-_VNZ7Lk1.js} +1 -1
  9. package/out/renderer/assets/{architectureDiagram-IEHRJDOE-Dnbac4Ez.js → architectureDiagram-IEHRJDOE-DTpnyiCl.js} +1 -1
  10. package/out/renderer/assets/{blockDiagram-JOT3LUYC-LQLL2aoo.js → blockDiagram-JOT3LUYC-WRMQhKcT.js} +1 -1
  11. package/out/renderer/assets/browser-page-DxvaF_od.js +1 -0
  12. package/out/renderer/assets/{c4Diagram-VJAJSXHY-Dkwrfgoq.js → c4Diagram-VJAJSXHY-Dqr2t_SX.js} +1 -1
  13. package/out/renderer/assets/channel-DWyvkgwR.js +1 -0
  14. package/out/renderer/assets/{chunk-4BMEZGHF-CoYUrJk7.js → chunk-4BMEZGHF-VrQLaWOX.js} +1 -1
  15. package/out/renderer/assets/{chunk-A2AXSNBT-HorAhh_g.js → chunk-A2AXSNBT-JbtRYOmY.js} +1 -1
  16. package/out/renderer/assets/{chunk-AEK57VVT-DTzjT6Fa.js → chunk-AEK57VVT-D5ZhImUH.js} +1 -1
  17. package/out/renderer/assets/{chunk-D6G4REZN-B7nQtFoz.js → chunk-D6G4REZN-DUc5G8Qu.js} +1 -1
  18. package/out/renderer/assets/{chunk-RZ5BOZE2-D88vbt_N.js → chunk-RZ5BOZE2-torZvZ85.js} +1 -1
  19. package/out/renderer/assets/{chunk-XZIHB7SX-0EUd2ZoR.js → chunk-XZIHB7SX-CkRX9XJ3.js} +1 -1
  20. package/out/renderer/assets/classDiagram-GIVACNV2-CjbYXD4Y.js +1 -0
  21. package/out/renderer/assets/classDiagram-v2-COTLJTTW-CjbYXD4Y.js +1 -0
  22. package/out/renderer/assets/clone-BLuae6Q5.js +1 -0
  23. package/out/renderer/assets/{compact-pagination-C6rPEaEV.js → compact-pagination-C8j5ZOjD.js} +1 -1
  24. package/out/renderer/assets/{dagre-OKDRZEBW-BP1ipKDa.js → dagre-OKDRZEBW-BeSeozND.js} +1 -1
  25. package/out/renderer/assets/{diagram-SSKATNLV-4RrMavMM.js → diagram-SSKATNLV-B1DwVFDf.js} +1 -1
  26. package/out/renderer/assets/{diagram-VNBRO52H-1d8WVjL6.js → diagram-VNBRO52H-D-c30ym_.js} +1 -1
  27. package/out/renderer/assets/{erDiagram-Q7BY3M3F-Bm8_w06I.js → erDiagram-Q7BY3M3F-DIssMOat.js} +1 -1
  28. package/out/renderer/assets/{flowDiagram-4HSFHLVR-CldQlpnE.js → flowDiagram-4HSFHLVR-CdTNf2L5.js} +1 -1
  29. package/out/renderer/assets/{ganttDiagram-APWFNJXF-DBoRJes4.js → ganttDiagram-APWFNJXF-DGl__V69.js} +1 -1
  30. package/out/renderer/assets/{gitGraphDiagram-7IBYFJ6S-D7mryG21.js → gitGraphDiagram-7IBYFJ6S-CDEvouMR.js} +1 -1
  31. package/out/renderer/assets/{graph-D0QWVchm.js → graph-DcplOFs2.js} +1 -1
  32. package/out/renderer/assets/icon-DITfm2jL.css +1 -0
  33. package/out/renderer/assets/{index-DIcuvujQ.js → index-BRiWNQSn.js} +1 -1
  34. package/out/renderer/assets/{index-D2Bt0r9y.js → index-DDuEnqhv.js} +394 -387
  35. package/out/renderer/assets/{index-DG4o13Hg.js → index-DrNa2kYY.js} +1 -1
  36. package/out/renderer/assets/{infoDiagram-PH2N3AL5-BkQngrDA.js → infoDiagram-PH2N3AL5-Fj0-QRyo.js} +1 -1
  37. package/out/renderer/assets/{installer-BTwDkxw3.js → installer-C5Cni-NB.js} +1 -1
  38. package/out/renderer/assets/{journeyDiagram-U35MCT3I-C_W7LIeb.js → journeyDiagram-U35MCT3I-DuqPXQA0.js} +1 -1
  39. package/out/renderer/assets/{kanban-definition-NDS4AKOZ-Dk0byars.js → kanban-definition-NDS4AKOZ-BNm-Nc45.js} +1 -1
  40. package/out/renderer/assets/{layout-Dw37GQ8H.js → layout-Bf7tY41L.js} +1 -1
  41. package/out/renderer/assets/{legacy-agents-page-Dwhqro7r.js → legacy-agents-page-Zsh4wkfD.js} +1 -1
  42. package/out/renderer/assets/{mermaid.core-fF8rQxbR.js → mermaid.core-ovJ-Ftst.js} +5 -5
  43. package/out/renderer/assets/{mindmap-definition-ALO5MXBD-Bar5MZkh.js → mindmap-definition-ALO5MXBD-DeoEktqp.js} +1 -1
  44. package/out/renderer/assets/{page-header-BxEtfYZ4.js → page-header-dhdkzSOM.js} +1 -1
  45. package/out/renderer/assets/{parseISO-Ch7Ia-JL.js → parseISO-X5g1SrZ4.js} +1 -1
  46. package/out/renderer/assets/{pieDiagram-IB7DONF6-CKsztwrg.js → pieDiagram-IB7DONF6-DZVzWOqr.js} +1 -1
  47. package/out/renderer/assets/{quadrantDiagram-7GDLP6J5-DfyPHpBg.js → quadrantDiagram-7GDLP6J5-C3XUFEBW.js} +1 -1
  48. package/out/renderer/assets/{radar-MK3ICKWK-CTTPT_0V.js → radar-MK3ICKWK-BUR-aXwF.js} +1 -1
  49. package/out/renderer/assets/{radient-auth-buttons-BOFrJt0t.js → radient-auth-buttons-BQxlFLQk.js} +1 -1
  50. package/out/renderer/assets/{requirementDiagram-KVF5MWMF-WGOccSbh.js → requirementDiagram-KVF5MWMF-BToLYBNy.js} +1 -1
  51. package/out/renderer/assets/{sankeyDiagram-QLVOVGJD-BgA-fd7Q.js → sankeyDiagram-QLVOVGJD-DnLYPQYY.js} +1 -1
  52. package/out/renderer/assets/{schedules-page-Cx6KQ-L9.js → schedules-page-BzekBI69.js} +1 -1
  53. package/out/renderer/assets/{sequenceDiagram-X6HHIX6F-SLQSmXRs.js → sequenceDiagram-X6HHIX6F--PfZO0A5.js} +1 -1
  54. package/out/renderer/assets/{settings-page-DLS-I3Yr.js → settings-page-A2CdEifL.js} +1 -1
  55. package/out/renderer/assets/{stateDiagram-DGXRK772-BPGUu0oZ.js → stateDiagram-DGXRK772-D5HQXkE9.js} +1 -1
  56. package/out/renderer/assets/stateDiagram-v2-YXO3MK2T-Dy6Q4aId.js +1 -0
  57. package/out/renderer/assets/{timeline-definition-BDJGKUSR-DcoCR1Vk.js → timeline-definition-BDJGKUSR-BqRqVKHe.js} +1 -1
  58. package/out/renderer/assets/{use-agent-like-mutation-BWTNRQ9u.js → use-agent-like-mutation-CznWOMhh.js} +1 -1
  59. package/out/renderer/assets/{xychartDiagram-VJFVF3MP-BSep-6zq.js → xychartDiagram-VJFVF3MP-Co3C9GP-.js} +1 -1
  60. package/out/renderer/index.html +3 -3
  61. package/out/renderer/installer.html +3 -3
  62. package/package.json +3 -3
  63. package/out/renderer/assets/browser-page-CbGKjPow.js +0 -1
  64. package/out/renderer/assets/channel-C3VRqBwG.js +0 -1
  65. package/out/renderer/assets/classDiagram-GIVACNV2-CWF6V5Fp.js +0 -1
  66. package/out/renderer/assets/classDiagram-v2-COTLJTTW-CWF6V5Fp.js +0 -1
  67. package/out/renderer/assets/clone-DXkbKicv.js +0 -1
  68. package/out/renderer/assets/icon-BVsYZnXF.css +0 -1
  69. package/out/renderer/assets/stateDiagram-v2-YXO3MK2T-CajJa5pO.js +0 -1
  70. /package/out/renderer/assets/{icon-CyUpM0N4.js → icon-CIk6DTWl.js} +0 -0
package/out/main/index.js CHANGED
@@ -1555,6 +1555,7 @@ function desktopEndpoint(request) {
1555
1555
  }
1556
1556
  }
1557
1557
  const MAX_PROBE_PATHS = 64;
1558
+ const DIRECTORY_ENTRY_LIMIT = 200;
1558
1559
  const MAX_FILE_READ_BYTES = 64 * 1024 * 1024;
1559
1560
  const OPEN_SESSION_FLAG = "--open-session";
1560
1561
  const OPEN_CATALOGUE_FLAG = "--open-catalogue";
@@ -4577,7 +4578,7 @@ function compareVersions(a, b) {
4577
4578
  }
4578
4579
  return 0;
4579
4580
  }
4580
- function compareCandidates(a, b, preferredPrefix) {
4581
+ function compareCandidates$1(a, b, preferredPrefix) {
4581
4582
  const sameInstall = (candidate) => preferredPrefix !== null && preferredPrefix !== "" && candidate.record.prefix === preferredPrefix;
4582
4583
  if (sameInstall(a) !== sameInstall(b)) return sameInstall(a) ? -1 : 1;
4583
4584
  if (a.source !== b.source) return a.source === "record" ? -1 : 1;
@@ -4590,7 +4591,7 @@ function compareCandidates(a, b, preferredPrefix) {
4590
4591
  }
4591
4592
  function rankCandidates(candidates, preferredPrefix) {
4592
4593
  return [...candidates].sort(
4593
- (a, b) => compareCandidates(a, b, preferredPrefix)
4594
+ (a, b) => compareCandidates$1(a, b, preferredPrefix)
4594
4595
  );
4595
4596
  }
4596
4597
  async function discoverDaemons(options = {}) {
@@ -10851,6 +10852,10 @@ class TabRegistry {
10851
10852
  // silently reclaim the tab it owned in a previous app run.
10852
10853
  owner: restored ? "user" : options.owner,
10853
10854
  sessionId: restored ? null : options.sessionId ?? null,
10855
+ // The same value `sessionId` gets at creation, kept because a hand-over
10856
+ // overwrites `sessionId` and a revocation has to put the conversation back
10857
+ // (see the field's own note).
10858
+ homeSessionId: restored ? null : options.sessionId ?? null,
10854
10859
  nonce: restored || options.owner === "user" ? null : mintNonce(),
10855
10860
  handedTo: null,
10856
10861
  restored,
@@ -10974,13 +10979,23 @@ class TabRegistry {
10974
10979
  this.onChanged();
10975
10980
  }
10976
10981
  /** Revoke a hand-over: nulls the nonce, so the session's next action on that
10977
- * handle gets the ordinary `tab_closed` refusal. */
10982
+ * handle gets the ordinary `tab_closed` refusal, and puts the tab back in the
10983
+ * conversation the user opened it in.
10984
+ *
10985
+ * THE ATTRIBUTION COMES BACK, NOT JUST THE OWNERSHIP (review round 1, Q2): the
10986
+ * revocation used to null `sessionId` outright, which took the tab out of the
10987
+ * conversation it was opened in (`tabsInScope` reads that field, and so does the
10988
+ * conversation's own tab count) — so revoking a hand-over silently lost the
10989
+ * conversation, and the design's §5.1 row 6 says a revoke is a true inverse. The
10990
+ * ownership and the capability still go, and `mayDrive` still refuses without the
10991
+ * nonce. A tab with no home (the route, a draft, a restored tab) still lands on
10992
+ * `null`, which is the behaviour it always had. */
10978
10993
  revokeHandOver(tabId) {
10979
10994
  const record = this.tabs.get(tabId);
10980
10995
  if (!record) return;
10981
10996
  record.nonce = null;
10982
10997
  record.owner = "user";
10983
- record.sessionId = null;
10998
+ record.sessionId = record.homeSessionId;
10984
10999
  record.handedTo = null;
10985
11000
  record.allocationId = "";
10986
11001
  this.bumpEpoch(record.tabId);
@@ -11082,24 +11097,89 @@ class TabRegistry {
11082
11097
  }
11083
11098
  // ---- removal -------------------------------------------------------------
11084
11099
  /**
11085
- * Drop a tab and hand its webContents id to `onRemove`, which is where the
11086
- * debugger session, the log buffer and the child view are released.
11087
- *
11088
- * `forget` is the registry-local half, used by the fail-closed handle check
11089
- * where the caller has no cleanup work to do (the webContents is already
11090
- * destroyed) and by `destroy` below.
11100
+ * Drop one tab's record WITHOUT notifying: the registry-local half of every removal.
11101
+ *
11102
+ * EXTRACTED FROM `forget` FOR `destroyMany` (design R5), and the split is what makes a
11103
+ * batch one change rather than N: `destroyMany` drops every record first, then runs
11104
+ * ONE `applyLayout()` and ONE `onChanged()`, and the notify-free half is the only way
11105
+ * to do that without N full state builds, N IPC broadcasts and N `session.json`
11106
+ * writes — which is what N sequential closes cost today (`host.closeTab` →
11107
+ * `registry.destroy` → `onChanged` per tab).
11091
11108
  */
11092
- forget(tabId) {
11109
+ drop(tabId) {
11093
11110
  const record = this.tabs.get(tabId);
11094
11111
  if (!record) return void 0;
11095
11112
  this.tabs.delete(tabId);
11096
11113
  if (this.activeTabId === tabId) {
11097
11114
  this.activeTabId = this.list().filter((candidate) => candidate.owner === "user").at(-1)?.tabId ?? null;
11098
11115
  }
11116
+ return record;
11117
+ }
11118
+ /**
11119
+ * Drop a tab and hand its webContents id to `onRemove`, which is where the
11120
+ * debugger session, the log buffer and the child view are released.
11121
+ *
11122
+ * `forget` is the registry-local half PLUS the notification: `drop` above removes the
11123
+ * record, and this runs the one `applyLayout()`/`onChanged()` pair every single-tab
11124
+ * removal has always run. It is what `destroy` below and the fail-closed handle check
11125
+ * use (where the caller has no cleanup work to do — the webContents is already
11126
+ * destroyed), while `destroyMany` uses `drop` for the same reason one level down: it
11127
+ * wants N removals under ONE notification rather than N of each.
11128
+ *
11129
+ * WHAT IT IS NOT, because this file is where a reader learns which removal path
11130
+ * notifies and it used to say the opposite of `drop`'s note (review round 1, A8):
11131
+ * `forget` is NOT the registry-local half — `drop` above is — and a caller that wants
11132
+ * the removal without the notification wants `drop`.
11133
+ */
11134
+ forget(tabId) {
11135
+ const record = this.drop(tabId);
11136
+ if (!record) return void 0;
11099
11137
  this.applyLayout();
11100
11138
  this.onChanged();
11101
11139
  return record;
11102
11140
  }
11141
+ /**
11142
+ * Close SEVERAL tabs as one change: the registry half of the renderer's bulk closes
11143
+ * (design R5).
11144
+ *
11145
+ * WHY ONE INTENT RATHER THAN N `destroy` CALLS, in the order the design weighs it:
11146
+ *
11147
+ * 1. "Close all tabs in this conversation" cannot be expressed as a list without
11148
+ * racing. A list computed from a projection the renderer read up to seconds ago
11149
+ * misses a tab an agent opened in that conversation in the meantime, and the user
11150
+ * pressed something that said ALL. (The renderer resolves that mode HERE, at
11151
+ * execution time, for exactly this reason.)
11152
+ * 2. N round trips are N full `chromeState()` builds, N IPC broadcasts and N
11153
+ * `session.json` writes, each of which re-renders the strip. A batch is one of
11154
+ * each.
11155
+ * 3. `destroy` is synchronous and the chrome's closes do not take the per-tab lane
11156
+ * (`registry.lane` is the dispatcher's), so there is no interleaving to reason
11157
+ * about within one intent — and N separate intents are N separate windows in which
11158
+ * an agent can create a tab. One intent, one window, one decision.
11159
+ *
11160
+ * IDS THAT ARE ALREADY GONE ARE SKIPPED RATHER THAN REFUSED: the user's intent was
11161
+ * that they be closed, and they are. Refusing the batch because one tab was closed
11162
+ * twice — by a second press, or by an agent's own `close` — would leave the other
11163
+ * tabs open, which is the one outcome nobody asked for.
11164
+ *
11165
+ * `onRemove` STILL RUNS PER RECORD and after the single notification, so the
11166
+ * webContents/debugger/log release path stays the single one it has always been
11167
+ * (design 11.1: with `WebContentsView`, nothing destroys the webContents for you).
11168
+ */
11169
+ destroyMany(tabIds) {
11170
+ const removed = [];
11171
+ for (const tabId of tabIds) {
11172
+ const record = this.drop(tabId);
11173
+ if (record) removed.push(record);
11174
+ }
11175
+ if (removed.length === 0) return 0;
11176
+ this.applyLayout();
11177
+ this.onChanged();
11178
+ for (const record of removed) {
11179
+ this.onRemove(record.tabId, record.view.webContents.id);
11180
+ }
11181
+ return removed.length;
11182
+ }
11103
11183
  /** Destroy a tab: forget it, then release its resources. `onRemove` is
11104
11184
  * called for every removal path so a tab can never be forgotten while its
11105
11185
  * webContents, debugger session or log buffer leak (design 11.1: with
@@ -11716,10 +11796,15 @@ class BrowserHost {
11716
11796
  owner: entry.owner,
11717
11797
  // WHICH CONVERSATION THIS TAB BELONGS TO, and the ONLY new field this
11718
11798
  // change adds to the wire. `null` is a real and common value — a restored
11719
- // tab is nobody's (`registry.ts:231`), a user tab that was never handed
11720
- // over is nobody's, and a tab handed back goes back to `null` (`:386`) —
11721
- // and it means "not any conversation's", which is why a conversation's
11722
- // scope shows it under no scope but `"all"` (spec 7.2).
11799
+ // tab is nobody's (`registry.ts:231`), a user tab never handed over and
11800
+ // opened from the route or a draft is nobody's — and it means "not any
11801
+ // conversation's", which is why a conversation's scope shows it under no
11802
+ // scope but `"all"` (spec 7.2).
11803
+ //
11804
+ // A TAB HANDED BACK KEEPS ITS CONVERSATION (review round 1, Q2):
11805
+ // revocation restores `homeSessionId` rather than writing `null`, so the
11806
+ // field is "which conversation this belongs to", not "which session is
11807
+ // driving it" — the second question is `handedOver` and `owner`.
11723
11808
  sessionId: entry.sessionId,
11724
11809
  active: entry.active,
11725
11810
  restored: entry.restored,
@@ -11791,10 +11876,32 @@ class BrowserHost {
11791
11876
  }))
11792
11877
  };
11793
11878
  }
11794
- /** A user tab: created active, at about:blank, owned by the user and with no
11795
- * capability until the user hands it over (design 6.1, 6.3). */
11796
- async newTab() {
11797
- const record = this.registry.create({ owner: "user" });
11879
+ /**
11880
+ * A user tab: created active, at about:blank, owned by the user and with no
11881
+ * capability until the user hands it over (design 6.1, 6.3).
11882
+ *
11883
+ * ATTRIBUTED TO THE CONVERSATION IT WAS OPENED FROM when it has one (design R1).
11884
+ * That is the whole of the main-process half and it takes no new mechanism:
11885
+ * `registry.create` already stores a non-restored tab's `sessionId`
11886
+ * (`registry.ts:231`) and already leaves every user tab's `nonce` null (`:232`).
11887
+ *
11888
+ * WHY THIS CHANGES NO CAPABILITY, and the test that pins it
11889
+ * (`scripts/browser-host.test.mjs`): `mayDrive` requires the session id AND a
11890
+ * nonce (`registry.ts:395-397`), and this tab has the first and not the second, so
11891
+ * an agent in that conversation still cannot drive a tab the user opened. The
11892
+ * `tabs` listing redacts its handle, `agentTabCount()` filters on `owner` and is
11893
+ * unmoved, the agent cap is unmoved, and hand-over stays the only authority
11894
+ * transfer.
11895
+ *
11896
+ * Before this, all three ways a user could open a tab produced one the pane's
11897
+ * "This conversation" scope would never show, because the scope filter reads this
11898
+ * one field.
11899
+ */
11900
+ async newTab(sessionId2) {
11901
+ const record = this.registry.create({
11902
+ owner: "user",
11903
+ sessionId: sessionId2 ?? null
11904
+ });
11798
11905
  await record.view.webContents.loadURL("about:blank");
11799
11906
  await this.cdp.attach(record.view.webContents);
11800
11907
  this.onChanged();
@@ -11807,6 +11914,32 @@ class BrowserHost {
11807
11914
  this.onChanged();
11808
11915
  return this.chromeState();
11809
11916
  }
11917
+ /**
11918
+ * Close several tabs as ONE intent (design R5): resolve it, close them, notify once.
11919
+ *
11920
+ * TWO MODES RATHER THAN ONE LIST, and the difference is the race the design names:
11921
+ *
11922
+ * - `ids` is positional — "these tabs, the ones I could see" — and main closes
11923
+ * exactly those, skipping any that are already gone. A tab an agent opened after the
11924
+ * press is not in the list and survives, which the strip then shows honestly.
11925
+ * - `conversation` is resolved HERE, at execution time, against the live registry. A
11926
+ * list computed from a projection the renderer read seconds ago (the band stays open
11927
+ * while the user reads it) would miss a tab an agent opened in that conversation in
11928
+ * the meantime — and the user pressed something that said ALL.
11929
+ *
11930
+ * ONE `onChanged()` EITHER WAY, which is the whole point of `registry.destroyMany`: N
11931
+ * closes are N full `chromeState()` builds, N IPC broadcasts, N renderer re-reads and
11932
+ * N `session.json` writes.
11933
+ */
11934
+ closeTabs(intent) {
11935
+ const tabIds = intent.mode === "ids" ? intent.tabIds : this.registry.list().filter((record) => record.sessionId === intent.sessionId).map((record) => record.tabId);
11936
+ for (const tabId of tabIds) {
11937
+ this.dropReceipt(tabId);
11938
+ this.loadFailures.delete(tabId);
11939
+ }
11940
+ this.registry.destroyMany(tabIds);
11941
+ return this.chromeState();
11942
+ }
11810
11943
  /**
11811
11944
  * Re-create the tabs a previous run recorded (design 7).
11812
11945
  *
@@ -12094,6 +12227,7 @@ const BROWSER_IPC_CHANNELS = [
12094
12227
  "browser-state",
12095
12228
  "browser-new-tab",
12096
12229
  "browser-close-tab",
12230
+ "browser-close-tabs",
12097
12231
  "browser-activate-tab",
12098
12232
  "browser-navigate",
12099
12233
  "browser-reload",
@@ -12120,11 +12254,18 @@ function registerBrowserIpc(options) {
12120
12254
  return host;
12121
12255
  }
12122
12256
  electron.ipcMain.handle("browser-state", (event) => authorize(event).chromeState());
12123
- electron.ipcMain.handle("browser-new-tab", async (event) => authorize(event).newTab());
12257
+ electron.ipcMain.handle("browser-new-tab", async (event, sessionId2) => {
12258
+ const host = authorize(event);
12259
+ return host.newTab(optionalConversationOrThrow(sessionId2));
12260
+ });
12124
12261
  electron.ipcMain.handle("browser-close-tab", (event, tabId) => {
12125
12262
  const host = authorize(event);
12126
12263
  return host.closeTab(numberOrThrow(tabId, "tabId"));
12127
12264
  });
12265
+ electron.ipcMain.handle("browser-close-tabs", (event, intent) => {
12266
+ const host = authorize(event);
12267
+ return host.closeTabs(closeTabsIntentOrThrow(intent));
12268
+ });
12128
12269
  electron.ipcMain.handle("browser-activate-tab", (event, tabId) => {
12129
12270
  const host = authorize(event);
12130
12271
  return host.activateTab(numberOrThrow(tabId, "tabId"));
@@ -12220,6 +12361,38 @@ function numberOrThrow(value, name) {
12220
12361
  }
12221
12362
  return value;
12222
12363
  }
12364
+ function closeTabsIntentOrThrow(value) {
12365
+ if (!value || typeof value !== "object") {
12366
+ throw new Error("A close request is required.");
12367
+ }
12368
+ const intent = value;
12369
+ if (intent.mode === "ids") {
12370
+ if (!Array.isArray(intent.tabIds) || intent.tabIds.length === 0) {
12371
+ throw new Error("Closing tabs by id needs at least one tab id.");
12372
+ }
12373
+ const tabIds = intent.tabIds.map(
12374
+ (tabId) => numberOrThrow(tabId, "each tab id")
12375
+ );
12376
+ return { mode: "ids", tabIds };
12377
+ }
12378
+ if (intent.mode === "conversation") {
12379
+ const sessionId2 = optionalConversationOrThrow(intent.sessionId);
12380
+ if (sessionId2 === null) {
12381
+ throw new Error("Closing a conversation's tabs needs a conversation id.");
12382
+ }
12383
+ return { mode: "conversation", sessionId: sessionId2 };
12384
+ }
12385
+ throw new Error("Unsupported close request.");
12386
+ }
12387
+ function optionalConversationOrThrow(value) {
12388
+ if (value === null || value === void 0) return null;
12389
+ if (typeof value !== "string") {
12390
+ throw new Error("A conversation id must be a string.");
12391
+ }
12392
+ const sessionId2 = value.trim();
12393
+ if (!sessionId2) throw new Error("A conversation id cannot be empty.");
12394
+ return sessionId2;
12395
+ }
12223
12396
  function contentRectOrNull(value) {
12224
12397
  if (value === null || value === void 0) return null;
12225
12398
  if (typeof value !== "object")
@@ -15604,6 +15777,122 @@ function registerDevDriverIPC(options) {
15604
15777
  };
15605
15778
  });
15606
15779
  }
15780
+ const PRUNE_NAMES = [
15781
+ "__pycache__",
15782
+ "node_modules",
15783
+ "dist",
15784
+ "build",
15785
+ ".git",
15786
+ ".venv"
15787
+ ];
15788
+ function excluded(name) {
15789
+ if (name.startsWith(".")) return true;
15790
+ return PRUNE_NAMES.includes(name);
15791
+ }
15792
+ function resolveUserPath$1(filePath, cwd, home) {
15793
+ if (filePath === "~") return home;
15794
+ if (filePath.startsWith("~/")) return path$1.join(home, filePath.slice(2));
15795
+ if (cwd && !filePath.startsWith("/")) {
15796
+ const base = cwd === "~" || cwd.startsWith("~/") ? resolveUserPath$1(cwd, void 0, home) : cwd;
15797
+ return path$1.join(base, filePath);
15798
+ }
15799
+ return filePath;
15800
+ }
15801
+ const DIRECTORY_SCAN_LIMIT = 2e3;
15802
+ const collator = new Intl.Collator();
15803
+ function compareCandidates(a, b) {
15804
+ const display = collator.compare(a.name, b.name);
15805
+ if (display !== 0) return display;
15806
+ return a.name < b.name ? -1 : a.name > b.name ? 1 : 0;
15807
+ }
15808
+ function offerCandidate(heap, candidate) {
15809
+ if (heap.length < DIRECTORY_SCAN_LIMIT) {
15810
+ heap.push(candidate);
15811
+ siftUp(heap, heap.length - 1);
15812
+ return true;
15813
+ }
15814
+ if (compareCandidates(candidate, heap[0]) >= 0) return false;
15815
+ heap[0] = candidate;
15816
+ siftDown(heap, 0);
15817
+ return true;
15818
+ }
15819
+ function siftUp(heap, from) {
15820
+ let index = from;
15821
+ while (index > 0) {
15822
+ const parent = index - 1 >> 1;
15823
+ if (compareCandidates(heap[index], heap[parent]) <= 0) return;
15824
+ [heap[parent], heap[index]] = [heap[index], heap[parent]];
15825
+ index = parent;
15826
+ }
15827
+ }
15828
+ function siftDown(heap, from) {
15829
+ let index = from;
15830
+ for (; ; ) {
15831
+ const left = index * 2 + 1;
15832
+ const right = left + 1;
15833
+ let largest = index;
15834
+ if (left < heap.length && compareCandidates(heap[left], heap[largest]) > 0)
15835
+ largest = left;
15836
+ if (right < heap.length && compareCandidates(heap[right], heap[largest]) > 0)
15837
+ largest = right;
15838
+ if (largest === index) return;
15839
+ [heap[largest], heap[index]] = [heap[index], heap[largest]];
15840
+ index = largest;
15841
+ }
15842
+ }
15843
+ async function listDirectory(dir) {
15844
+ try {
15845
+ const candidates = [];
15846
+ let overflow = false;
15847
+ const stream = await promises.opendir(dir);
15848
+ for await (const entry of stream) {
15849
+ if (excluded(entry.name)) continue;
15850
+ if (!offerCandidate(candidates, {
15851
+ name: entry.name,
15852
+ directory: entry.isDirectory(),
15853
+ link: entry.isSymbolicLink()
15854
+ }))
15855
+ overflow = true;
15856
+ }
15857
+ candidates.sort(compareCandidates);
15858
+ const kept = candidates.slice(0, DIRECTORY_ENTRY_LIMIT);
15859
+ const entries = kept.map((candidate) => ({
15860
+ name: candidate.name,
15861
+ directory: candidate.link ? fs.statSync(path$1.join(dir, candidate.name), {
15862
+ throwIfNoEntry: false
15863
+ })?.isDirectory() ?? false : candidate.directory
15864
+ }));
15865
+ return {
15866
+ dir,
15867
+ entries,
15868
+ // Either bound withheld something. `overflow` is the scan cap, which
15869
+ // means the kept set is the smallest ALPHABETICAL
15870
+ // `DIRECTORY_SCAN_LIMIT` names of a directory nobody finished reading —
15871
+ // the same rows a full sort-then-truncate would have answered with — and
15872
+ // the second term is the answer's own `DIRECTORY_ENTRY_LIMIT`-row limit.
15873
+ truncated: overflow || candidates.length > DIRECTORY_ENTRY_LIMIT
15874
+ };
15875
+ } catch (error) {
15876
+ return {
15877
+ dir,
15878
+ entries: [],
15879
+ truncated: false,
15880
+ error: error instanceof Error ? error.message : String(error)
15881
+ };
15882
+ }
15883
+ }
15884
+ function realPathOrNull(path2) {
15885
+ try {
15886
+ return fs.realpathSync(path2);
15887
+ } catch {
15888
+ return null;
15889
+ }
15890
+ }
15891
+ function outsideWorkspace(realTarget, realRoot) {
15892
+ if (realRoot === null) return void 0;
15893
+ if (realTarget === null) return true;
15894
+ return realTarget !== realRoot && !realTarget.startsWith(`${realRoot}${path$1.sep}`);
15895
+ }
15607
15896
  const LAUNCHER_MISSES_BEFORE_GONE = 2;
15608
15897
  function createLauncherWatch(input) {
15609
15898
  const threshold = input.missesBeforeGone ?? LAUNCHER_MISSES_BEFORE_GONE;
@@ -15790,6 +16079,25 @@ const READABLE_VERSION_PATTERN = /^v?\d+(\.\d+){0,3}([.-]?(?:a|b|c|rc|alpha|beta
15790
16079
  const isReadableVersion = (value) => typeof value === "string" && READABLE_VERSION_PATTERN.test(value.trim());
15791
16080
  const UP_TO_DATE_AFFIRMATION = "The application and server are up to date";
15792
16081
  const updateCheckAffirmation = (channels) => channels.app === "current" && channels.server === "current" ? UP_TO_DATE_AFFIRMATION : null;
16082
+ const readable = (value) => isReadableVersion(value) ? value.trim() : null;
16083
+ const serverChannelVerdict = ({
16084
+ installVersion,
16085
+ runningVersion,
16086
+ publishedVersion,
16087
+ isNewer
16088
+ }) => {
16089
+ const published = readable(publishedVersion);
16090
+ const running = readable(runningVersion);
16091
+ if (!published || !running)
16092
+ return { status: "unavailable", state: "unreadable" };
16093
+ const install = readable(installVersion);
16094
+ const subject = install ?? running;
16095
+ if (isNewer(published, subject))
16096
+ return { status: "available", state: "install-behind" };
16097
+ if (install && install !== running && isNewer(install, running))
16098
+ return { status: "restart-required", state: "restart-required" };
16099
+ return { status: "current", state: "current" };
16100
+ };
15793
16101
  const updateCheckVerdict = (channels) => ({
15794
16102
  app: channels.app,
15795
16103
  server: channels.server,
@@ -15859,6 +16167,37 @@ function stderrTail(stderr, maxLines = 4) {
15859
16167
  const lines = stderr.split("\n").map((line) => line.trim()).filter((line) => line.length > 0);
15860
16168
  return lines.slice(-maxLines).join("\n").slice(-600);
15861
16169
  }
16170
+ function readingOfHealth(health) {
16171
+ const result = health.result;
16172
+ return {
16173
+ version: result?.version ?? null,
16174
+ prefix: result?.prefix ? result.prefix : null,
16175
+ installKind: result?.install_kind ? result.install_kind : null
16176
+ };
16177
+ }
16178
+ function appOwnedInstallRoots(input) {
16179
+ const support = managedSupportRoot(input.home);
16180
+ return [
16181
+ path$1.join(support, "managed-python"),
16182
+ ...legacyVenvPaths(support),
16183
+ managedVenvPath(input),
16184
+ managedVenvPath({ ...input, packaged: !input.packaged })
16185
+ ];
16186
+ }
16187
+ function appOwnsInstallRoot(input, prefix) {
16188
+ return isWithinAnyRoot(appOwnedInstallRoots(input), prefix);
16189
+ }
16190
+ function newerReading(left, right, isNewer) {
16191
+ if (typeof left !== "string" || !isReadableVersion(left)) return right;
16192
+ if (typeof right !== "string" || !isReadableVersion(right)) return left;
16193
+ return isNewer(left, right) ? left : right;
16194
+ }
16195
+ function isWithinAnyRoot(roots, candidate) {
16196
+ return roots.some((root) => {
16197
+ const relative = path__namespace.relative(root, candidate);
16198
+ return !relative.startsWith("..") && !path__namespace.isAbsolute(relative);
16199
+ });
16200
+ }
15862
16201
  class UpdateService {
15863
16202
  mainWindow = null;
15864
16203
  isDevMode;
@@ -17724,10 +18063,24 @@ ${heal.removed.join("\n")}`,
17724
18063
  return this.backendService?.getBackendUrl() ?? this.backendUrl;
17725
18064
  }
17726
18065
  /**
17727
- * Get the installed backend version using the health API
17728
- * @returns Promise resolving to the installed version or null if not found
18066
+ * Read the server serving this app: its version, and the install it runs from.
18067
+ *
18068
+ * The install root travels with the version because the update check is a
18069
+ * statement about an INSTALL, and `/health` is the only thing that knows which
18070
+ * one is answering. The app used to classify the install it could name from the
18071
+ * `local-operator` shim on `PATH` and compare THAT against PyPI while reading
18072
+ * this version separately - so a machine serving the app from a three-release-old
18073
+ * environment was told "The application and server are up to date" beside a
18074
+ * Settings row naming the older build.
18075
+ *
18076
+ * @returns the reading, with a null version when nothing answered
17729
18077
  */
17730
- async getInstalledBackendVersion() {
18078
+ async readRunningBackend() {
18079
+ const notAnswered = {
18080
+ version: null,
18081
+ prefix: null,
18082
+ installKind: null
18083
+ };
17731
18084
  try {
17732
18085
  const backendUrl = this.liveBackendUrl();
17733
18086
  logger.info(
@@ -17747,21 +18100,22 @@ ${heal.removed.join("\n")}`,
17747
18100
  `Health API returned status ${response.status}`,
17748
18101
  LogFileType.UPDATE_SERVICE
17749
18102
  );
17750
- return null;
18103
+ return notAnswered;
17751
18104
  }
17752
18105
  const healthData = await response.json();
17753
- if (healthData.result?.version) {
18106
+ const reading = readingOfHealth(healthData);
18107
+ if (reading.version) {
17754
18108
  logger.info(
17755
- `Backend version from health API: ${healthData.result.version}`,
18109
+ `Backend version from health API: ${reading.version} (install root ${reading.prefix ?? "not reported"}, install kind ${reading.installKind ?? "not reported"})`,
17756
18110
  LogFileType.UPDATE_SERVICE
17757
18111
  );
17758
- return healthData.result.version;
18112
+ return reading;
17759
18113
  }
17760
18114
  logger.info(
17761
18115
  "Backend version not available in health response",
17762
18116
  LogFileType.UPDATE_SERVICE
17763
18117
  );
17764
- return "Unknown";
18118
+ return { ...reading, version: "Unknown" };
17765
18119
  } catch (error) {
17766
18120
  logger.error(
17767
18121
  "Error getting backend version from health API:",
@@ -17789,15 +18143,16 @@ ${heal.removed.join("\n")}`,
17789
18143
  `Alternative health API returned status ${response.status}`,
17790
18144
  LogFileType.UPDATE_SERVICE
17791
18145
  );
17792
- return null;
18146
+ return notAnswered;
17793
18147
  }
17794
18148
  const healthData = await response.json();
17795
- if (healthData.result?.version) {
18149
+ const reading = readingOfHealth(healthData);
18150
+ if (reading.version) {
17796
18151
  logger.info(
17797
- `Backend version from alternative health API: ${healthData.result.version}`,
18152
+ `Backend version from alternative health API: ${reading.version} (install root ${reading.prefix ?? "not reported"}, install kind ${reading.installKind ?? "not reported"})`,
17798
18153
  LogFileType.UPDATE_SERVICE
17799
18154
  );
17800
- return healthData.result.version;
18155
+ return reading;
17801
18156
  }
17802
18157
  } catch (altError) {
17803
18158
  logger.error(
@@ -17822,7 +18177,7 @@ ${heal.removed.join("\n")}`,
17822
18177
  `Backend version from pip: ${version}`,
17823
18178
  LogFileType.UPDATE_SERVICE
17824
18179
  );
17825
- return version;
18180
+ return { version, prefix: null, installKind: null };
17826
18181
  }
17827
18182
  } catch (pipError) {
17828
18183
  logger.error(
@@ -17832,9 +18187,19 @@ ${heal.removed.join("\n")}`,
17832
18187
  );
17833
18188
  }
17834
18189
  }
17835
- return null;
18190
+ return notAnswered;
17836
18191
  }
17837
18192
  }
18193
+ /**
18194
+ * The version the server serving this app reports, or null when it did not.
18195
+ *
18196
+ * Kept beside `readRunningBackend` rather than replaced by it: four callers ask
18197
+ * only this question (the skew notices, the by-hand panel), and none of them may
18198
+ * start judging an install because a second reading became available.
18199
+ */
18200
+ async getInstalledBackendVersion() {
18201
+ return (await this.readRunningBackend()).version;
18202
+ }
17838
18203
  /**
17839
18204
  * Get the latest version from PyPI
17840
18205
  * @returns Promise resolving to the latest version string or null if unable to fetch
@@ -17876,6 +18241,76 @@ ${heal.removed.join("\n")}`,
17876
18241
  * @param silent - Whether to suppress notifications on no update
17877
18242
  * @returns Promise resolving to update info or null if no update is available
17878
18243
  */
18244
+ /**
18245
+ * Read the install that is serving this app, from the root `/health` named.
18246
+ *
18247
+ * The version comes from the dist-info at that root
18248
+ * (`resolveDistributionMarkers`, the same read the global classification uses),
18249
+ * which is shell-free and needs no interpreter: `pip show` inside that
18250
+ * environment would spawn the very server's python, and a second process is not
18251
+ * worth a number that is already on disk beside the code.
18252
+ *
18253
+ * A root the server did not name answers with nulls, and the check treats that
18254
+ * as "the serving install could not be identified" rather than falling back to
18255
+ * an install the app can name - which is the reported defect.
18256
+ */
18257
+ readServingInstall(reading) {
18258
+ const prefix = reading.prefix;
18259
+ if (!prefix) {
18260
+ return {
18261
+ prefix: null,
18262
+ version: null,
18263
+ installKind: reading.installKind,
18264
+ appOwned: false
18265
+ };
18266
+ }
18267
+ let version = null;
18268
+ try {
18269
+ const markers = resolveDistributionMarkers(prefix);
18270
+ version = (markers.editable || markers.sourceRef) && isReadableVersion(reading.version) ? reading.version : markers.version;
18271
+ } catch (error) {
18272
+ logger.warn(
18273
+ `Could not read the install at ${prefix}: ${error.message}`,
18274
+ LogFileType.UPDATE_SERVICE
18275
+ );
18276
+ }
18277
+ return {
18278
+ prefix,
18279
+ version,
18280
+ installKind: reading.installKind,
18281
+ appOwned: appOwnsInstallRoot(this.ownedInstallInput(), prefix)
18282
+ };
18283
+ }
18284
+ /**
18285
+ * This instance's own ownership triple.
18286
+ *
18287
+ * `packaged` is the build this process IS, not the environment it is asking
18288
+ * about: a packaged build pointed at a dev server still owns the environment
18289
+ * its bundle carries, and the sibling name is in the root list for the
18290
+ * question the other way round.
18291
+ */
18292
+ ownedInstallInput() {
18293
+ return {
18294
+ platform: process.platform,
18295
+ home: electron.app.getPath("home"),
18296
+ appDataPath: electron.app.getPath("appData"),
18297
+ packaged: electron.app.isPackaged
18298
+ };
18299
+ }
18300
+ /**
18301
+ * The identity of the install serving this app, for the classification the plan
18302
+ * needs when that install is not one the app owns.
18303
+ *
18304
+ * Read from the console script INSIDE the reported prefix, so
18305
+ * `classifyGlobalInstall` sees the install that answered rather than whichever
18306
+ * one the shim on `PATH` happens to point at - the two differ exactly on the
18307
+ * machines this check exists for. Null when the prefix carries no console
18308
+ * script, which is "we could not classify it" rather than a guess.
18309
+ */
18310
+ servingInstallIdentity(prefix) {
18311
+ const script = process.platform === "win32" ? path$1.join(prefix, "Scripts", "local-operator.exe") : path$1.join(prefix, "bin", "local-operator");
18312
+ return fs.existsSync(script) ? readInstallIdentity(script) : null;
18313
+ }
17879
18314
  /**
17880
18315
  * Decide what the backend update prompt may offer for a startup mode.
17881
18316
  *
@@ -17889,13 +18324,34 @@ ${heal.removed.join("\n")}`,
17889
18324
  * is the same question: a server the app attaches to but does not own was
17890
18325
  * still being told to run pip, three lines below the code that had already
17891
18326
  * learned better (reviews U4, D4).
18327
+ *
18328
+ * Both of those modes resolve the plan for ONE install: the install that is
18329
+ * actually serving this app, as `/health` reports it. Classifying a global
18330
+ * install by name while a different one serves the user is how the check came to
18331
+ * affirm a version the panel's own row contradicted.
17892
18332
  */
17893
- async resolveBackendUpdatePlan(startupMode) {
18333
+ async resolveBackendUpdatePlan(startupMode, serving) {
17894
18334
  if (startupMode === LocalOperatorStartupMode.EXISTING_SERVER || startupMode === LocalOperatorStartupMode.GLOBAL_INSTALL) {
17895
- const identity = await this.resolveInstallIdentity();
18335
+ if (serving.appOwned) {
18336
+ const detail = `The server serving this app runs from Local Operator's own managed environment at ${serving.prefix}, which the app owns rather than a package manager (the backend reports it as install kind "${serving.installKind || "not reported"}")${serving.version ? `, at version ${serving.version}` : " and its installed version could not be read"}.`;
18337
+ logger.info(
18338
+ `External backend install (${startupMode}) runs from the app's own managed environment at ${serving.prefix}; no installer owns it, so the app names no command`,
18339
+ LogFileType.UPDATE_SERVICE
18340
+ );
18341
+ return {
18342
+ canManageUpdate: false,
18343
+ updateCommand: "",
18344
+ remedy: "This server is running from Local Operator's own managed environment, which no package manager owns - so there is no terminal command that can update it correctly. The app can only update a server it started itself: stop this one, then start Local Operator again and let it start its own.",
18345
+ detail,
18346
+ sourceBuild: false,
18347
+ appOwned: true,
18348
+ installedInstallVersion: serving.version
18349
+ };
18350
+ }
18351
+ const identity = (serving.prefix ? this.servingInstallIdentity(serving.prefix) : null) ?? await this.resolveInstallIdentity();
17896
18352
  const plan = resolveGlobalInstallPlan({ identity });
17897
18353
  logger.info(
17898
- `External backend install (${startupMode}): ${plan.detail}; remedy is \`${plan.updateCommand || "nothing the app can name"}\`; install version ${identity.version ?? "unreadable"}`,
18354
+ `External backend install (${startupMode}): ${plan.detail}; remedy is \`${plan.updateCommand || "nothing the app can name"}\`; install version ${serving.version ?? identity.version ?? "unreadable"}`,
17899
18355
  LogFileType.UPDATE_SERVICE
17900
18356
  );
17901
18357
  return {
@@ -17904,7 +18360,8 @@ ${heal.removed.join("\n")}`,
17904
18360
  remedy: plan.remedy,
17905
18361
  detail: plan.detail,
17906
18362
  sourceBuild: plan.sourceBuild,
17907
- installedInstallVersion: identity.version ?? null
18363
+ appOwned: false,
18364
+ installedInstallVersion: serving.version ?? identity.version ?? null
17908
18365
  };
17909
18366
  }
17910
18367
  return {
@@ -17913,7 +18370,20 @@ ${heal.removed.join("\n")}`,
17913
18370
  remedy: "Updating the server will improve AI functionality.",
17914
18371
  detail: `The app started this server itself (${startupMode}).`,
17915
18372
  sourceBuild: false,
17916
- installedInstallVersion: null
18373
+ /*
18374
+ * `false` although this IS the app's own environment: the flag answers
18375
+ * "may the manual panel tell the reader nothing here can move it", and on
18376
+ * this arm the app moves it itself with the button above. It is the
18377
+ * app-owned MANUAL arm that owns the sentence (review round 1, UX U1).
18378
+ */
18379
+ appOwned: false,
18380
+ /*
18381
+ * The app-managed environment's own on-disk version when the server named
18382
+ * it, which is what an update moves: the check compares against the same
18383
+ * install it would rewrite, so a landed update with a daemon still on the
18384
+ * old build is visible as exactly that rather than as "nothing newer".
18385
+ */
18386
+ installedInstallVersion: serving.version
17917
18387
  };
17918
18388
  }
17919
18389
  /**
@@ -18026,11 +18496,17 @@ ${heal.removed.join("\n")}`,
18026
18496
  );
18027
18497
  return { status: "unavailable", info: null };
18028
18498
  }
18029
- const plan = await this.resolveBackendUpdatePlan(startupMode);
18030
- const runningVersion = await this.getInstalledBackendVersion();
18499
+ const running = await this.readRunningBackend();
18500
+ const runningVersion = running.version;
18501
+ const serving = this.readServingInstall(running);
18502
+ const plan = await this.resolveBackendUpdatePlan(startupMode, serving);
18031
18503
  const latestVersion = await this.getLatestPypiVersion();
18032
18504
  if (latestVersion) this.lastPublishedBackendVersion = latestVersion;
18033
- const installVersion = plan.installedInstallVersion;
18505
+ const installVersion = serving.prefix ? plan.installedInstallVersion : newerReading(
18506
+ plan.installedInstallVersion,
18507
+ runningVersion,
18508
+ (candidate, subject) => this.isNewerVersion(candidate, subject)
18509
+ );
18034
18510
  const installedVersion = installVersion && isReadableVersion(installVersion) ? installVersion : runningVersion;
18035
18511
  if (!installedVersion || !latestVersion) {
18036
18512
  logger.error(
@@ -18080,15 +18556,27 @@ ${heal.removed.join("\n")}`,
18080
18556
  }
18081
18557
  return { status: "unavailable", info: null };
18082
18558
  }
18083
- const shouldUpdate = this.isNewerVersion(latestVersion, installedVersion);
18559
+ const channel = serverChannelVerdict({
18560
+ installVersion,
18561
+ runningVersion,
18562
+ publishedVersion: latestVersion,
18563
+ isNewer: (candidate, subject) => this.isNewerVersion(candidate, subject)
18564
+ });
18565
+ const shouldUpdate = channel.status === "available";
18084
18566
  logger.info(
18085
- `Install on disk reports: ${installVersion ?? "no reading"}, running backend reports: ${runningVersion ?? "no reading"}, Latest: ${latestVersion}, Update needed: ${shouldUpdate}, Startup mode: ${startupMode}`,
18567
+ `Install on disk reports: ${installVersion ?? "no reading"} (at ${serving.prefix ?? "an install the server did not name"}), running backend reports: ${runningVersion ?? "no reading"}, Latest: ${latestVersion}, Update needed: ${shouldUpdate}, Server channel: ${channel.status} (${channel.state}), Startup mode: ${startupMode}`,
18086
18568
  LogFileType.UPDATE_SERVICE
18087
18569
  );
18088
18570
  logger.info(
18089
18571
  `Installed backend version: ${installedVersion}, Latest: ${latestVersion}, Update needed: ${shouldUpdate}, Startup mode: ${startupMode}`,
18090
18572
  LogFileType.UPDATE_SERVICE
18091
18573
  );
18574
+ if (channel.state === "restart-required") {
18575
+ logger.info(
18576
+ `The install is current (${installedVersion}) and the server serving this app runs ${runningVersion}; a restart of that server picks the installed build up.`,
18577
+ LogFileType.UPDATE_SERVICE
18578
+ );
18579
+ }
18092
18580
  if (shouldUpdate) {
18093
18581
  logger.info(
18094
18582
  `New backend version available: ${latestVersion} (installed: ${installedVersion})`,
@@ -18100,6 +18588,7 @@ ${heal.removed.join("\n")}`,
18100
18588
  runningVersion,
18101
18589
  updateCommand: plan.updateCommand,
18102
18590
  canManageUpdate: plan.canManageUpdate,
18591
+ appOwned: plan.appOwned,
18103
18592
  startupMode,
18104
18593
  remedy: plan.remedy,
18105
18594
  detail: plan.detail,
@@ -18155,7 +18644,7 @@ ${heal.removed.join("\n")}`,
18155
18644
  restartable: this.backendIsAppOwned()
18156
18645
  });
18157
18646
  }
18158
- return { status: "current", info: null };
18647
+ return { status: channel.status, info: null };
18159
18648
  } catch (error) {
18160
18649
  logger.error(
18161
18650
  "Error checking for backend updates:",
@@ -18549,6 +19038,8 @@ ${tail}` : ""}`,
18549
19038
  return false;
18550
19039
  }
18551
19040
  const startupMode = this.backendService.getStartupMode();
19041
+ const running = await this.readRunningBackend();
19042
+ const serving = this.readServingInstall(running);
18552
19043
  switch (startupMode) {
18553
19044
  case LocalOperatorStartupMode.NOT_STARTED:
18554
19045
  logger.info(
@@ -18565,7 +19056,10 @@ ${tail}` : ""}`,
18565
19056
  return false;
18566
19057
  case LocalOperatorStartupMode.EXISTING_SERVER:
18567
19058
  case LocalOperatorStartupMode.GLOBAL_INSTALL: {
18568
- const plan = await this.resolveBackendUpdatePlan(startupMode);
19059
+ const plan = await this.resolveBackendUpdatePlan(
19060
+ startupMode,
19061
+ serving
19062
+ );
18569
19063
  const target2 = targetVersion ?? this.lastPublishedBackendVersion ?? null;
18570
19064
  if (plan.canManageUpdate) {
18571
19065
  return await this.updateGlobalInstall(
@@ -18578,7 +19072,7 @@ ${tail}` : ""}`,
18578
19072
  `Cannot manage the update for ${startupMode}: ${plan.detail}`,
18579
19073
  LogFileType.UPDATE_SERVICE
18580
19074
  );
18581
- const installed = await this.getInstalledBackendVersion();
19075
+ const installed = running.version;
18582
19076
  this.sendToRenderer("backend-update-manual-required", {
18583
19077
  message: plan.remedy,
18584
19078
  command: plan.updateCommand,
@@ -18587,7 +19081,14 @@ ${tail}` : ""}`,
18587
19081
  currentVersion: installed && installed !== "Unknown" ? installed : null,
18588
19082
  installVersion: plan.installedInstallVersion,
18589
19083
  runningVersion: installed,
18590
- sourceBuild: plan.sourceBuild
19084
+ sourceBuild: plan.sourceBuild,
19085
+ /*
19086
+ * The second door into the same state, and it needs the same reading: this
19087
+ * panel renders `ManualRemedyNote` too, and on an app-owned install the
19088
+ * closing line it would otherwise append asks for a press that cannot move
19089
+ * the environment (review round 1, UX U1).
19090
+ */
19091
+ appOwned: plan.appOwned
18591
19092
  });
18592
19093
  return false;
18593
19094
  }
@@ -19227,14 +19728,7 @@ class ViewerRecordPublisher {
19227
19728
  }
19228
19729
  }
19229
19730
  const BASE64_FILE_EXTENSIONS = ["csv", "tsv", "xls", "xlsx", "ods"];
19230
- const resolveUserPath = (filePath, cwd) => {
19231
- if (filePath === "~") return electron.app.getPath("home");
19232
- if (filePath.startsWith("~/"))
19233
- return path$1.join(electron.app.getPath("home"), filePath.slice(2));
19234
- if (cwd && !filePath.startsWith("/"))
19235
- return path$1.join(cwd.startsWith("~/") ? resolveUserPath(cwd) : cwd, filePath);
19236
- return filePath;
19237
- };
19731
+ const resolveUserPath = (filePath, cwd) => resolveUserPath$1(filePath, cwd, electron.app.getPath("home"));
19238
19732
  const windowLaunch = resolveWindowLaunchPlan({
19239
19733
  env: launchEnv,
19240
19734
  argv: process.argv,
@@ -20051,18 +20545,28 @@ electron.app.whenReady().then(async () => {
20051
20545
  "probe-files",
20052
20546
  async (_, paths, cwd) => {
20053
20547
  const asked = Array.isArray(paths) ? paths.filter((path2) => typeof path2 === "string") : [];
20548
+ const realRoot = cwd ? realPathOrNull(resolveUserPath(cwd)) : null;
20054
20549
  return asked.slice(0, MAX_PROBE_PATHS).map((input) => {
20055
20550
  let resolved = input;
20056
20551
  try {
20057
20552
  resolved = resolveUserPath(input, cwd);
20058
20553
  const stat = fs.statSync(resolved, { throwIfNoEntry: false });
20554
+ const exists = stat !== void 0;
20059
20555
  return {
20060
20556
  input,
20061
20557
  resolved,
20062
- exists: stat !== void 0,
20558
+ exists,
20063
20559
  isFile: stat?.isFile() ?? false,
20064
20560
  sizeBytes: stat?.isFile() ? stat.size : null,
20065
- mtimeMs: stat?.isFile() ? stat.mtimeMs : null
20561
+ mtimeMs: stat?.isFile() ? stat.mtimeMs : null,
20562
+ /*
20563
+ * The containment verdict, and the reason it is asked ONLY of a path
20564
+ * that exists: the one caller that reads it (the composer's `@` chip)
20565
+ * asks the question about a candidate reference, so a path that is not
20566
+ * there costs no second syscall — and a miss is the common case on the
20567
+ * keystroke path this runs on.
20568
+ */
20569
+ outsideWorkspace: exists ? outsideWorkspace(realPathOrNull(resolved), realRoot) : void 0
20066
20570
  };
20067
20571
  } catch (error) {
20068
20572
  return {
@@ -20078,6 +20582,13 @@ electron.app.whenReady().then(async () => {
20078
20582
  });
20079
20583
  }
20080
20584
  );
20585
+ electron.ipcMain.handle(
20586
+ "list-directory",
20587
+ async (_, dir, cwd) => {
20588
+ const target2 = typeof dir === "string" && dir.length > 0 ? dir : ".";
20589
+ return await listDirectory(resolveUserPath(target2, cwd));
20590
+ }
20591
+ );
20081
20592
  electron.ipcMain.handle(
20082
20593
  "read-file-bytes",
20083
20594
  async (_, filePath, maxBytes = MAX_FILE_READ_BYTES) => {