summrise-agent 1.2.511 → 1.2.512

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.
package/README.md CHANGED
@@ -22,6 +22,14 @@ npm i -g https://agent.saisi.online/summrise-agent/summrise-agent-latest.tgz
22
22
  summrise setup --reg-key <key-from-console> # --reg-key is optional (local mode works without it)
23
23
  ```
24
24
 
25
+ **ON A DEVICE THE INSTALLER SET UP, A HAND INSTALL MUST PASS `--prefix`.** `agent/deploy/summrise-online-setup.ps1`
26
+ does exactly that — `npm install -g --prefix $NpmGlobal $tgz`, where `$NpmGlobal` is `<install dir>\components\npm-global`
27
+ ("Layout v2"; it was `tools\npm-global` before that) — and puts that directory on `PATH`. **A bare `npm i -g` lands in
28
+ npm's DEFAULT global prefix instead**, which is not where the running agent looks, so the install succeeds and nothing
29
+ changes. The layout is the one thing to keep in step, and `summrise update` prints the right prefix for an existing
30
+ device rather than making anyone derive it.
31
+
32
+
25
33
  **Run `setup` from an elevated PowerShell.** The install directory is under `Program Files` and the registry key is
26
34
  `HKLM`, so a normal shell fails with `EPERM: operation not permitted, mkdir 'C:\Program Files\Summrise\scripts'` —
27
35
  a stack trace where the useful sentence is "run it as administrator".
@@ -47,6 +55,11 @@ npm i -g --prefix (Split-Path (Get-Command summrise).Source) https://agent.saisi
47
55
  summrise update
48
56
  ```
49
57
 
58
+ **YOU DO NOT HAVE TO COMPUTE THAT PREFIX — THE CLI PRINTS IT.** When `summrise update` refuses because the running
59
+ CLI is older than the release channel, it answers with the exact command for THIS device, prefix included, computed from
60
+ where the CLI is running (`Split-Path (Get-Command summrise).Source`, asked of the process that IS that binary). The
61
+ `--prefix` in the block above is what it prints; the paragraph below is why it matters.
62
+
50
63
  **The `--prefix` is not optional on a device that is already set up.** Plain
51
64
  `npm i -g <url>` installs into npm's DEFAULT global prefix, which is not where
52
65
  `summrise` lives when the agent runs as SYSTEM. On a real device the two prefixes
package/bin/summrise.exe CHANGED
Binary file
package/package.json CHANGED
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "name": "summrise-agent",
3
- "version": "1.2.511",
3
+ "version": "1.2.512",
4
4
  "description": "Summrise Agent — Windows device MCP server (terminal/SSH/serial) + panel. Self-hosted npm distribution.",
5
- "//build": "唯一构建路径:在本目录执行 npm run build。它是自包含的:经 npm exec 按需拉取 typescript@5,无需预装任何 node_modules(干净检出可跑;旧脚本引用 ../summrise-desktop-electron/node_modules/.bin/tsc,干净检出必 ENOENT)。必须用 npm exec --package … -- tsc 的形式:npx 会吃掉传给 tsc 的 -p/--outDir 等参数(round-435:npx 写法从未真正跑通过)。 LANDING 4b: the CLI IS A RUST BINARY NOW (bin/summrise.exe, built by `cargo xwin build -p summrise-cli` and staged by scripts/build.sh) — nothing here compiles it, and src/summrise.ts + bin/summrise.js are DELETED. This script therefore only compiles the electron TS (--noCheck pure emit, same as release.yml) and syncs both src/*.js copies. LANDING 6: the desktop shell's URL/certificate policy is Rust compiled to wasm. summrise_url_policy.js and summrise_url_policy_bg.wasm are NOT built here — agent/summrise-url-policy/build.sh produces them (wasm-pack) and they are committed — this script only COPIES the committed pair into the package's own summrise-desktop-electron/src/, the way it does for main.js/preload.js, so that npm pack carries what the shell requires.",
5
+ "//build": "THE ONLY BUILD PATH: run npm run build in this directory. It is self-contained: it pulls typescript@5 on demand through npm exec, with no preinstalled node_modules (so it runs in a clean checkout; the old script referenced ../summrise-desktop-electron/node_modules/.bin/tsc, which is always ENOENT in a clean checkout). It MUST use the `npm exec --package … -- tsc` form: npx swallows the -p/--outDir arguments passed to tsc (round-435: the npx form never actually ran). LANDING 4b: the CLI IS A RUST BINARY NOW (bin/summrise.exe, built by `cargo xwin build -p summrise-cli` and staged by scripts/build.sh) — nothing here compiles it, and src/summrise.ts + bin/summrise.js are DELETED. This script therefore only compiles the electron TS (--noCheck pure emit, same as release.yml) and syncs both src/*.js copies. LANDING 6: the desktop shell's URL/certificate policy is Rust compiled to wasm. summrise_url_policy.js and summrise_url_policy_bg.wasm are NOT built here — agent/summrise-url-policy/build.sh produces them (wasm-pack) and they are committed — this script only COPIES the committed pair into the package's own summrise-desktop-electron/src/, the way it does for main.js/preload.js, so that npm pack carries what the shell requires.",
6
6
  "bin": {
7
7
  "summrise": "bin/summrise.exe"
8
8
  },
@@ -12,6 +12,8 @@
12
12
  "summrise-agent.exe",
13
13
  "summrise-launch.exe",
14
14
  "summrise-desktop-electron/src/main.js",
15
+ "summrise-desktop-electron/src/embedded-slot.js",
16
+ "summrise-desktop-electron/src/browser-sessions.js",
15
17
  "summrise-desktop-electron/src/preload.js",
16
18
  "summrise-desktop-electron/src/summrise_url_policy.js",
17
19
  "summrise-desktop-electron/src/summrise_url_policy_bg.wasm",
@@ -28,6 +30,6 @@
28
30
  ],
29
31
  "license": "MIT",
30
32
  "scripts": {
31
- "build": "npm exec --yes --package typescript@5.9.3 -- tsc -p ../summrise-desktop-electron/tsconfig.json --outDir /tmp/summrise-electron-fresh --noCheck && cp /tmp/summrise-electron-fresh/main.js ../summrise-desktop-electron/src/main.js && cp /tmp/summrise-electron-fresh/preload.js ../summrise-desktop-electron/src/preload.js && cp ../summrise-desktop-electron/src/main.js ../summrise-desktop-electron/src/preload.js ../summrise-desktop-electron/src/summrise_url_policy.js ../summrise-desktop-electron/src/summrise_url_policy_bg.wasm ../summrise-desktop-electron/src/summrise_shell_policy.js ../summrise-desktop-electron/src/summrise_shell_policy_bg.wasm ./summrise-desktop-electron/src/ && rm -rf /tmp/summrise-electron-fresh"
33
+ "build": "npm exec --yes --package typescript@5.9.3 -- tsc -p ../summrise-desktop-electron/tsconfig.json --outDir /tmp/summrise-electron-fresh --noCheck && cp /tmp/summrise-electron-fresh/main.js ../summrise-desktop-electron/src/main.js && cp /tmp/summrise-electron-fresh/embedded-slot.js ../summrise-desktop-electron/src/embedded-slot.js && cp /tmp/summrise-electron-fresh/browser-sessions.js ../summrise-desktop-electron/src/browser-sessions.js && cp /tmp/summrise-electron-fresh/preload.js ../summrise-desktop-electron/src/preload.js && cp ../summrise-desktop-electron/src/main.js ../summrise-desktop-electron/src/embedded-slot.js ../summrise-desktop-electron/src/browser-sessions.js ../summrise-desktop-electron/src/preload.js ../summrise-desktop-electron/src/summrise_url_policy.js ../summrise-desktop-electron/src/summrise_url_policy_bg.wasm ../summrise-desktop-electron/src/summrise_shell_policy.js ../summrise-desktop-electron/src/summrise_shell_policy_bg.wasm ./summrise-desktop-electron/src/ && rm -rf /tmp/summrise-electron-fresh"
32
34
  }
33
35
  }
Binary file
@@ -0,0 +1,139 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.browserSessionsBind = browserSessionsBind;
4
+ exports.browserOpen = browserOpen;
5
+ exports.browserClose = browserClose;
6
+ exports.browserList = browserList;
7
+ exports.browserCloseAll = browserCloseAll;
8
+ // ── Browser-session windows: the cluster that owns them (round-247) ────────────────────────────
9
+ // `main.ts`'s browser-session section, moved out whole: the three operations an AI drives over IPC or
10
+ // the loopback control server (`browserOpen`, `browserClose`, `browserList`), the session table they
11
+ // read and write, and the shutdown sweep that closes it. What those operations DECIDE is not here, and
12
+ // never was: the reuse/evict plan is `agent/summrise-shell-policy`'s (`planBrowserOpen`), the window id
13
+ // is its `browserId`, the CDP endpoint they advertise is its `cdpEndpoint`, and the cap is its
14
+ // `MAX_BROWSER_WINDOWS`. This file applies those answers to real `BrowserWindow`s.
15
+ //
16
+ // ── WHY THIS IS A FILE OF ITS OWN, AND THE THREE THINGS IT DOES NOT OWN ────────────────────────
17
+ // The survey that found it measured that nothing here touches `tray`, `Tray`, `Menu`, `mainWindow`,
18
+ // `ipcMain` or the `win` variable — and it also measured what a survey of NAMES misses: the section has
19
+ // THREE dependencies on `main.ts`, and they are why this file takes a seam rather than an import.
20
+ // * the ONE load door (`loadTarget`) — `main.ts`'s single call site of the URL policy's
21
+ // `sanitizeBrowserUrl`. Every URL a session window opens must go through it, never around it.
22
+ // * the window icon (`windowIcon`) — the shell's own resolution of `windowIconName()` against the
23
+ // install tree, which is `main.ts`'s business (it records what it resolved for
24
+ // `/api/shell/icon-status`) and is shared with the main window.
25
+ // * the window cap (`maxBrowserWindows`) — `K.MAX_BROWSER_WINDOWS`, the boot table `main.ts` parses
26
+ // once from the policy crate. One number, fixed at boot: a VALUE, not a getter.
27
+ // Importing any of the three from `main.ts` would make `main.ts` require this module and this module
28
+ // require `main.ts`: a cycle whose failure mode is a half-initialised module rather than an error. So
29
+ // `main.ts` binds all three ONCE (`browserSessionsBind`) and this module reads them through `deps()`.
30
+ //
31
+ // THE IPC REGISTRATIONS DELIBERATELY DID NOT MOVE WITH IT. They go through `main.ts`'s ONE door
32
+ // (`ipcHandle`, which applies the frame check and is the file's only `ipcMain.handle` call), and the
33
+ // channel set is pinned against the preload's by `test/ipc-door.test.mjs`. A handler registered here
34
+ // would be a second door, so `main.ts` keeps the three `ipcHandle("browser-session:…")` lines and calls
35
+ // in — and with them the THREE other call sites the section always had: the loopback control server's
36
+ // `browser-open`/`browser-close`/`browser-list` routes (the SPA in a plain browser) and the main
37
+ // window's `setWindowOpenHandler`, which routes a `target=_blank` from the panel to a sandboxed session
38
+ // instead of a preload-bearing window.
39
+ const electron_1 = require("electron");
40
+ const summrise_shell_policy_1 = require("./summrise_shell_policy");
41
+ /** The CDP port every session window is advertised on. Read from the SAME boot table `main.ts` parses,
42
+ * rather than bound: it is a policy constant, not host state — `main.ts` does not own 9333, it passes
43
+ * it to `--remote-debugging-port` and `agent/summrise-shell-policy`'s `boot.rs` defines it. Bound state
44
+ * is for what the host OWNS; a second reader of the crate's table is not a second decision. */
45
+ const CDP_PORT = JSON.parse((0, summrise_shell_policy_1.shellConstants)()).CDP_PORT;
46
+ let bound = null;
47
+ /** Called ONCE by `main.ts`, before any `browser-session:*` channel can be invoked. */
48
+ function browserSessionsBind(host) { bound = host; }
49
+ function deps() {
50
+ // LOUD, not silent: an unbound host would otherwise open a window on a fallback URL, with no icon and
51
+ // no cap — the failures a reader would have to reconstruct from a browser that went to the wrong page.
52
+ if (!bound)
53
+ throw new Error("browser-sessions: browserSessionsBind() was never called — main.ts binds the shell's load door, its window icon and its session cap at startup");
54
+ return bound;
55
+ }
56
+ const browserSessions = new Map();
57
+ /** Initial target per browser window — reuse matching uses this instead of
58
+ * webContents.getURL() (which lags during navigation; stage-n). */
59
+ const browserTargets = new Map();
60
+ /** Open a browser-session window on a decided load target (loadTarget() is the
61
+ * ONE load door — the scheme policy lives there). */
62
+ function browserOpen(url) {
63
+ const target = deps().loadTarget(url);
64
+ // stage-n: reuse an existing window on the same URL instead of stacking duplicates (AI-driven
65
+ // browsing opens/closes sessions repeatedly). Match against the window's INITIAL target (stored at
66
+ // open) — webContents.getURL() lags during navigation (returns about:blank while loading), so a
67
+ // same-URL reopen right after the first open would otherwise miss the reuse and stack a duplicate.
68
+ //
69
+ // THE PLAN IS THE POLICY CRATE'S, in one call: which window to reuse (first non-destroyed match, in
70
+ // INSERTION order), and — when the cap is reached — which one to evict (the OLDEST, not the least
71
+ // recently used). This file only reads the session table and applies the answer. The `destroyed` flag
72
+ // travels with the row because a destroyed window is skipped for reuse while still COUNTING against
73
+ // the cap, which is what `browserSessions.size` did.
74
+ const plan = JSON.parse((0, summrise_shell_policy_1.planBrowserOpen)(JSON.stringify([...browserSessions].map(([id, bw]) => ({ id, target: browserTargets.get(id) ?? "", destroyed: bw.isDestroyed() }))), target, deps().maxBrowserWindows));
75
+ if (plan.reuse) {
76
+ const existing = browserSessions.get(plan.reuse);
77
+ if (existing) {
78
+ if (existing.isMinimized())
79
+ existing.restore();
80
+ existing.show();
81
+ existing.focus();
82
+ return { ok: true, id: plan.reuse, url: target, cdp: (0, summrise_shell_policy_1.cdpEndpoint)(CDP_PORT) };
83
+ }
84
+ }
85
+ if (plan.evict)
86
+ browserClose(plan.evict);
87
+ const id = (0, summrise_shell_policy_1.browserId)(Date.now());
88
+ const bw = new electron_1.BrowserWindow({
89
+ width: 1100, height: 750, title: `Summrise Browser — ${target}`,
90
+ ...(deps().windowIcon() ? { icon: deps().windowIcon() } : {}),
91
+ // review #6/#7: these windows load ARBITRARY internet pages. contextIsolation
92
+ // on + nodeIntegration off + NO preload = zero Node/bridge surface (the
93
+ // default; made explicit). Popups are denied (they would otherwise spawn
94
+ // unmanaged windows outside the MAX_BROWSER_WINDOWS cap).
95
+ webPreferences: { contextIsolation: true, nodeIntegration: false, sandbox: true },
96
+ });
97
+ bw.webContents.setWindowOpenHandler(() => ({ action: "deny" }));
98
+ bw.loadURL(target);
99
+ bw.on("closed", () => { browserSessions.delete(id); browserTargets.delete(id); });
100
+ // stage-n: the window title starts as "Summrise Browser — <target>" but a
101
+ // site's own <title> is more useful after load (and after in-page
102
+ // navigation); follow it so window managers/taskbar show the real page.
103
+ bw.webContents.on("page-title-updated", (_e, title) => {
104
+ if (!bw.isDestroyed() && title)
105
+ bw.setTitle(`Summrise Browser — ${title}`);
106
+ });
107
+ browserSessions.set(id, bw);
108
+ browserTargets.set(id, target);
109
+ return { ok: true, id, url: target, cdp: (0, summrise_shell_policy_1.cdpEndpoint)(CDP_PORT) };
110
+ }
111
+ function browserClose(id) {
112
+ const bw = browserSessions.get(id || "");
113
+ // review #g: close() is vetoable by a page's beforeunload — an arbitrary
114
+ // remote page could refuse eviction, so the MAX_BROWSER_WINDOWS cap never
115
+ // freed and browserOpen stalled. destroy() is unconditional (these are
116
+ // disposable, headless-driven windows).
117
+ if (bw) {
118
+ bw.destroy();
119
+ browserSessions.delete(id || "");
120
+ browserTargets.delete(id || "");
121
+ }
122
+ return { ok: true };
123
+ }
124
+ function browserList() {
125
+ const list = [...browserSessions.entries()].map(([id, bw]) => ({ id, url: bw.webContents.getURL() }));
126
+ return { ok: true, sessions: list, cdp: (0, summrise_shell_policy_1.cdpEndpoint)(CDP_PORT) };
127
+ }
128
+ /** stage-n: close every browser-session window explicitly — app.quit()
129
+ * tears down the main window but independent BrowserWindows with pending
130
+ * beforeunload handlers can stall or leak on exit. The table is THIS file's state, so the sweep over it
131
+ * is too: `main.ts`'s `before-quit` handler calls in, the way it calls the three operations above. */
132
+ function browserCloseAll() {
133
+ for (const [id, bw] of browserSessions) {
134
+ if (!bw.isDestroyed())
135
+ bw.destroy();
136
+ browserSessions.delete(id);
137
+ browserTargets.delete(id);
138
+ }
139
+ }
@@ -0,0 +1,347 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.embeddedSlot = embeddedSlot;
4
+ // ── ONE EMBEDDED SLOT, INSTANTIATED TWICE ──────────────────────────────────────────────────────
5
+ // The shell embeds TWO native `WebContentsView`s over the SPA's placeholders: the real-render BROWSER
6
+ // view (an internet browser an AI also drives over CDP) and the DSH view (the operator's own harness on
7
+ // loopback). They arrived here as two modules — `embedded-view.ts` and `dsh-view.ts` — that a
8
+ // function-by-function comparison (`docs/agents/ideas.md`, round 111) measured to be near-copies of one
9
+ // pattern: the same eight twinned operations (`bind`/`deps`/`ensure`/`place`/`navigate`/`reload`/
10
+ // `recover`/`state`), the same three slot-state bindings, and ELEVEN occurrences of
11
+ // `setVisible`/`setBounds`/`addChildView`/`removeChildView` on each side. This file is that pattern,
12
+ // ONCE, as a factory: `embeddedSlot(config)` returns one slot's functions, and `main.ts` builds both
13
+ // instances from it.
14
+ //
15
+ // ── WHAT IS A PARAMETER, AND WHY EACH ONE CANNOT BE MERGED AWAY ────────────────────────────────
16
+ // * `door` — the ONE load door for this slot: `loadTarget` (the url policy's `sanitizeBrowserUrl`,
17
+ // admitting any http/https) for the browser, `dshTargetFor` (its own loopback origin, or nothing)
18
+ // for the DSH. Forced by the crate split: the two policy crates must not depend on each other, so
19
+ // shell-policy takes the ANSWER of url-policy's predicate. Merging them would either expose the
20
+ // internet to the DSH's `window.open` or confine the browser view to loopback.
21
+ // * `popupTarget` — the `target=_blank` decision, over the same split as `door`:
22
+ // `embeddedPopupTarget(door(raw))` for the browser (its argument is the DECIDED target, and a
23
+ // refusal is DROPPED rather than collapsed to `about:blank`, so a refused link cannot erase the page
24
+ // being read) against `dshPopupTarget(raw, isDshUrl(raw))` for the DSH.
25
+ // * `recoverUrl(lastDecided)` — what a crashed view is reloaded with: the browser's `embeddedRecoverUrl`
26
+ // (the last DECIDED url, `about:blank` as the fallback, because "recovering a crashed view must not
27
+ // make a request to a search engine to do it") against the DSH's `dshHome(base)` ("a crash recovery
28
+ // that lands somewhere other than the harness is a view the operator did not ask for").
29
+ // * `stateUrl(live, lastDecided)` — the `url` field of `state()`. The browser's address bar must never
30
+ // go blank, so it answers the last decided url when its view cannot (`shownUrl`); the DSH's record
31
+ // must not report a dead object's last url, so it answers "" (`shownUrl(live, "")`). This is a
32
+ // FINDING rather than one of the four load-bearing differences above: the two modules differed here
33
+ // by accident of their callers, and merging it either way would change one pane's contract.
34
+ // * `goneChannel` — `embedded-browser:gone` / `embedded-dsh:gone`. The signal is the same on both
35
+ // sides (a renderer that died must not leave the pane saying "starting…" forever); only the channel
36
+ // name differs, and the names are pinned by the preload/panel fixtures.
37
+ // * `navChannel?` — present ONLY for the browser: it brings the three navigation pushes
38
+ // (`did-navigate`, `did-navigate-in-page`, `page-title-updated`), the `canBack`/`canFwd` fields of
39
+ // `state()`, and the `go`/`zoom` operations. The DSH has no history surface, so adding them there
40
+ // would be uncalled surface (and a channel the preload does not expose would fail the channel-set
41
+ // equality in `test/ipc-door.test.mjs`).
42
+ //
43
+ // ── WHAT IS DELIBERATELY NOT A PARAMETER, AND WHY ──────────────────────────────────────────────
44
+ // THE LIFECYCLE AND THE STARTUP NAVIGATION ARE CALLER BEHAVIOURS. The browser slot is created EAGERLY at
45
+ // startup, hidden, and never navigated (an attached AI always has an `about:blank` CDP target; it used
46
+ // to load `bing.com` on every launch) — `main.ts` calls `ensure()` from `app.whenReady()`. The DSH slot
47
+ // is created LAZILY by the pane's `open()` and navigated home — `main.ts`'s `dshOpen()` is
48
+ // `slot.navigate(dshHome(dshBase()))`, and `navigate()` is what creates the view. Neither belongs in a
49
+ // parameter of `ensure()`: a flag here would let one caller ask for the other's lifecycle by accident.
50
+ // `main.ts` also keeps `bootDshPort`, `dshTargetFor`, `loadHarnessDoors`, `dshOpen` and EVERY
51
+ // `ipcHandle(...)` registration, because the shell holds the ONE `ipcMain.handle` door.
52
+ //
53
+ // A POLICY THAT IS THE SAME FOR BOTH SLOTS STAYS AN IMPORT, not a parameter: the minimum slot
54
+ // (`slotTooSmall`), the visible conjunction (`viewVisible`), the history sign (`goBackwards`) and the
55
+ // zoom clamp (`zoomFactor`) are the shell policy crate's answers and are identical for the two views.
56
+ // Only what DIFFERS travels as a parameter.
57
+ //
58
+ // ── FIVE THINGS THE TWO IMPLEMENTATIONS DISAGREED ABOUT THAT WERE NOT IN THE LIST ABOVE ────────
59
+ // Each was decided here explicitly rather than inherited from whichever side happened to be copied:
60
+ // 1. THE RE-SHOW AFTER A CRASH. Both `recover()`s contained a "re-show if the slot is live" block that
61
+ // could never run: `ensure()` — which recovery has just called to build the fresh view — clears the
62
+ // visible flag first. It is FIXED here rather than deleted: the block's own comment states its
63
+ // purpose ("kept so a renderer-crash recovery can re-show the fresh view at the same spot without
64
+ // waiting for the SPA"), the retained bounds exist for exactly that, and the fix makes the slot's
65
+ // record agree with its screen (the flag is restored, so `state().visible` is true when the fresh
66
+ // view is shown). It is not a new always-on behaviour: after a REAL crash the `gone` handler has
67
+ // already cleared the flag, so the SPA's next `place()` still shows the view — this only covers a
68
+ // recovery invoked without that event (an explicit recover, or a stalled renderer).
69
+ // 2. `ensure()` WAS EXPORTED BY ONE MODULE AND PRIVATE IN THE OTHER. The slot exposes it for both: it
70
+ // is the browser's eager-creation hook, and the difference is which CALLER calls it, not what it
71
+ // does. The DSH's instance is simply never asked.
72
+ // 3. THE PERMISSION HANDLERS. The browser's `ensure()` installed deny-by-default request/check
73
+ // handlers on the session; the DSH's installed none. Both instances install them now: the DSH is
74
+ // still a whole web application on loopback, and the DSH's own suite already called the posture it
75
+ // lacked "the browser view's security posture". The calls are idempotent and inside the same
76
+ // try/catch, so a session that refuses them is non-fatal on both sides.
77
+ // 4. THE `gone` HANDLER'S ORDER. The browser checked the window FIRST and returned — leaving a dead
78
+ // renderer's view visible — where the DSH hid the view first and only the PUSH depended on the
79
+ // window. The DSH's order is the rule here, on both slots: a renderer that died never repaints, the
80
+ // hide needs nothing from the host, and the window (which `main.ts` re-creates and nulls) is read
81
+ // only where it is genuinely required.
82
+ // 5. `state()`'s `visible` FIELD reached the crate on one side (`viewVisible`) and was spelled
83
+ // `dshVisible && !!wc` on the other. The two are the same answer, so both go through the crate —
84
+ // the decision belongs to the policy, not to one call site.
85
+ const electron_1 = require("electron");
86
+ const summrise_shell_policy_1 = require("./summrise_shell_policy");
87
+ function embeddedSlot(config) {
88
+ if (!config || typeof config !== "object") {
89
+ throw new Error("embedded-slot: a slot is built from its host config (win, door, popupTarget, recoverUrl, stateUrl, " +
90
+ "goneChannel, and navChannel for a slot with a history surface) — there is no such thing as a default one");
91
+ }
92
+ const REQUIRED = ["win", "door", "popupTarget", "recoverUrl", "stateUrl"];
93
+ for (const name of REQUIRED) {
94
+ if (typeof config[name] !== "function") {
95
+ throw new Error(`embedded-slot: ${name} is not a function — a slot is built with its host (main.ts lends both ` +
96
+ `instances the shell's window getter, one load door, and the policy answers only that slot may use)`);
97
+ }
98
+ }
99
+ if (typeof config.goneChannel !== "string" || !config.goneChannel) {
100
+ throw new Error("embedded-slot: goneChannel is missing — a crashed renderer pushes `gone` so the pane can show a " +
101
+ "recovery state instead of 'starting…' forever, and a slot that cannot push it must not be built");
102
+ }
103
+ if (config.navChannel !== undefined && (typeof config.navChannel !== "string" || !config.navChannel)) {
104
+ throw new Error("embedded-slot: navChannel is present but is not a channel name — omit it for a slot with no history surface");
105
+ }
106
+ const { win, door, popupTarget, recoverUrl, stateUrl, goneChannel, navChannel } = config;
107
+ // ── THE PER-INSTANCE STATE, AND IT IS THE INSTANCE'S ─────────────────────────────────────────
108
+ // Four bindings, closed over rather than module-level, which is what makes two slots independent: the
109
+ // two modules this replaces each held their state at module scope, so a second instance was not
110
+ // merely unused, it was unrepresentable. (`embeddedUrl` is the fourth rather than the third: the
111
+ // browser's module held view/visible/bounds AND the last url its door decided, which recovery and
112
+ // `state()` both read. The DSH's recover ignores it and its `stateUrl` does too.)
113
+ let view = null;
114
+ let visible = false;
115
+ let bounds = null;
116
+ let lastDecided = "about:blank";
117
+ function ensure() {
118
+ if (view && !view.webContents.isDestroyed())
119
+ return view;
120
+ // ── THE HOST IS READ BEFORE ANYTHING IS BUILT, AND BEFORE ANY STATE IS SET ─────────────────────
121
+ // An UNBOUND slot is one whose window getter refuses (`deps().win()` on a host whose `bind()` never
122
+ // ran), and a refusal must leave the slot exactly as it was: nothing built, nothing assigned, no
123
+ // handler wired. The read used to sit AFTER `view = built` — the order BOTH modules had before the
124
+ // unification — so the loud throw left a fully built view in the slot's state that no window ever
125
+ // showed: its crash handler was live, a later `state()`/`reload()` answered about it, and a second
126
+ // `ensure()` returned it without reading the window again. Nothing below needs the window to BUILD a
127
+ // view; the read is only about WHERE to attach one, so it happens first and a refusal creates
128
+ // nothing. This is a deliberate behaviour change on the UNBOUND path only: a bound host's getter
129
+ // returns exactly what it returned before, and reads it exactly once.
130
+ const w = win();
131
+ const built = new electron_1.WebContentsView({
132
+ webPreferences: {
133
+ contextIsolation: true,
134
+ nodeIntegration: false,
135
+ sandbox: true,
136
+ // NO `preload` KEY, on either slot: zero Node surface in a renderer. The browser view renders
137
+ // arbitrary internet pages; the DSH is a whole web application on loopback. A `preload` added
138
+ // here would be added to BOTH views at once, which is why it is written down.
139
+ },
140
+ });
141
+ built.setVisible(false);
142
+ visible = false;
143
+ // ── `target=_blank`, ONE DECISION PER SLOT ─────────────────────────────────────────────────
144
+ // Real sites open every external link in a new window (Baidu's, the harness's docs), and this is a
145
+ // single-tab pane: the popup itself is always DENIED and the SAME view moves instead. WHICH targets
146
+ // qualify is the `popupTarget` parameter's — through the slot's own door, so a refused scheme is
147
+ // dropped rather than navigated to `about:blank`.
148
+ built.webContents.setWindowOpenHandler(({ url }) => {
149
+ const decided = popupTarget(url);
150
+ if (decided !== undefined)
151
+ navigateDecided(decided);
152
+ return { action: "deny" };
153
+ });
154
+ if (navChannel) {
155
+ // Event-driven (no polling): the SPA's address bar and back/fwd buttons track the ACTUAL page, so
156
+ // an in-page (hash) navigation and a title-only update are changes it has to see too.
157
+ const push = () => {
158
+ const w = win();
159
+ if (!w || w.isDestroyed())
160
+ return;
161
+ const s = stateWithHistory();
162
+ w.webContents.send(navChannel, { url: s.url, canBack: s.canBack, canFwd: s.canFwd, title: s.title });
163
+ };
164
+ built.webContents.on("did-navigate", push);
165
+ built.webContents.on("did-navigate-in-page", push);
166
+ built.webContents.on("page-title-updated", push);
167
+ }
168
+ // A renderer that crashed (process-gone) or hung mid-load never paints again. The dead view is HIDDEN
169
+ // first — that needs nothing from the host — so the pane's recovery banner, which paints under the
170
+ // native view, becomes visible in the slot; only the PUSH needs a live window, which `main.ts`
171
+ // re-creates and nulls (a destroyed window's `send` throws in Electron).
172
+ built.webContents.on("render-process-gone", (_e, details) => {
173
+ try {
174
+ built.setVisible(false);
175
+ }
176
+ catch { /* already gone */ }
177
+ visible = false;
178
+ const w = win();
179
+ if (w && !w.isDestroyed()) {
180
+ w.webContents.send(goneChannel, { reason: details.reason, exitCode: details.exitCode });
181
+ }
182
+ });
183
+ try {
184
+ electron_1.session.defaultSession.setPermissionRequestHandler((_wc, _perm, cb) => cb(false));
185
+ electron_1.session.defaultSession.setPermissionCheckHandler(() => false);
186
+ }
187
+ catch { /* non-fatal (already set at app ready, or a session that does not exist) */ }
188
+ view = built;
189
+ // Attach to the main window's content view once the window exists — THE WINDOW READ AT THE TOP of
190
+ // this function, not a second read: the same one getter call, moved ahead of the build. The view is
191
+ // added HIDDEN and only shown when the SPA's page is active and reports its placeholder bounds.
192
+ if (w)
193
+ w.contentView.addChildView(built);
194
+ return built;
195
+ }
196
+ /** Place the view over the SPA's slot (CSS px in the window's content coordinate space). Empty bounds
197
+ * hide it. */
198
+ function place(next) {
199
+ const v = ensure();
200
+ const w = win();
201
+ if (!w)
202
+ return;
203
+ if ((0, summrise_shell_policy_1.slotTooSmall)(next)) {
204
+ v.setVisible(false);
205
+ visible = false;
206
+ return;
207
+ }
208
+ bounds = next;
209
+ v.setBounds(next);
210
+ v.setVisible(true);
211
+ visible = true;
212
+ // Keep it above the SPA content (the SPA's placeholder is an empty div).
213
+ w.contentView.addChildView(v);
214
+ }
215
+ /** Load an ALREADY DECIDED url. The one path that does not open the door again: the popup decision and
216
+ * `recover()` both hand this a target their own policy has decided, and re-deciding it would be a
217
+ * second call the caller did not ask for. */
218
+ function navigateDecided(url) {
219
+ const v = ensure();
220
+ lastDecided = url;
221
+ v.webContents.loadURL(url).catch(() => { });
222
+ }
223
+ function navigate(raw) {
224
+ // Every raw url the SPA or the AI hands this slot is decided by the ONE load door before it reaches
225
+ // loadURL — and it is recorded FIRST, so the record is the door's answer and never the raw string.
226
+ const url = door(raw);
227
+ navigateDecided(url);
228
+ return { ok: true, url };
229
+ }
230
+ function reload() {
231
+ const v = view;
232
+ if (!v || v.webContents.isDestroyed())
233
+ return { ok: false };
234
+ try {
235
+ v.webContents.reload();
236
+ return { ok: true };
237
+ }
238
+ catch {
239
+ return { ok: false };
240
+ }
241
+ }
242
+ /** The record WITHOUT a history surface: exactly the four keys the DSH's pane reads. */
243
+ function stateRecord() {
244
+ const v = view;
245
+ const wc = v && !v.webContents.isDestroyed() ? v.webContents : null;
246
+ return {
247
+ ok: true,
248
+ url: stateUrl(wc ? wc.getURL() : undefined, lastDecided),
249
+ title: wc ? wc.getTitle() : "",
250
+ visible: (0, summrise_shell_policy_1.viewVisible)(visible, !!wc),
251
+ };
252
+ }
253
+ /** ...and the same record WITH the two history fields, which only the browser's slot has. */
254
+ function stateWithHistory() {
255
+ const v = view;
256
+ const wc = v && !v.webContents.isDestroyed() ? v.webContents : null;
257
+ return {
258
+ ...stateRecord(),
259
+ canBack: !!wc && wc.navigationHistory.canGoBack(),
260
+ canFwd: !!wc && wc.navigationHistory.canGoForward(),
261
+ };
262
+ }
263
+ /** Recover after a renderer crash: a dead webContents never recovers, so re-create the view. */
264
+ function recover() {
265
+ // THE SLOT'S PLACEMENT IS READ BEFORE THE RE-CREATE. `ensure()` clears the visible flag for the view
266
+ // it builds, so a re-show block that reads the flag afterwards can never run — which is exactly the
267
+ // defect both modules carried. Reading it here is what makes the re-show below reachable, and the
268
+ // bounds are retained on every hide for the same reason (they are the SPA's last placement).
269
+ const wasVisible = visible;
270
+ const lastBounds = bounds;
271
+ try {
272
+ if (view && !view.webContents.isDestroyed()) {
273
+ view.webContents.close({ waitForBeforeUnload: false });
274
+ }
275
+ if (view) {
276
+ try {
277
+ win()?.contentView.removeChildView(view);
278
+ }
279
+ catch { /* already gone */ }
280
+ view = null;
281
+ }
282
+ }
283
+ catch { /* fall through to ensure() */ }
284
+ const fresh = ensure();
285
+ if (!fresh || fresh.webContents.isDestroyed())
286
+ return { ok: false };
287
+ // The recovery target is the parameter's, and it is ALREADY A DECISION: the browser's re-loads the
288
+ // last url its door decided (never a third-party home page a crash would fetch), the DSH's is its
289
+ // own home (`dshHome`), because a recovery that lands anywhere else is a view nobody asked for.
290
+ fresh.webContents.loadURL(recoverUrl(lastDecided)).catch(() => { });
291
+ // Re-show at the same spot if the slot was live when the renderer died — and restore the flag, so the
292
+ // record the pane reads agrees with what is on screen. A slot that was hidden (never placed, or the
293
+ // SPA cleared its placeholder) stays hidden: the SPA's next place() is what shows it.
294
+ if (wasVisible && lastBounds) {
295
+ const w = win();
296
+ if (w) {
297
+ fresh.setBounds(lastBounds);
298
+ fresh.setVisible(true);
299
+ visible = true;
300
+ w.contentView.addChildView(fresh);
301
+ }
302
+ }
303
+ return { ok: true };
304
+ }
305
+ const slot = { ensure, place, navigate, reload, recover, state: stateRecord };
306
+ if (!navChannel)
307
+ return slot;
308
+ // THE HISTORY SURFACE, only for the slot that declared a nav channel. A pane with no address bar and no
309
+ // back/fwd buttons has no use for these, and a channel nothing invokes is a channel the preload does not
310
+ // expose — which `test/ipc-door.test.mjs` fails on.
311
+ const nav = {
312
+ ...slot,
313
+ state: stateWithHistory,
314
+ /** Back/forward through the REAL webContents history; the direction sign is the crate's. */
315
+ go: (delta) => {
316
+ const v = view;
317
+ if (!v || v.webContents.isDestroyed())
318
+ return { ok: false };
319
+ try {
320
+ if ((0, summrise_shell_policy_1.goBackwards)(delta))
321
+ v.webContents.goBack();
322
+ else
323
+ v.webContents.goForward();
324
+ return { ok: true };
325
+ }
326
+ catch {
327
+ return { ok: false };
328
+ }
329
+ },
330
+ /** Zoom the REAL view (webContents.setZoomFactor — the native browser zooms, not a CSS scale). The
331
+ * clamp AND the falsy test are the crate's, because `zoomFactor(0)` is 100% and not 50%. */
332
+ zoom: (factor) => {
333
+ const v = view;
334
+ if (!v || v.webContents.isDestroyed())
335
+ return { ok: false };
336
+ const f = (0, summrise_shell_policy_1.zoomFactor)(factor);
337
+ try {
338
+ v.webContents.setZoomFactor(f);
339
+ return { ok: true, factor: f };
340
+ }
341
+ catch {
342
+ return { ok: false };
343
+ }
344
+ },
345
+ };
346
+ return nav;
347
+ }
@@ -82,6 +82,21 @@ const http = __importStar(require("http"));
82
82
  // this file, which holds exactly one instance of each glue, evaluates it.
83
83
  const summrise_url_policy_1 = require("./summrise_url_policy");
84
84
  const summrise_shell_policy_1 = require("./summrise_shell_policy");
85
+ // THE TWO EMBEDDED VIEWS ARE ONE MODULE NOW. `embedded-slot.ts` holds the pattern the browser's view and
86
+ // the DSH's view each used to spell out (`embedded-view.ts` and `dsh-view.ts`, near-copies that a
87
+ // function-by-function comparison measured), and this file builds BOTH instances from it — one slot per
88
+ // view, with the differences that are load-bearing (the load door, the popup decision, the recovery
89
+ // target, the state's url fallback, the `gone` channel, and the browser-only nav channel) passed in as
90
+ // configuration. Every `ipcHandle` registration stays HERE (see the block below "THE EMBEDDED VIEW'S IPC"),
91
+ // because this file holds the ONE `ipcMain.handle` door.
92
+ const embedded_slot_1 = require("./embedded-slot");
93
+ // THE BROWSER-SESSION WINDOWS ARE THEIR OWN MODULE TOO. It owns the session table and the three
94
+ // operations an AI drives; this file owns the load door every one of them opens through, the window
95
+ // icon they share with the main window, and the cap's number — lent to it in one `browserSessionsBind`
96
+ // call below. The IPC registrations and the three other call sites stay HERE: this file holds the ONE
97
+ // `ipcMain.handle` door, and the loopback control server and the panel's `target=_blank` handler are
98
+ // this file's routers, not the module's.
99
+ const browser_sessions_1 = require("./browser-sessions");
85
100
  const K = JSON.parse((0, summrise_shell_policy_1.shellConstants)());
86
101
  // The handful the shell reads often enough that `K.` at every use site would be noise. They are still the
87
102
  // table's values — destructuring a copy of the parse, not a second declaration.
@@ -235,13 +250,6 @@ let win = null;
235
250
  let tray = null;
236
251
  /** Whether the hide-to-tray notification has been shown (once per launch). */
237
252
  let hideNotified = false;
238
- const browserSessions = new Map();
239
- /** Initial target per browser window — reuse matching uses this instead of
240
- * webContents.getURL() (which lags during navigation; stage-n). */
241
- const browserTargets = new Map();
242
- /** Cap on concurrent browser-session windows — an AI loop opening sessions
243
- * repeatedly must not pile up windows on the desktop (stage-n). The number is `K.MAX_BROWSER_WINDOWS`;
244
- * the PLAN that applies it (reuse by initial target, then evict the oldest) is the policy crate's. */
245
253
  // stage-m: SINGLE-INSTANCE LOCK — a second Summrise window exits immediately.
246
254
  // (The agent itself also enforces single-instance via bind-failure exit.)
247
255
  const gotTheLock = electron_1.app.requestSingleInstanceLock();
@@ -373,322 +381,60 @@ function buildMenu() {
373
381
  * cannot quietly reach for a weaker policy of its own.
374
382
  *
375
383
  * Callers keep their own about:blank semantics: browserOpen() opens a blank
376
- * window, embeddedNavigate() loads it, and the embedded view's window-open
384
+ * window, the browser slot's navigate() loads it, and that slot's window-open
377
385
  * handler REFUSES it (an external link must not blank the page being read). */
378
386
  function loadTarget(raw) {
379
387
  return (0, summrise_url_policy_1.sanitizeBrowserUrl)(raw);
380
388
  }
381
- /** Open a browser-session window on a decided load target (loadTarget() is the
382
- * ONE load door — the scheme policy lives there). */
383
- function browserOpen(url) {
384
- const target = loadTarget(url);
385
- // stage-n: reuse an existing window on the same URL instead of stacking duplicates (AI-driven
386
- // browsing opens/closes sessions repeatedly). Match against the window's INITIAL target (stored at
387
- // open) — webContents.getURL() lags during navigation (returns about:blank while loading), so a
388
- // same-URL reopen right after the first open would otherwise miss the reuse and stack a duplicate.
389
- //
390
- // THE PLAN IS THE POLICY CRATE'S, in one call: which window to reuse (first non-destroyed match, in
391
- // INSERTION order), and — when the cap is reached — which one to evict (the OLDEST, not the least
392
- // recently used). This file only reads the session table and applies the answer. The `destroyed` flag
393
- // travels with the row because a destroyed window is skipped for reuse while still COUNTING against
394
- // the cap, which is what `browserSessions.size` did.
395
- const plan = JSON.parse((0, summrise_shell_policy_1.planBrowserOpen)(JSON.stringify([...browserSessions].map(([id, bw]) => ({ id, target: browserTargets.get(id) ?? "", destroyed: bw.isDestroyed() }))), target, K.MAX_BROWSER_WINDOWS));
396
- if (plan.reuse) {
397
- const existing = browserSessions.get(plan.reuse);
398
- if (existing) {
399
- if (existing.isMinimized())
400
- existing.restore();
401
- existing.show();
402
- existing.focus();
403
- return { ok: true, id: plan.reuse, url: target, cdp: (0, summrise_shell_policy_1.cdpEndpoint)(K.CDP_PORT) };
404
- }
405
- }
406
- if (plan.evict)
407
- browserClose(plan.evict);
408
- const id = (0, summrise_shell_policy_1.browserId)(Date.now());
409
- const bw = new electron_1.BrowserWindow({
410
- width: 1100, height: 750, title: `Summrise Browser — ${target}`,
411
- ...(windowIcon() ? { icon: windowIcon() } : {}),
412
- // review #6/#7: these windows load ARBITRARY internet pages. contextIsolation
413
- // on + nodeIntegration off + NO preload = zero Node/bridge surface (the
414
- // default; made explicit). Popups are denied (they would otherwise spawn
415
- // unmanaged windows outside the MAX_BROWSER_WINDOWS cap).
416
- webPreferences: { contextIsolation: true, nodeIntegration: false, sandbox: true },
417
- });
418
- bw.webContents.setWindowOpenHandler(() => ({ action: "deny" }));
419
- bw.loadURL(target);
420
- bw.on("closed", () => { browserSessions.delete(id); browserTargets.delete(id); });
421
- // stage-n: the window title starts as "Summrise Browser — <target>" but a
422
- // site's own <title> is more useful after load (and after in-page
423
- // navigation); follow it so window managers/taskbar show the real page.
424
- bw.webContents.on("page-title-updated", (_e, title) => {
425
- if (!bw.isDestroyed() && title)
426
- bw.setTitle(`Summrise Browser — ${title}`);
427
- });
428
- browserSessions.set(id, bw);
429
- browserTargets.set(id, target);
430
- return { ok: true, id, url: target, cdp: (0, summrise_shell_policy_1.cdpEndpoint)(K.CDP_PORT) };
431
- }
432
- function browserClose(id) {
433
- const bw = browserSessions.get(id || "");
434
- // review #g: close() is vetoable by a page's beforeunload — an arbitrary
435
- // remote page could refuse eviction, so the MAX_BROWSER_WINDOWS cap never
436
- // freed and browserOpen stalled. destroy() is unconditional (these are
437
- // disposable, headless-driven windows).
438
- if (bw) {
439
- bw.destroy();
440
- browserSessions.delete(id || "");
441
- browserTargets.delete(id || "");
442
- }
443
- return { ok: true };
444
- }
445
- function browserList() {
446
- const list = [...browserSessions.entries()].map(([id, bw]) => ({ id, url: bw.webContents.getURL() }));
447
- return { ok: true, sessions: list, cdp: (0, summrise_shell_policy_1.cdpEndpoint)(K.CDP_PORT) };
448
- }
449
- ipcHandle("browser-session:open", (url) => browserOpen(url));
450
- ipcHandle("browser-session:close", (id) => browserClose(id));
451
- ipcHandle("browser-session:list", () => browserList());
452
- // ── Embedded real-render browser view (round-246) ──────────────────────────
453
- // The SPA's Browser page used to show the BRIDGE's headless-chromium as a
454
- // JPEG screencast (lossy q60-92 frames over a websocket) — never as sharp as
455
- // a real browser, and a SECOND browser instance alongside the desktop shell.
456
- // In the Electron shell we replace it with a REAL WebContentsView embedded
457
- // over the SPA's browser placeholder: GPU-composited, vector text, directly
458
- // interactive. Its webContents is a first-class CDP target on the SAME
459
- // :9333 endpoint, so AI (playwright connectOverCDP) drives EXACTLY the page
460
- // the user sees — one browser, zero JPEG.
461
- //
462
- // Security posture mirrors the browser-session windows: the view loads
463
- // arbitrary internet pages, so contextIsolation on, nodeIntegration off, NO
464
- // preload, sandbox on, popups denied, permissions denied by default.
465
- let embeddedView = null;
466
- let embeddedVisible = false;
467
- let embeddedUrl = "about:blank";
468
- // round-256: last placed bounds — kept so a renderer-crash recovery can
469
- // re-show the fresh view at the same spot without waiting for the SPA.
470
- let embeddedBounds = null;
471
- function embeddedViewEnsure() {
472
- if (embeddedView && !embeddedView.webContents.isDestroyed())
473
- return embeddedView;
474
- const view = new electron_1.WebContentsView({
475
- webPreferences: {
476
- contextIsolation: true,
477
- nodeIntegration: false,
478
- sandbox: true,
479
- // round-246 (review #6/#7 parity): no preload = zero Node surface.
480
- },
481
- });
482
- view.setVisible(false);
483
- embeddedVisible = false;
484
- // round-248 (user report "浏览器超链接跳转不了"): `target=_blank` links
485
- // (the norm on real sites — Baidu's every external link opens a new
486
- // window) were DENIED, so clicking them did nothing. The embedded view is
487
- // a single-tab browser: intercept window.open and navigate the SAME view
488
- // to the requested URL instead (the address bar follows via the nav
489
- // events). The scheme allow-list is loadTarget()'s (the ONE load door):
490
- // anything it refuses (javascript:, file:, chrome:) must NOT blank the page
491
- // the user is reading, so a refused target is dropped here instead.
492
- view.webContents.setWindowOpenHandler(({ url }) => {
493
- // THE DOOR DECIDES, THIS FILE APPLIES: `loadTarget` is the ONE load door (the URL policy's), and
494
- // the DECISION to DROP a refused target rather than navigate to the blank page is the policy
495
- // crate's — a refused `javascript:`/`file:`/`chrome:` link must not erase what the operator is
496
- // reading, which is why a refusal is `undefined` here and not `"about:blank"`.
497
- const decided = (0, summrise_shell_policy_1.embeddedPopupTarget)(loadTarget(url));
498
- if (decided !== undefined) {
499
- embeddedNavigate(decided);
500
- }
501
- return { action: "deny" };
502
- });
503
- // round-248: same-view navigation for window.name/target=_top style links
504
- // that Chromium routes as renderer-initiated top navigations is already
505
- // handled natively; this handler only covers the window.open path above.
506
- // round-247: real-navigation events → SPA (URL/title/history tracking).
507
- embeddedWireNavEvents(view);
508
- try {
509
- electron_1.session.defaultSession.setPermissionRequestHandler((_wc, _perm, cb) => cb(false));
510
- electron_1.session.defaultSession.setPermissionCheckHandler(() => false);
511
- }
512
- catch { /* non-fatal (already set at app ready) */ }
513
- embeddedView = view;
514
- // Attach to the main window's content view once the window exists. The
515
- // view is added hidden and only shown when the SPA's Browser page is
516
- // active and reports its placeholder bounds.
517
- if (win)
518
- win.contentView.addChildView(view);
519
- return view;
520
- }
521
- /** Place the embedded view over the SPA's browser placeholder (CSS px in the
522
- * window's content coordinate space). Empty bounds hide it. */
523
- function embeddedViewPlace(bounds) {
524
- const view = embeddedViewEnsure();
525
- if (!win)
526
- return;
527
- // `K.MIN_SLOT_PX` AND ITS COMPARISON ARE ONE FUNCTION NOW: this view and the DSH view each spelled
528
- // `bounds.width < 50 || bounds.height < 50` BEFORE the port, which is two copies of one policy — the
529
- // place where the browser view and the DSH view would come to disagree about what "showing" means.
530
- if ((0, summrise_shell_policy_1.slotTooSmall)(bounds)) {
531
- view.setVisible(false);
532
- embeddedVisible = false;
533
- return;
534
- }
535
- embeddedBounds = bounds;
536
- view.setBounds(bounds);
537
- view.setVisible(true);
538
- embeddedVisible = true;
539
- // Keep it above the SPA content (the SPA placeholder is an empty div).
540
- win.contentView.addChildView(view);
541
- }
542
- function embeddedNavigate(raw) {
543
- // Every raw URL the SPA/AI hands the embedded view is decided by the ONE
544
- // load door before it reaches loadURL.
545
- const url = loadTarget(raw);
546
- const view = embeddedViewEnsure();
547
- embeddedUrl = url;
548
- view.webContents.loadURL(url).catch(() => { });
549
- return { ok: true, url };
550
- }
551
- /** round-247: nav controls on the embedded view (the SPA's toolbar mirrors a
552
- * real browser: back/fwd/reload operate the actual webContents history). */
553
- function embeddedGo(delta) {
554
- const view = embeddedView;
555
- if (!view || view.webContents.isDestroyed())
556
- return { ok: false };
557
- try {
558
- if ((0, summrise_shell_policy_1.goBackwards)(delta))
559
- view.webContents.goBack();
560
- else
561
- view.webContents.goForward();
562
- return { ok: true };
563
- }
564
- catch {
565
- return { ok: false };
566
- }
567
- }
568
- function embeddedReload() {
569
- const view = embeddedView;
570
- if (!view || view.webContents.isDestroyed())
571
- return { ok: false };
572
- try {
573
- view.webContents.reload();
574
- return { ok: true };
575
- }
576
- catch {
577
- return { ok: false };
578
- }
579
- }
580
- function embeddedState() {
581
- const view = embeddedView;
582
- const wc = view && !view.webContents.isDestroyed() ? view.webContents : null;
583
- return {
584
- ok: true,
585
- // The FALLBACK is the policy crate's: a live `getURL()` that answers "" (not yet navigated) and no
586
- // live contents at all are the same answer, and it is the last URL this view was sent to.
587
- url: (0, summrise_shell_policy_1.shownUrl)(wc ? wc.getURL() : undefined, embeddedUrl),
588
- canBack: !!wc && wc.navigationHistory.canGoBack(),
589
- canFwd: !!wc && wc.navigationHistory.canGoForward(),
590
- title: wc ? wc.getTitle() : "",
591
- visible: (0, summrise_shell_policy_1.viewVisible)(embeddedVisible, !!wc),
592
- };
593
- }
594
- /** round-247: push real navigation state (URL + title + history) to the SPA
595
- * so its address bar and back/fwd buttons track the ACTUAL embedded page —
596
- * event-driven (fires on navigation, no polling). */
597
- function embeddedWireNavEvents(view) {
598
- const wc = view.webContents;
599
- const push = () => {
600
- if (!win || win.isDestroyed())
601
- return;
602
- const s = embeddedState();
603
- win.webContents.send("embedded-browser:nav", { url: s.url, canBack: s.canBack, canFwd: s.canFwd, title: s.title });
604
- };
605
- wc.on("did-navigate", push);
606
- wc.on("did-navigate-in-page", push);
607
- wc.on("page-title-updated", push);
608
- // round-256: the embedded view's RENDERER can crash (process-gone) or hang
609
- // mid-load (did-fail-load). Push a `gone` event so the SPA shows a recovery
610
- // state instead of "Starting…" forever; the SPA then offers a reload.
611
- wc.on("render-process-gone", (_e, details) => {
612
- if (!win || win.isDestroyed())
613
- return;
614
- // Hide the dead view so the SPA's recovery banner (which paints under
615
- // the native view) becomes visible in the slot.
616
- try {
617
- view.setVisible(false);
618
- }
619
- catch { /* already gone */ }
620
- embeddedVisible = false;
621
- win.webContents.send("embedded-browser:gone", {
622
- reason: details.reason,
623
- exitCode: details.exitCode,
624
- });
625
- });
626
- }
627
- /** round-256: recover the embedded view after a RENDERER CRASH. A
628
- * process-gone renderer usually leaves the webContents object alive but
629
- * dead — loadURL on it never recovers, so force a full re-create: drop the
630
- * old view (destroy + remove) and build a fresh one via embeddedViewEnsure,
631
- * then navigate to the last URL (or a default). */
632
- function embeddedRecover() {
633
- try {
634
- if (embeddedView && !embeddedView.webContents.isDestroyed()) {
635
- embeddedView.webContents.close({ waitForBeforeUnload: false });
636
- }
637
- if (embeddedView) {
638
- try {
639
- win?.contentView.removeChildView(embeddedView);
640
- }
641
- catch { /* already gone */ }
642
- embeddedView = null;
643
- }
644
- }
645
- catch { /* fall through to ensure() */ }
646
- const view = embeddedViewEnsure();
647
- if (!view || view.webContents.isDestroyed())
648
- return { ok: false };
649
- // Already decided by loadTarget() when embeddedUrl was set (the ONE load
650
- // door) — recovery re-loads that decision, it does not re-open the policy.
651
- // The fallback is about:blank, NOT a third-party home page: recovering a
652
- // crashed view must not make a request to a search engine to do it. That
653
- // rule is `embeddedRecoverUrl`'s, in the policy crate.
654
- const url = (0, summrise_shell_policy_1.embeddedRecoverUrl)(embeddedUrl);
655
- view.webContents.loadURL(url).catch(() => { });
656
- // Re-show if the SPA slot is live (bounds were placed before the crash).
657
- if (embeddedVisible && win && embeddedBounds) {
658
- view.setBounds(embeddedBounds);
659
- view.setVisible(true);
660
- win.contentView.addChildView(view);
661
- }
662
- return { ok: true };
663
- }
664
- ipcHandle("embedded-browser:recover", () => embeddedRecover());
665
- ipcHandle("embedded-browser:navigate", (url) => embeddedNavigate(String(url || "")));
666
- ipcHandle("embedded-browser:back", () => embeddedGo(-1));
667
- ipcHandle("embedded-browser:fwd", () => embeddedGo(1));
668
- ipcHandle("embedded-browser:reload", () => embeddedReload());
669
- /** round-251: zoom the REAL embedded view (webContents.setZoomFactor — the
670
- * native browser zooms, not a CSS scale). factor: 0.5-3.0. */
671
- ipcHandle("embedded-browser:zoom", (factor) => {
672
- const view = embeddedView;
673
- if (!view || view.webContents.isDestroyed())
674
- return { ok: false };
675
- // The clamp AND the `Number(factor) || 1` falsy test are the policy crate's, because `zoomFactor(0)`
676
- // is 100% and not 50% — a boundary that a hand-written `Math.min/Math.max` here got right by
677
- // accident and a rewrite would not.
678
- const f = (0, summrise_shell_policy_1.zoomFactor)(factor);
679
- try {
680
- view.webContents.setZoomFactor(f);
681
- return { ok: true, factor: f };
682
- }
683
- catch {
684
- return { ok: false };
685
- }
389
+ // ── THE BROWSER SLOT: THE FIRST INSTANCE OF `embedded-slot.ts` ─────────────────────────────
390
+ // Built ONCE here (module load), the way the bind call it replaces was. Everything the slot needs is
391
+ // either a function object this file owns (the window getter and the ONE load door above) or a policy
392
+ // answer only THIS view may use — and the two that must not be merged into the module are the popup
393
+ // decision and the recovery target, because they are what keeps this view a general browser while the
394
+ // DSH's is confined to its own loopback origin (see `embedded-slot.ts`'s header).
395
+ const browserSlot = (0, embedded_slot_1.embeddedSlot)({
396
+ // A GETTER, not the value: the window does not exist yet and is nulled when it closes.
397
+ win: () => win,
398
+ door: loadTarget,
399
+ // `target=_blank` through the ONE load door: its argument is the DECIDED target, and a refused scheme
400
+ // answers `undefined` — DROPPED rather than collapsed to `about:blank`, so an external link cannot
401
+ // erase the page the operator is reading.
402
+ popupTarget: (raw) => (0, summrise_shell_policy_1.embeddedPopupTarget)(loadTarget(raw)),
403
+ // The last DECIDED url, or the crate's blank fallback: recovering a crashed view must not fetch a
404
+ // third-party home page to do it.
405
+ recoverUrl: (lastDecided) => (0, summrise_shell_policy_1.embeddedRecoverUrl)(lastDecided),
406
+ // This state feeds the SPA's ADDRESS BAR, which must never go blank: a view that cannot answer reports
407
+ // the last url the door decided for it. (The DSH's record does the opposite — see its instance below.)
408
+ stateUrl: (live, lastDecided) => (0, summrise_shell_policy_1.shownUrl)(live, lastDecided),
409
+ goneChannel: "embedded-browser:gone",
410
+ // THE BROWSER-ONLY SURFACE: nav pushes, canBack/canFwd, and go/zoom. The DSH instance has none of it.
411
+ navChannel: "embedded-browser:nav",
686
412
  });
413
+ // The browser-session windows' seam, bound ONCE here (module load) and never re-bound. Three things
414
+ // this file owns and the sessions do not: the ONE load door above (their every URL goes through it),
415
+ // the window icon the main window also uses, and the cap's number from the boot table. `main.ts`
416
+ // requiring the module and the module requiring `main.ts` would be a cycle, so they are lent instead,
417
+ // and `browser-sessions.ts` reads them through its own `deps()`.
418
+ (0, browser_sessions_1.browserSessionsBind)({ loadTarget, windowIcon, maxBrowserWindows: K.MAX_BROWSER_WINDOWS });
419
+ ipcHandle("browser-session:open", (url) => (0, browser_sessions_1.browserOpen)(url));
420
+ ipcHandle("browser-session:close", (id) => (0, browser_sessions_1.browserClose)(id));
421
+ ipcHandle("browser-session:list", () => (0, browser_sessions_1.browserList)());
422
+ // ── THE EMBEDDED VIEW'S IPC — THE REGISTRATIONS STAY HERE ──────────────────────────────────
423
+ // The slot itself (its state and its operations) is `embedded-slot.ts`. These lines do NOT go with it:
424
+ // this file holds the ONE `ipcMain.handle` door — `ipcHandle` applies the frame check — and
425
+ // `test/ipc-door.test.mjs` pins the channels registered behind that door against the ones `preload.ts`
426
+ // invokes. A handler registered in the module would be a second door.
427
+ ipcHandle("embedded-browser:recover", () => browserSlot.recover());
428
+ ipcHandle("embedded-browser:navigate", (url) => browserSlot.navigate(String(url || "")));
429
+ ipcHandle("embedded-browser:back", () => browserSlot.go(-1));
430
+ ipcHandle("embedded-browser:fwd", () => browserSlot.go(1));
431
+ ipcHandle("embedded-browser:reload", () => browserSlot.reload());
432
+ ipcHandle("embedded-browser:zoom", (factor) => browserSlot.zoom(factor));
687
433
  ipcHandle("embedded-browser:place", (bounds) => {
688
- embeddedViewPlace(bounds);
434
+ browserSlot.place(bounds);
689
435
  return { ok: true };
690
436
  });
691
- ipcHandle("embedded-browser:state", () => embeddedState());
437
+ ipcHandle("embedded-browser:state", () => browserSlot.state());
692
438
  // ── THE DSH VIEW: the harness's own UI, embedded in summrise ────────────────────────────────
693
439
  //
694
440
  // WHY LOOPBACK AND NOT THE PUBLIC HOSTNAME. The DSH runs on THIS machine (127.0.0.1:18081), and
@@ -701,9 +447,26 @@ ipcHandle("embedded-browser:state", () => embeddedState());
701
447
  //
702
448
  // A SECOND view, not a re-use of the browser one: the browser view is a single-tab internet
703
449
  // browser an AI drives over CDP, and navigating it to the DSH would throw that session away.
704
- let dshView = null;
705
- let dshVisible = false;
706
- let dshBounds = null;
450
+ //
451
+ // ── THE DSH SLOT IS THE SECOND INSTANCE OF THE SAME MODULE; THIS IS ITS HOST HALF ──────────
452
+ // The view's state and its operations are `embedded-slot.ts` now, built from the same factory as the
453
+ // browser's. What matters HERE is the three functions below it that DELIBERATELY DID NOT GO INTO IT,
454
+ // because they belong to the boot and the host rather than to the view:
455
+ // * `bootDshPort` — the boot's port pin, the twin of `bootAgentPort` below: called once, from
456
+ // `app.whenReady()`, as the argument to `setDshPort`, which feeds the url policy's port state
457
+ // that this file owns. No operation of the view calls it.
458
+ // * `dshTargetFor` — the ONE load door for that view, and a door is the HOST's: this file owns
459
+ // the load door of every view it lends one to (`loadTarget` above is the same shape for the
460
+ // other two modules), and it is where this file evaluates the url-policy predicate
461
+ // `isDshUrl` — the rule the import block above states for both crates. It is LENT to the slot in
462
+ // the `embeddedSlot` call below, never re-declared there.
463
+ // * `loadHarnessDoors` — the boot's ONE read of the agent's harness table: it MUTATES the url
464
+ // policy's own port list (`addDshPort`), fetches with the device token (`authHeaders`) and is
465
+ // bounded by the boot table's `HARNESS_FETCH_MS`. The view never calls it, so lending this
466
+ // module a credential and a boot number for a caller it does not have would be dependency
467
+ // surface bought for nothing.
468
+ // `WebContentsView` left this file's electron import with the view: nothing here names the type
469
+ // any more, and a surface no reader can act on is the rule the child_process import above cites.
707
470
  /** The DSH's port. Env first (a deployment may move it), else the component's default — the range test
708
471
  * and the fallback are the policy crate's, the env read is this file's. */
709
472
  function bootDshPort() {
@@ -749,133 +512,50 @@ function loadHarnessDoors() {
749
512
  })
750
513
  .finally(() => clearTimeout(timer));
751
514
  }
752
- function dshViewEnsure() {
753
- if (dshView && !dshView.webContents.isDestroyed())
754
- return dshView;
755
- const view = new electron_1.WebContentsView({
756
- webPreferences: {
757
- contextIsolation: true,
758
- nodeIntegration: false,
759
- sandbox: true,
760
- // No preload: the DSH SPA needs no Node surface, and a view that cannot reach one cannot
761
- // be talked into using one.
762
- },
763
- });
764
- view.setVisible(false);
765
- dshVisible = false;
766
- // The DSH opens links (docs, the operator's own pages) with target=_blank. Deny the POPUP but
767
- // keep the click meaningful where the door allows it — and the door allows one origin, so in
768
- // practice an external link is simply dropped rather than silently navigating this view away
769
- // from the harness.
770
- view.webContents.setWindowOpenHandler(({ url }) => {
771
- const decided = (0, summrise_shell_policy_1.dshPopupTarget)(url, (0, summrise_url_policy_1.isDshUrl)(url));
772
- if (decided !== undefined)
773
- dshNavigate(decided);
774
- return { action: "deny" };
775
- });
776
- view.webContents.on("render-process-gone", (_e, details) => {
777
- try {
778
- view.setVisible(false);
779
- }
780
- catch { /* already gone */ }
781
- dshVisible = false;
782
- // The SAME signal the browser view sends, for the same reason (round-256 there): without it
783
- // the pane shows "starting…" forever over a view that will never paint, and the operator has
784
- // no way to tell a slow load from a dead renderer.
785
- if (win && !win.isDestroyed()) {
786
- win.webContents.send("embedded-dsh:gone", { reason: details.reason, exitCode: details.exitCode });
787
- }
788
- });
789
- dshView = view;
790
- if (win)
791
- win.contentView.addChildView(view);
792
- return view;
793
- }
794
- function dshNavigate(url) {
795
- const target = dshTargetFor(url);
796
- const view = dshViewEnsure();
797
- view.webContents.loadURL(target).catch(() => { });
798
- return { ok: true, url: target };
799
- }
800
- /** Open (or re-focus) the DSH at its configured address. Idempotent.
801
- *
802
- * `dshHome` is the policy crate's and it was spelled TWICE in this file before the port — here and in
803
- * `dshRecover` — which is exactly the pair that must not drift: a crash recovery that lands somewhere
804
- * other than the harness is a view the operator did not ask for. */
515
+ // THE DSH INSTANCE, built ONCE here (module load) from the SAME factory as the browser slot. The window
516
+ // is a getter because it does not exist yet and is nulled when it closes; `door` is the load door above;
517
+ // and the DSH's own answers travel as FUNCTIONS rather than as values for the reason the crate's docs
518
+ // give — "the BASE arrives from the host, which is the only thing that can read the configured DSH
519
+ // port" — and because `setDshPort` runs at `whenReady`, so a snapshot taken here would pin the port list
520
+ // before the agent's harness table has even been read.
521
+ //
522
+ // THE LIFECYCLE IS THE CALLER'S, NOT A PARAMETER: this slot is created LAZILY (nothing calls `ensure()`
523
+ // until the pane's `open()` navigates), where the browser's is created eagerly at startup. `ensure` is on
524
+ // the slot's surface for both instances; only the browser's caller asks for it early.
525
+ const dshSlot = (0, embedded_slot_1.embeddedSlot)({
526
+ win: () => win,
527
+ door: dshTargetFor,
528
+ // The DSH's own popup decision: over its own door, and only the harness's origin is admitted — an
529
+ // external `target=_blank` link is dropped rather than navigating this pane away from the harness.
530
+ popupTarget: (raw) => (0, summrise_shell_policy_1.dshPopupTarget)(raw, (0, summrise_url_policy_1.isDshUrl)(raw)),
531
+ // Always the harness home: a crash recovery that lands somewhere other than the harness is a view the
532
+ // operator did not ask for.
533
+ recoverUrl: () => (0, summrise_shell_policy_1.dshHome)((0, summrise_url_policy_1.dshBase)()),
534
+ // This record describes the LIVE view and nothing else — a dead webContents reports blank rather than
535
+ // its last url (the browser slot's address bar is the opposite case; see its instance above).
536
+ stateUrl: (live) => (0, summrise_shell_policy_1.shownUrl)(live, ""),
537
+ goneChannel: "embedded-dsh:gone",
538
+ // NO navChannel: the DSH pane has no address bar and no back/fwd buttons.
539
+ });
540
+ /** Open (or re-focus) the DSH at its configured address. Idempotent — and the `open` OPERATION stays in
541
+ * this file because the lifecycle is the caller's: this is the call that CREATES the DSH's view (the
542
+ * slot builds it lazily inside `navigate`), and navigates it home through the ONE door above. */
805
543
  function dshOpen() {
806
- return dshNavigate((0, summrise_shell_policy_1.dshHome)((0, summrise_url_policy_1.dshBase)()));
807
- }
808
- /** Place the view over the SPA's slot. Empty bounds hide it (the same contract as the browser). */
809
- function dshPlace(bounds) {
810
- const view = dshViewEnsure();
811
- if (!win)
812
- return;
813
- // The SAME predicate the browser view uses — one `MIN_SLOT_PX`, two call sites, before the port.
814
- if ((0, summrise_shell_policy_1.slotTooSmall)(bounds)) {
815
- view.setVisible(false);
816
- dshVisible = false;
817
- return;
818
- }
819
- dshBounds = bounds;
820
- view.setBounds(bounds);
821
- view.setVisible(true);
822
- dshVisible = true;
823
- win.contentView.addChildView(view);
824
- }
825
- function dshReload() {
826
- const view = dshView;
827
- if (!view || view.webContents.isDestroyed())
828
- return { ok: false };
829
- try {
830
- view.webContents.reload();
831
- return { ok: true };
832
- }
833
- catch {
834
- return { ok: false };
835
- }
836
- }
837
- /** Recover after a renderer crash: a dead webContents never recovers, so re-create the view. */
838
- function dshRecover() {
839
- try {
840
- if (dshView && !dshView.webContents.isDestroyed())
841
- dshView.webContents.close({ waitForBeforeUnload: false });
842
- if (dshView) {
843
- try {
844
- win?.contentView.removeChildView(dshView);
845
- }
846
- catch { /* already gone */ }
847
- dshView = null;
848
- }
849
- }
850
- catch { /* fall through to ensure() */ }
851
- const view = dshViewEnsure();
852
- if (!view || view.webContents.isDestroyed())
853
- return { ok: false };
854
- view.webContents.loadURL((0, summrise_shell_policy_1.dshHome)((0, summrise_url_policy_1.dshBase)())).catch(() => { });
855
- if (dshVisible && win && dshBounds) {
856
- view.setBounds(dshBounds);
857
- view.setVisible(true);
858
- win.contentView.addChildView(view);
859
- }
860
- return { ok: true };
861
- }
862
- function dshState() {
863
- const view = dshView;
864
- const wc = view && !view.webContents.isDestroyed() ? view.webContents : null;
865
- return { ok: true, url: wc ? wc.getURL() : "", title: wc ? wc.getTitle() : "", visible: dshVisible && !!wc };
544
+ return dshSlot.navigate((0, summrise_shell_policy_1.dshHome)((0, summrise_url_policy_1.dshBase)()));
866
545
  }
867
546
  ipcHandle("embedded-dsh:open", () => dshOpen());
868
547
  ipcHandle("embedded-dsh:place", (bounds) => {
869
- dshPlace(bounds);
548
+ dshSlot.place(bounds);
870
549
  return { ok: true };
871
550
  });
872
- ipcHandle("embedded-dsh:state", () => dshState());
873
- // SELECT A HOST: `dshNavigate` already refuses anything `isDshUrl` does not admit, so this channel can
874
- // only reach a door the agent configured — and it answers with the address it actually loaded, which for a
875
- // refusal is `about:blank`. The pane shows that rather than pretending the switch worked.
876
- ipcHandle("embedded-dsh:go", (url) => dshNavigate(String(url)));
877
- ipcHandle("embedded-dsh:reload", () => dshReload());
878
- ipcHandle("embedded-dsh:recover", () => dshRecover());
551
+ ipcHandle("embedded-dsh:state", () => dshSlot.state());
552
+ // SELECT A HOST: the slot's `navigate` already refuses anything `isDshUrl` does not admit, so this
553
+ // channel can only reach a door the agent configured — and it answers with the address it actually
554
+ // loaded, which for a refusal is `about:blank`. The pane shows that rather than pretending the switch
555
+ // worked.
556
+ ipcHandle("embedded-dsh:go", (url) => dshSlot.navigate(String(url)));
557
+ ipcHandle("embedded-dsh:reload", () => dshSlot.reload());
558
+ ipcHandle("embedded-dsh:recover", () => dshSlot.recover());
879
559
  // Desktop-app settings (auto-launch) — the Settings page toggles this. We
880
560
  // manage a per-user scheduled task ("SummriseDesktop", onlogon) instead of
881
561
  // Electron's setLoginItemSettings: in dev mode (electron .) the login-item
@@ -1070,9 +750,9 @@ const httpServer = http.createServer((req, res) => {
1070
750
  // guard (a POST reaches the same reader), while `/open`, `/close` and `/start-agent` require POST and
1071
751
  // a GET to one of them falls through to the 404.
1072
752
  switch ((0, summrise_shell_policy_1.controlRoute)(req.method ?? "", pathname)) {
1073
- case "browser-open": return send(browserOpen((0, summrise_shell_policy_1.controlQuery)(req.url, "url") || K.ABOUT_BLANK));
1074
- case "browser-close": return send(browserClose((0, summrise_shell_policy_1.controlQuery)(req.url, "id") || ""));
1075
- case "browser-list": return send(browserList());
753
+ case "browser-open": return send((0, browser_sessions_1.browserOpen)((0, summrise_shell_policy_1.controlQuery)(req.url, "url") || K.ABOUT_BLANK));
754
+ case "browser-close": return send((0, browser_sessions_1.browserClose)((0, summrise_shell_policy_1.controlQuery)(req.url, "id") || ""));
755
+ case "browser-list": return send((0, browser_sessions_1.browserList)());
1076
756
  case "start-agent": return (async () => send(await startAgentTask()))();
1077
757
  case "agent-status": return (async () => send({ ok: true, running: await agentResponds(CONTROL_PROBE_MS) }))();
1078
758
  case "icon-status": return send({ ok: true, icons: iconReport,
@@ -1227,7 +907,7 @@ if (gotTheLock) {
1227
907
  // review #7: a target=_blank from the SPA must NOT get a preload-bearing
1228
908
  // window; route it to a sandboxed browser session instead.
1229
909
  win.webContents.setWindowOpenHandler(({ url }) => {
1230
- browserOpen(url);
910
+ (0, browser_sessions_1.browserOpen)(url);
1231
911
  return { action: "deny" };
1232
912
  });
1233
913
  // stage-n: load /desktop/ only when the agent is actually listening.
@@ -1447,14 +1127,17 @@ if (gotTheLock) {
1447
1127
  // attached playwright/AI ALWAYS has its own dedicated page to drive —
1448
1128
  // without it, playwright grabbed the MAIN window (now tripwired) or the
1449
1129
  // user had to open the Browser page first. The view is hidden until the
1450
- // SPA's Browser page reports bounds (embeddedViewPlace shows it).
1130
+ // SPA's Browser page reports bounds (the slot's place() shows it).
1451
1131
  // NO EAGER NAVIGATION: this used to loadURL("https://www.bing.com"), i.e. a
1452
1132
  // request to a third party on EVERY launch before anyone asked for a page.
1453
1133
  // The view is still created — about:blank is a first-class CDP target (the
1454
1134
  // reason it exists here is that a target be available, not that a page be
1455
1135
  // loaded), and the user/AI navigates it next (loadTarget is the one door).
1136
+ // THIS CALL IS THE BROWSER SLOT'S LIFECYCLE AND IT LIVES HERE ON PURPOSE: the browser's view is
1137
+ // created eagerly, the DSH's is created lazily by its pane's open() — a difference between two
1138
+ // CALLERS, so it is these lines rather than a parameter of the shared slot.
1456
1139
  try {
1457
- embeddedViewEnsure();
1140
+ browserSlot.ensure();
1458
1141
  }
1459
1142
  catch { /* non-fatal */ }
1460
1143
  console.log(`[summrise] CDP endpoint: ${(0, summrise_shell_policy_1.cdpEndpoint)(K.CDP_PORT)} (playwright connectOverCDP)`);
@@ -1609,11 +1292,7 @@ electron_1.app.on("before-quit", () => {
1609
1292
  electron_1.app.isQuitting = true;
1610
1293
  // stage-n: close every browser-session window explicitly — app.quit()
1611
1294
  // tears down the main window but independent BrowserWindows with pending
1612
- // beforeunload handlers can stall or leak on exit.
1613
- for (const [id, bw] of browserSessions) {
1614
- if (!bw.isDestroyed())
1615
- bw.destroy();
1616
- browserSessions.delete(id);
1617
- browserTargets.delete(id);
1618
- }
1295
+ // beforeunload handlers can stall or leak on exit. The sweep itself lives with the
1296
+ // table it walks (`browser-sessions.ts`), which is why this is a call and not a loop.
1297
+ (0, browser_sessions_1.browserCloseAll)();
1619
1298
  });
Binary file