summrise-agent 1.2.509 → 1.2.511

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.
@@ -57,9 +57,35 @@ const electron_1 = require("electron");
57
57
  const path = __importStar(require("path"));
58
58
  const fs = __importStar(require("fs"));
59
59
  const http = __importStar(require("http"));
60
- // url-policy.ts (shipped alongside, staged by summrise update): pure
61
- // origin/URL predicates, unit-tested in test/url-policy.test.mjs.
62
- const url_policy_1 = require("./url-policy");
60
+ // THE POLICY IS RUST, IN TWO CRATES, AND THIS FILE IS THE HOST THAT CALLS IT.
61
+ //
62
+ // `agent/summrise-url-policy` (landing 6a) owns the URL/origin/certificate decisions; `cargo test -p
63
+ // summrise-url-policy` pins them. `agent/summrise-shell-policy` (landing 6b) owns everything else this
64
+ // file used to decide: the device token's line pattern, the auth header, the port precedence, the icon
65
+ // per platform, the native menu table, the browser-session plan and both embedded views' doors, the
66
+ // loopback control server's router, the schtasks arguments, the watchdog, the tripwire, the CDP
67
+ // self-check and every string the tray shows. `cargo test -p summrise-shell-policy` pins those.
68
+ //
69
+ // BOTH are built to wasm with `wasm-pack --target nodejs` and required here; the glue compiles each
70
+ // module SYNCHRONOUSLY, so the first decision may run immediately — which matters, because the first
71
+ // thing this file does is pin the agent's port. The glues and the modules are committed and shipped
72
+ // (agent/summrise-agent-npm/required-in-tgz.txt names all four files, and `summrise update` stages them).
73
+ //
74
+ // **THE TWO CRATES DO NOT DEPEND ON EACH OTHER, AND THAT IS A MEASURED CONSTRAINT RATHER THAN A STYLE.**
75
+ // wasm-bindgen exports every `#[wasm_bindgen]` item in the whole crate GRAPH, so making the shell-policy
76
+ // crate depend on the url-policy one made its glue re-export all 18 url-policy names — and each glue
77
+ // compiles its OWN `.wasm`, so url-policy's `thread_local!` port state would exist TWICE.
78
+ // `setAgentPort(7740)` through one module would leave the other at 18080: a custom-port install where the
79
+ // DSH door admits the wrong origin and the tripwire snaps the panel back. So where a decision below
80
+ // composes a url-policy predicate it passes that predicate's ANSWER in — `isDshUrl(raw)`,
81
+ // `isDesktopSpaUrl(url)`, `isBaseOrigin(url)`, `parseAgentPort(raw)`, `sanitizeBrowserUrl(raw)` — and
82
+ // this file, which holds exactly one instance of each glue, evaluates it.
83
+ const summrise_url_policy_1 = require("./summrise_url_policy");
84
+ const summrise_shell_policy_1 = require("./summrise_shell_policy");
85
+ const K = JSON.parse((0, summrise_shell_policy_1.shellConstants)());
86
+ // The handful the shell reads often enough that `K.` at every use site would be noise. They are still the
87
+ // table's values — destructuring a copy of the parse, not a second declaration.
88
+ const { AGENT_TASK, SCHTASKS_TIMEOUT_MS, AGENT_PROBE_DEFAULT_MS, AGENT_PROBE_BOOT_MS, AGENT_PROBE_TRAY_MS, CONTROL_PROBE_MS, STATUS_FETCH_MS, HARNESS_FETCH_MS, CDP_CHECK_MS, MENU_FLUSH_MS, TRAY_POLL_MS, RETRY_START_MS, TRIPWIRE_BACKOFF_MS, } = K;
63
89
  // IPC audit #3: /api/status is TOKEN-GATED (same fact the watchdog fix cites);
64
90
  // credential-less fetches got 401 -> version title + tray vitals were DEAD on
65
91
  // every configured device. The shell runs as the interactive admin, and the
@@ -72,47 +98,44 @@ const url_policy_1 = require("./url-policy");
72
98
  const INSTALL_ROOT = path.join(__dirname, "..", "..", "..");
73
99
  let _tokenCache = { at: 0, tok: null };
74
100
  function agentToken() {
75
- if (Date.now() - _tokenCache.at < 60_000)
101
+ if ((0, summrise_shell_policy_1.tokenCacheFresh)(_tokenCache.at, Date.now()))
76
102
  return _tokenCache.tok;
77
- let tok = null;
103
+ let raw;
78
104
  try {
79
- const raw = fs.readFileSync(path.join(INSTALL_ROOT, "etc", "config.yaml"), "utf8");
80
- // THIS PATTERN IS NARROWER THAN THE TWO OTHER PARSERS OF THE SAME LINE: the CLI's
81
- // (bin/summrise.js, `[A-Za-z0-9._-]+`) and the agent's Rust recovery both accept more than
82
- // lowercase hex. Measured against a live device on 2026-09-24 — the token is 64 hex characters
83
- // with no space before the colon, so all three agree TODAY, and hex is what the worker issues.
84
- // The failure mode if that ever stops being true is SILENT: no credential, a 401, a title that
85
- // stays "Summrise" and vitals that stay blank — the state the comment above claims to have
86
- // fixed. Widen this line before debugging that.
87
- const m = /device_token:\s*"?([0-9a-f]{16,})"?/.exec(raw);
88
- if (m)
89
- tok = m[1];
105
+ raw = fs.readFileSync(path.join(INSTALL_ROOT, "etc", "config.yaml"), "utf8");
90
106
  }
91
107
  catch { /* no local config — vitals stay hidden, same as before */ }
108
+ // THE PATTERN IS THE POLICY CRATE'S, and it is deliberately NARROWER than the two other parsers of
109
+ // this line: the CLI's (bin/summrise.js, `[A-Za-z0-9._-]+`) and the agent's Rust recovery both accept
110
+ // more than lowercase hex. Measured against a live device on 2026-09-24 — the token is 64 hex
111
+ // characters with no space before the colon, so all three agree TODAY, and hex is what the worker
112
+ // issues. The failure mode if that ever stops being true is SILENT: no credential, a 401, a title that
113
+ // stays "Summrise" and vitals that stay blank. `cargo test -p summrise-shell-policy` pins which side of
114
+ // that divergence the pattern is on; WIDEN IT THERE before debugging this.
115
+ const tok = (0, summrise_shell_policy_1.deviceToken)(raw) ?? null;
92
116
  _tokenCache = { at: Date.now(), tok };
93
117
  return tok;
94
118
  }
95
119
  function authHeaders() {
96
- const t = agentToken();
97
- return t ? { authorization: `Bearer ${t}` } : {};
120
+ const value = (0, summrise_shell_policy_1.authorization)(agentToken());
121
+ return value ? { authorization: value } : {};
98
122
  }
99
123
  // Agent bind port (custom-port installs): explicit SUMMRISE_AGENT_PORT env
100
124
  // first, then the agent's config.yaml server.port next to the install dir
101
125
  // (same file agentToken() reads — sync fs, same best-effort discipline),
102
126
  // else the canonical 18080. Resolved once at boot before any probe/window;
103
- // url-policy predicates follow via setAgentPort.
104
- function resolveAgentPort() {
105
- const env = Number(process.env.SUMMRISE_AGENT_PORT);
106
- if (Number.isInteger(env) && env > 0 && env < 65536)
107
- return env;
127
+ // the Rust policy's predicates follow via setAgentPort (which ignores an
128
+ // invalid port rather than resetting to the default).
129
+ function bootAgentPort() {
130
+ // The ORDER (env, then the device's own config, then the canonical port) is the policy crate's; the
131
+ // YAML walk that produces the middle answer is the URL policy's, which is why it is evaluated HERE and
132
+ // passed in. Both reads are best-effort and both fall through to the next source.
133
+ let fromConfig;
108
134
  try {
109
- const raw = fs.readFileSync(path.join(INSTALL_ROOT, "etc", "config.yaml"), "utf8");
110
- const port = (0, url_policy_1.parseAgentPort)(raw);
111
- if (port)
112
- return port;
135
+ fromConfig = (0, summrise_url_policy_1.parseAgentPort)(fs.readFileSync(path.join(INSTALL_ROOT, "etc", "config.yaml"), "utf8")) ?? undefined;
113
136
  }
114
137
  catch { /* no local config — default below */ }
115
- return 18080;
138
+ return (0, summrise_shell_policy_1.resolveAgentPort)(process.env.SUMMRISE_AGENT_PORT, fromConfig);
116
139
  }
117
140
  // Remote-verifiable icon facts (GET /api/shell/icon-status on the 9444
118
141
  // loopback control server): file blind-flying on icon issues ended here —
@@ -147,10 +170,12 @@ function resolveIcon(name, reportKey) {
147
170
  // silently falling back to the stock electron.exe icon, device-caught).
148
171
  // Empty string when absent — callers fall back to Electron defaults.
149
172
  function appIcon() {
150
- return resolveIcon(process.platform === "win32" ? "icon.ico" : "icon.png", "tray");
173
+ // WHICH FILE is the policy crate's decision (`icon.ico` for the Windows tray, `icon.png` everywhere
174
+ // else); resolving and reporting it is this file's.
175
+ return resolveIcon((0, summrise_shell_policy_1.trayIconName)(process.platform), "tray");
151
176
  }
152
177
  function windowIcon() {
153
- return resolveIcon("icon.png", "window");
178
+ return resolveIcon((0, summrise_shell_policy_1.windowIconName)(), "window");
154
179
  }
155
180
  // Native-decode probe: file-exists is NOT proof Electron can use the
156
181
  // image (a corrupt/undecodable file falls back silently). Report what
@@ -170,17 +195,16 @@ function probeImage(p) {
170
195
  // IPC audit #2: preload runs in EVERY frame; will-navigate never gated
171
196
  // iframes. Handlers must reject anything not sourced from the pinned panel.
172
197
  function frameOk(e) {
173
- return (0, url_policy_1.frameUrlOk)(e.senderFrame?.url || "");
198
+ return (0, summrise_url_policy_1.frameUrlOk)(e.senderFrame?.url || "");
174
199
  }
175
200
  // THE ONE REFUSAL a forbidden frame gets. It used to have TWO shapes — seven
176
201
  // handlers answered this one and the rest answered a bare `{ ok: false }`,
177
202
  // which the SPA cannot tell apart from a dead view (`j?.ok` is falsy either
178
203
  // way, and only this shape carries the reason). The preload's `invoke` callers
179
204
  // were written against THIS shape, so every handler answers it now.
180
- const FORBIDDEN_FRAME = Object.freeze({
181
- ok: false,
182
- error: "forbidden frame",
183
- });
205
+ // THE VALUE IS THE POLICY CRATE'S, so the one refusal has one owner. `Object.freeze` is kept here
206
+ // because it is this file's guarantee to itself, not a decision about what the SPA reads.
207
+ const FORBIDDEN_FRAME = Object.freeze(JSON.parse((0, summrise_shell_policy_1.forbiddenFrame)()));
184
208
  // THE ONE IPC DOOR: `ipcMain.handle` is called HERE and nowhere else, so the
185
209
  // frame check above is applied ONCE per channel and no handler can forget it.
186
210
  // A fourteenth handler that re-checked (or skipped) the frame was SILENT:
@@ -201,10 +225,12 @@ function ipcHandle(channel, fn) {
201
225
  // P1: CDP port for AI (playwright) to drive Summrise's own pages — the SAME
202
226
  // Electron window the user watches. Summrise's playwright-mcp connects via
203
227
  // connectOverCDP("http://127.0.0.1:9333") and drives this window's pages.
204
- const CDP_PORT = 9333;
205
- // P1b: fallback local control endpoint for browser sessions (used when the
206
- // SPA runs in a plain browser, not under the Electron preload IPC).
207
- const CTRL_PORT = 9444;
228
+ // P1b: CTRL_PORT is the fallback local control endpoint for browser sessions
229
+ // (used when the SPA runs in a plain browser, not under the Electron preload IPC).
230
+ // BOTH NUMBERS ARE THE POLICY CRATE'S (`K.CDP_PORT`, `K.CTRL_PORT`) and NOTHING about when or whether
231
+ // they are opened moved with them: the `remote-debugging-port` switch below is still the only thing that
232
+ // opens 9333, and 9333 is a product contract — `agent/tests/mcp_autoselect_integration.rs` binds it, the
233
+ // CLI probes it, and the design sweep's attached mode depends on it.
208
234
  let win = null;
209
235
  let tray = null;
210
236
  /** Whether the hide-to-tray notification has been shown (once per launch). */
@@ -214,8 +240,8 @@ const browserSessions = new Map();
214
240
  * webContents.getURL() (which lags during navigation; stage-n). */
215
241
  const browserTargets = new Map();
216
242
  /** Cap on concurrent browser-session windows — an AI loop opening sessions
217
- * repeatedly must not pile up windows on the desktop (stage-n). */
218
- const MAX_BROWSER_WINDOWS = 8;
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. */
219
245
  // stage-m: SINGLE-INSTANCE LOCK — a second Summrise window exits immediately.
220
246
  // (The agent itself also enforces single-instance via bind-failure exit.)
221
247
  const gotTheLock = electron_1.app.requestSingleInstanceLock();
@@ -226,12 +252,12 @@ else {
226
252
  electron_1.app.on("second-instance", () => { focusMain(); });
227
253
  }
228
254
  // CDP must be enabled before app ready — pass it through Chromium switches.
229
- electron_1.app.commandLine.appendSwitch("remote-debugging-port", String(CDP_PORT));
255
+ electron_1.app.commandLine.appendSwitch("remote-debugging-port", String(K.CDP_PORT));
230
256
  // Lab-device self-signed certs (OpenWrt-style ONT defaults): bypass cert
231
257
  // errors ONLY on private-network hosts — the public internet keeps full
232
258
  // validation. Registered before ready so no navigation can race it.
233
259
  electron_1.app.on("certificate-error", (event, _webContents, url, _error, _certificate, callback) => {
234
- if ((0, url_policy_1.certBypassAllowed)(String(url || ""))) {
260
+ if ((0, summrise_url_policy_1.certBypassAllowed)(String(url || ""))) {
235
261
  event.preventDefault();
236
262
  callback(true);
237
263
  return;
@@ -301,77 +327,34 @@ function focusMain() {
301
327
  win.show();
302
328
  win.focus();
303
329
  }
304
- /** Build the native application menu. Items that act on the SPA's state send
305
- * `summrise-menu` commands; pure-window items use Electron roles. */
330
+ /** Build the native application menu.
331
+ *
332
+ * THE TABLE IS THE POLICY CRATE'S — every label, every accelerator and every command id — because the
333
+ * command ids are the contract with the SPA's own `summrise-menu` listener and the accelerators are the
334
+ * second half of it (the SPA handles the same chords from its own keydown map in a plain browser). A
335
+ * renamed id or a dropped row breaks the desktop app and nothing else. `Menu.buildFromTemplate` and the
336
+ * Electron `role` strings are this file's. */
306
337
  function buildMenu() {
307
- const isMac = process.platform === "darwin";
308
- const template = [
309
- ...(isMac ? [{
310
- label: electron_1.app.name,
311
- submenu: [
312
- { role: "about" },
313
- { type: "separator" },
314
- { role: "hide" },
315
- { role: "hideOthers" },
316
- { role: "unhide" },
317
- { type: "separator" },
318
- { role: "quit" },
319
- ],
320
- }] : []),
321
- {
322
- label: "File",
323
- submenu: [
324
- { label: "New Terminal", accelerator: "CmdOrCtrl+Shift+T", click: () => sendMenu("new-pty") },
325
- { label: "New SSH Connection…", accelerator: "CmdOrCtrl+Shift+S", click: () => sendMenu("new-ssh") },
326
- { label: "New Serial Connection…", accelerator: "CmdOrCtrl+Shift+P", click: () => sendMenu("new-serial") },
327
- { label: "New Browser Session…", accelerator: "CmdOrCtrl+Shift+B", click: () => sendMenu("new-browser") },
328
- { type: "separator" },
329
- { label: "Close Session", accelerator: "CmdOrCtrl+W", click: () => sendMenu("close-session") },
330
- { type: "separator" },
331
- isMac ? { role: "close" } : { role: "quit", label: "Exit" },
332
- ],
333
- },
334
- {
335
- label: "Edit",
336
- submenu: [
337
- { role: "undo" },
338
- { role: "redo" },
339
- { type: "separator" },
340
- { role: "cut" },
341
- { role: "copy" },
342
- { role: "paste" },
343
- { role: "selectAll" },
344
- ],
345
- },
346
- {
347
- label: "View",
348
- submenu: [
349
- { label: "Toggle Trajectory", accelerator: "CmdOrCtrl+Shift+Y", click: () => sendMenu("toggle-trajectory") },
350
- { type: "separator" },
351
- { label: "Reload", accelerator: "CmdOrCtrl+R", click: () => { if (win)
352
- win.webContents.reload(); } },
353
- { role: "togglefullscreen" },
354
- { role: "resetZoom" },
355
- { role: "zoomIn" },
356
- { role: "zoomOut" },
357
- { role: "toggleDevTools" },
358
- ],
359
- },
360
- {
361
- label: "Session",
362
- submenu: [
363
- { label: "Next Session", accelerator: "Ctrl+Tab", click: () => sendMenu("next-session") },
364
- { label: "Previous Session", accelerator: "Ctrl+Shift+Tab", click: () => sendMenu("prev-session") },
365
- // IPC audit #6: the "List Sessions" entry dispatched 'list-sessions',
366
- // a command with NO SPA-side handler — deleted (dead menu surface).
367
- // The "Export Session Log…" entry (^⇧E) was deleted for the same kind of
368
- // reason read the other way: the per-tab export mark IS the action
369
- // (TabBar's .tab-export, one mark per session, so it exports what you
370
- // point at rather than whatever happens to be active). The accelerator
371
- // is still handled by the SPA's own keydown map in a plain browser.
372
- ],
373
- },
374
- ];
338
+ const template = JSON.parse((0, summrise_shell_policy_1.appMenuJson)(process.platform, electron_1.app.name)).map((section) => ({
339
+ label: section.label,
340
+ submenu: section.items.map((item) => {
341
+ if (item.separator)
342
+ return { type: "separator" };
343
+ const base = {};
344
+ if (item.label)
345
+ base.label = item.label;
346
+ if (item.accelerator)
347
+ base.accelerator = item.accelerator;
348
+ // THREE KINDS OF ROW, and the crate's table says which is which: a command the SPA dispatches, the
349
+ // ONE host method (Reload), and an Electron role the menu system implements itself.
350
+ if (item.command)
351
+ return { ...base, click: () => sendMenu(item.command) };
352
+ if (item.reload)
353
+ return { ...base, click: () => { if (win)
354
+ win.webContents.reload(); } };
355
+ return { ...base, role: item.role };
356
+ }),
357
+ }));
375
358
  return electron_1.Menu.buildFromTemplate(template);
376
359
  }
377
360
  // --- Browser-session control: shared core (used by both IPC and HTTP) ---
@@ -383,9 +366,9 @@ function buildMenu() {
383
366
  * collapse to "about:blank", because a loadable file:// would hand the AI a
384
367
  * local-file read primitive via the shared CDP endpoint (:9333).
385
368
  *
386
- * The DECISION itself is url-policy.sanitizeBrowserUrl() — pure, and
387
- * unit-tested in test/url-policy.test.mjs, since main.ts imports electron and
388
- * no node suite can import it. This function is that predicate's ONE call site
369
+ * The DECISION itself is the Rust policy's sanitizeBrowserUrl() — pure, and
370
+ * unit-tested in agent/summrise-url-policy (src/tests.rs) because main.ts
371
+ * imports electron and no node suite can import it. This function is that predicate's ONE call site
389
372
  * in the shell (test/ipc-door.test.mjs pins the count), so a fourth load site
390
373
  * cannot quietly reach for a weaker policy of its own.
391
374
  *
@@ -393,35 +376,36 @@ function buildMenu() {
393
376
  * window, embeddedNavigate() loads it, and the embedded view's window-open
394
377
  * handler REFUSES it (an external link must not blank the page being read). */
395
378
  function loadTarget(raw) {
396
- return (0, url_policy_1.sanitizeBrowserUrl)(raw);
379
+ return (0, summrise_url_policy_1.sanitizeBrowserUrl)(raw);
397
380
  }
398
381
  /** Open a browser-session window on a decided load target (loadTarget() is the
399
382
  * ONE load door — the scheme policy lives there). */
400
383
  function browserOpen(url) {
401
384
  const target = loadTarget(url);
402
- // stage-n: reuse an existing window on the same URL instead of stacking
403
- // duplicates (AI-driven browsing opens/closes sessions repeatedly); focus
404
- // the existing window. Match against the window's INITIAL target (stored
405
- // at open) — webContents.getURL() lags during navigation (returns
406
- // about:blank while loading), so a same-URL reopen right after the first
407
- // open would otherwise miss the reuse and stack a duplicate.
408
- for (const [id, bw] of browserSessions) {
409
- if (!bw.isDestroyed() && browserTargets.get(id) === target) {
410
- if (bw.isMinimized())
411
- bw.restore();
412
- bw.show();
413
- bw.focus();
414
- return { ok: true, id, url: target, cdp: `http://127.0.0.1:${CDP_PORT}` };
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) };
415
404
  }
416
405
  }
417
- // Window cap: at most MAX_BROWSER_WINDOWS; evict the oldest when exceeded
418
- // so an AI loop cannot pile up windows.
419
- if (browserSessions.size >= MAX_BROWSER_WINDOWS) {
420
- const oldest = browserSessions.keys().next().value;
421
- if (oldest)
422
- browserClose(oldest);
423
- }
424
- const id = `browser-${Date.now()}`;
406
+ if (plan.evict)
407
+ browserClose(plan.evict);
408
+ const id = (0, summrise_shell_policy_1.browserId)(Date.now());
425
409
  const bw = new electron_1.BrowserWindow({
426
410
  width: 1100, height: 750, title: `Summrise Browser — ${target}`,
427
411
  ...(windowIcon() ? { icon: windowIcon() } : {}),
@@ -443,7 +427,7 @@ function browserOpen(url) {
443
427
  });
444
428
  browserSessions.set(id, bw);
445
429
  browserTargets.set(id, target);
446
- return { ok: true, id, url: target, cdp: `http://127.0.0.1:${CDP_PORT}` };
430
+ return { ok: true, id, url: target, cdp: (0, summrise_shell_policy_1.cdpEndpoint)(K.CDP_PORT) };
447
431
  }
448
432
  function browserClose(id) {
449
433
  const bw = browserSessions.get(id || "");
@@ -460,7 +444,7 @@ function browserClose(id) {
460
444
  }
461
445
  function browserList() {
462
446
  const list = [...browserSessions.entries()].map(([id, bw]) => ({ id, url: bw.webContents.getURL() }));
463
- return { ok: true, sessions: list, cdp: `http://127.0.0.1:${CDP_PORT}` };
447
+ return { ok: true, sessions: list, cdp: (0, summrise_shell_policy_1.cdpEndpoint)(K.CDP_PORT) };
464
448
  }
465
449
  ipcHandle("browser-session:open", (url) => browserOpen(url));
466
450
  ipcHandle("browser-session:close", (id) => browserClose(id));
@@ -506,9 +490,13 @@ function embeddedViewEnsure() {
506
490
  // anything it refuses (javascript:, file:, chrome:) must NOT blank the page
507
491
  // the user is reading, so a refused target is dropped here instead.
508
492
  view.webContents.setWindowOpenHandler(({ url }) => {
509
- const target = loadTarget(url);
510
- if (target !== "about:blank") {
511
- embeddedNavigate(target);
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);
512
500
  }
513
501
  return { action: "deny" };
514
502
  });
@@ -536,7 +524,10 @@ function embeddedViewPlace(bounds) {
536
524
  const view = embeddedViewEnsure();
537
525
  if (!win)
538
526
  return;
539
- if (!bounds || bounds.width < 50 || bounds.height < 50) {
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)) {
540
531
  view.setVisible(false);
541
532
  embeddedVisible = false;
542
533
  return;
@@ -564,7 +555,7 @@ function embeddedGo(delta) {
564
555
  if (!view || view.webContents.isDestroyed())
565
556
  return { ok: false };
566
557
  try {
567
- if (delta < 0)
558
+ if ((0, summrise_shell_policy_1.goBackwards)(delta))
568
559
  view.webContents.goBack();
569
560
  else
570
561
  view.webContents.goForward();
@@ -591,11 +582,13 @@ function embeddedState() {
591
582
  const wc = view && !view.webContents.isDestroyed() ? view.webContents : null;
592
583
  return {
593
584
  ok: true,
594
- url: wc ? wc.getURL() || embeddedUrl : embeddedUrl,
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),
595
588
  canBack: !!wc && wc.navigationHistory.canGoBack(),
596
589
  canFwd: !!wc && wc.navigationHistory.canGoForward(),
597
590
  title: wc ? wc.getTitle() : "",
598
- visible: embeddedVisible && !!view && !view.webContents.isDestroyed(),
591
+ visible: (0, summrise_shell_policy_1.viewVisible)(embeddedVisible, !!wc),
599
592
  };
600
593
  }
601
594
  /** round-247: push real navigation state (URL + title + history) to the SPA
@@ -656,8 +649,9 @@ function embeddedRecover() {
656
649
  // Already decided by loadTarget() when embeddedUrl was set (the ONE load
657
650
  // door) — recovery re-loads that decision, it does not re-open the policy.
658
651
  // The fallback is about:blank, NOT a third-party home page: recovering a
659
- // crashed view must not make a request to a search engine to do it.
660
- const url = embeddedUrl || "about:blank";
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);
661
655
  view.webContents.loadURL(url).catch(() => { });
662
656
  // Re-show if the SPA slot is live (bounds were placed before the crash).
663
657
  if (embeddedVisible && win && embeddedBounds) {
@@ -678,7 +672,10 @@ ipcHandle("embedded-browser:zoom", (factor) => {
678
672
  const view = embeddedView;
679
673
  if (!view || view.webContents.isDestroyed())
680
674
  return { ok: false };
681
- const f = Math.min(3, Math.max(0.5, Number(factor) || 1));
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);
682
679
  try {
683
680
  view.webContents.setZoomFactor(f);
684
681
  return { ok: true, factor: f };
@@ -707,14 +704,50 @@ ipcHandle("embedded-browser:state", () => embeddedState());
707
704
  let dshView = null;
708
705
  let dshVisible = false;
709
706
  let dshBounds = null;
710
- /** The DSH's port. Env first (a deployment may move it), else the component's default. */
711
- function resolveDshPort() {
712
- const fromEnv = Number(process.env.SUMMRISE_DSH_PORT);
713
- return Number.isInteger(fromEnv) && fromEnv > 0 && fromEnv < 65536 ? fromEnv : 18081;
707
+ /** The DSH's port. Env first (a deployment may move it), else the component's default — the range test
708
+ * and the fallback are the policy crate's, the env read is this file's. */
709
+ function bootDshPort() {
710
+ return (0, summrise_shell_policy_1.resolveDshPort)(process.env.SUMMRISE_DSH_PORT);
714
711
  }
715
- /** The ONE load door for this view: its own origin, or nothing. */
716
- function dshTarget(raw) {
717
- return (0, url_policy_1.isDshUrl)(raw) ? new URL(raw).toString() : "about:blank";
712
+ /** The ONE load door for this view: its own origin, or nothing.
713
+ *
714
+ * TWO OWNERS, ONE ANSWER: `isDshUrl` is the URL policy's predicate over its own port list, and this
715
+ * file evaluates it because that list is state the two wasm modules must not each hold a copy of (see
716
+ * the import block). The SERIALIZATION of an admitted target — and the refusal of everything else — is
717
+ * the shell policy's. */
718
+ function dshTargetFor(raw) {
719
+ return (0, summrise_shell_policy_1.dshTarget)(raw, (0, summrise_url_policy_1.isDshUrl)(raw));
720
+ }
721
+ // ── ONE DOOR PER HOST, TAKEN FROM THE AGENT'S OWN TABLE ───────────────────────────────────────────────
722
+ // The harness page shows the SELECTED host's own harness, and each host is reached through its own
723
+ // forward on this machine's loopback. Those ports are not a shell setting: the agent keeps them, because
724
+ // it is what knows which host a forward belongs to. So the shell asks, once at boot, and admits what the
725
+ // answer names — never a port of its own choosing, and never a host that is not in the table.
726
+ //
727
+ // A read that fails leaves the list as it was. A shell that widened its own door list on a failed read is
728
+ // exactly how the view ends up pointed somewhere it was not told about.
729
+ function loadHarnessDoors() {
730
+ // `agentBase()` rather than a second port read: `setAgentPort` has already run at this point, so the
731
+ // URL policy's base IS what `resolveAgentPort()` would compute — one answer, not two.
732
+ const base = (0, summrise_url_policy_1.agentBase)();
733
+ const controller = new AbortController();
734
+ const timer = setTimeout(() => controller.abort(), HARNESS_FETCH_MS);
735
+ fetch(`${base}/api/workspace/harnesses`, { headers: authHeaders(), signal: controller.signal })
736
+ .then((r) => (r.ok ? r.json() : Promise.reject(new Error(`HTTP ${r.status}`))))
737
+ .then((answer) => {
738
+ const rows = answer?.harnesses;
739
+ // THREE OWNERS, ONE DOOR LIST: the agent NAMES the ports (`local_port` per host), the policy crate
740
+ // says which are usable (`Array.isArray` and `Number(row?.local_port)` are JavaScript operations on
741
+ // a JavaScript value, so they happen inside `harnessDoors`; the RANGE TEST is the same one every
742
+ // other port in the shell goes through), and the URL policy ADMITS them — `addDshPort` dedupes
743
+ // against its own list, which is state this module must not keep a second copy of.
744
+ for (const port of (0, summrise_shell_policy_1.harnessDoors)(rows))
745
+ (0, summrise_url_policy_1.addDshPort)(port);
746
+ })
747
+ .catch(() => {
748
+ /* the list stays as it was: one host, or none */
749
+ })
750
+ .finally(() => clearTimeout(timer));
718
751
  }
719
752
  function dshViewEnsure() {
720
753
  if (dshView && !dshView.webContents.isDestroyed())
@@ -735,9 +768,9 @@ function dshViewEnsure() {
735
768
  // practice an external link is simply dropped rather than silently navigating this view away
736
769
  // from the harness.
737
770
  view.webContents.setWindowOpenHandler(({ url }) => {
738
- const target = dshTarget(url);
739
- if (target !== "about:blank")
740
- dshNavigate(target);
771
+ const decided = (0, summrise_shell_policy_1.dshPopupTarget)(url, (0, summrise_url_policy_1.isDshUrl)(url));
772
+ if (decided !== undefined)
773
+ dshNavigate(decided);
741
774
  return { action: "deny" };
742
775
  });
743
776
  view.webContents.on("render-process-gone", (_e, details) => {
@@ -759,21 +792,26 @@ function dshViewEnsure() {
759
792
  return view;
760
793
  }
761
794
  function dshNavigate(url) {
762
- const target = dshTarget(url);
795
+ const target = dshTargetFor(url);
763
796
  const view = dshViewEnsure();
764
797
  view.webContents.loadURL(target).catch(() => { });
765
798
  return { ok: true, url: target };
766
799
  }
767
- /** Open (or re-focus) the DSH at its configured address. Idempotent. */
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. */
768
805
  function dshOpen() {
769
- return dshNavigate((0, url_policy_1.dshBase)() + "/");
806
+ return dshNavigate((0, summrise_shell_policy_1.dshHome)((0, summrise_url_policy_1.dshBase)()));
770
807
  }
771
808
  /** Place the view over the SPA's slot. Empty bounds hide it (the same contract as the browser). */
772
809
  function dshPlace(bounds) {
773
810
  const view = dshViewEnsure();
774
811
  if (!win)
775
812
  return;
776
- if (!bounds || bounds.width < 50 || bounds.height < 50) {
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)) {
777
815
  view.setVisible(false);
778
816
  dshVisible = false;
779
817
  return;
@@ -813,7 +851,7 @@ function dshRecover() {
813
851
  const view = dshViewEnsure();
814
852
  if (!view || view.webContents.isDestroyed())
815
853
  return { ok: false };
816
- view.webContents.loadURL((0, url_policy_1.dshBase)() + "/").catch(() => { });
854
+ view.webContents.loadURL((0, summrise_shell_policy_1.dshHome)((0, summrise_url_policy_1.dshBase)())).catch(() => { });
817
855
  if (dshVisible && win && dshBounds) {
818
856
  view.setBounds(dshBounds);
819
857
  view.setVisible(true);
@@ -832,6 +870,10 @@ ipcHandle("embedded-dsh:place", (bounds) => {
832
870
  return { ok: true };
833
871
  });
834
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)));
835
877
  ipcHandle("embedded-dsh:reload", () => dshReload());
836
878
  ipcHandle("embedded-dsh:recover", () => dshRecover());
837
879
  // Desktop-app settings (auto-launch) — the Settings page toggles this. We
@@ -839,7 +881,7 @@ ipcHandle("embedded-dsh:recover", () => dshRecover());
839
881
  // Electron's setLoginItemSettings: in dev mode (electron .) the login-item
840
882
  // API is unreliable, while schtasks works for the current user without
841
883
  // elevation. The task runs start-desktop.ps1 (non-elevated → clickable).
842
- const AUTOSTART_TASK = "SummriseDesktop";
884
+ const AUTOSTART_TASK = K.AUTOSTART_TASK;
843
885
  // Resolve the autostart script from the install root (layout v2: the shell
844
886
  // lives at <install>\components\summrise-desktop-electron\src\, so the script
845
887
  // is at <install>\scripts\ — process.cwd() depends on how the shell was
@@ -849,7 +891,7 @@ const AUTOSTART_SCRIPT = path.join(INSTALL_ROOT, "scripts", "start-desktop.ps1")
849
891
  async function autoLaunchTaskExists() {
850
892
  // review #4: was sync execSync schtasks — the same hang class that killed
851
893
  // electron; route through the bounded async runner.
852
- const r = await runSchtasks(["/query", "/tn", AUTOSTART_TASK]);
894
+ const r = await runSchtasks((0, summrise_shell_policy_1.schtasksQueryArgs)(AUTOSTART_TASK));
853
895
  return r.ok;
854
896
  }
855
897
  /** Run schtasks asynchronously with a hard timeout — the SYNC execSync
@@ -869,7 +911,7 @@ function runSchtasks(args) {
869
911
  const t = setTimeout(() => { try {
870
912
  child.kill();
871
913
  }
872
- catch { } resolve({ ok: false, error: "schtasks timed out" }); }, 15000);
914
+ catch { } resolve({ ok: false, error: "schtasks timed out" }); }, SCHTASKS_TIMEOUT_MS);
873
915
  child.on("error", (e) => { clearTimeout(t); resolve({ ok: false, error: String(e) }); });
874
916
  child.on("close", (code) => { clearTimeout(t); resolve({ ok: code === 0, error: code === 0 ? undefined : `schtasks exit ${code}` }); });
875
917
  }
@@ -880,45 +922,52 @@ function runSchtasks(args) {
880
922
  }
881
923
  async function autoLaunchTaskSet(enabled) {
882
924
  try {
883
- if (enabled && !(await autoLaunchTaskExists())) {
884
- // Under SYSTEM, a spawn-array schtasks /create without /ru fails with
885
- // "no mapping between account names and security IDs" (exit 1) — the
886
- // interactive user is not resolvable from the service session. A bare
887
- // STRING invocation happens to work (shell default), but spawn arrays
888
- // need an explicit account: use Administrator (the d1 console user).
889
- //
890
- // THIS PATH CANNOT REPRODUCE WHAT `summrise` REGISTERS, and the difference matters. The CLI
891
- // gives SummriseDesktop TWO triggers — AtLogOn plus a guarded 5-minute pulse (bin/summrise.js:
892
- // `New-ScheduledTaskTrigger -Once -At (Get-Date).AddMinutes(3) -RepetitionInterval 5min`, run
893
- // through desktop-pulse.vbs → ensure-desktop.ps1, which exits when electron is already up).
894
- // `schtasks /create` has no way to express a repetition interval at all, so what this writes is
895
- // a LOGON-ONLY task: toggling auto-launch OFF deletes the hardened task, and toggling it ON
896
- // again replaces it with one that cannot recover a wedged shell. `summrise update` heals it and
897
- // says so in its log — "desk: SummriseDesktop hardened (guarded 5-min pulse)" — which is how
898
- // this collision was found in the first place.
899
- //
900
- // Doing it properly means New-ScheduledTaskTrigger through PowerShell (the CLI's shape,
901
- // duplicated in JS) or delegating to the CLI; neither is a one-liner, and the update-time repair
902
- // bounds the damage to "until the next update". Recorded where the downgrade happens, so the
903
- // next reader sees the trap instead of rediscovering it from a shell that stopped recovering.
904
- const r = await runSchtasks([
905
- "/create", "/tn", AUTOSTART_TASK,
906
- // schtasks re-parses the /tr VALUE as a command line — it needs its
907
- // OWN inner quotes around the script path. The spawn array keeps the
908
- // outer arg intact; the embedded \\" quotes survive to schtasks.
909
- "/tr", `powershell -NoProfile -ExecutionPolicy Bypass -File \\"${AUTOSTART_SCRIPT}\\"`,
910
- "/sc", "onlogon", "/ru", "Administrator", "/f",
911
- ]);
912
- if (!r.ok)
913
- return r;
914
- }
915
- else if (!enabled && (await autoLaunchTaskExists())) {
916
- // End the task first (a RUNNING task's process tree is terminated on
917
- // delete) then remove it — the current electron instance must survive.
918
- await runSchtasks(["/end", "/tn", AUTOSTART_TASK]);
919
- const r = await runSchtasks(["/delete", "/tn", AUTOSTART_TASK, "/f"]);
920
- if (!r.ok)
921
- return r;
925
+ // THE PLAN IS THE POLICY CRATE'S (`autoLaunchPlan(enabled, exists)`): create when it is asked for and
926
+ // absent, end-then-delete when it is refused and present, and NOTHING when it is already in the
927
+ // state asked for — which is a SUCCESS and not a no-op to be apologised for. This file asks whether
928
+ // the task exists and runs the argv; it decides neither.
929
+ switch ((0, summrise_shell_policy_1.autoLaunchPlan)(!!enabled, await autoLaunchTaskExists())) {
930
+ case "create": {
931
+ // Under SYSTEM, a spawn-array schtasks /create without /ru fails with
932
+ // "no mapping between account names and security IDs" (exit 1) — the
933
+ // interactive user is not resolvable from the service session. A bare
934
+ // STRING invocation happens to work (shell default), but spawn arrays
935
+ // need an explicit account: use Administrator (the d1 console user).
936
+ //
937
+ // THIS PATH CANNOT REPRODUCE WHAT `summrise` REGISTERS, and the difference matters. The CLI
938
+ // gives SummriseDesktop TWO triggers — AtLogOn plus a guarded 5-minute pulse (bin/summrise.js:
939
+ // `New-ScheduledTaskTrigger -Once -At (Get-Date).AddMinutes(3) -RepetitionInterval 5min`, run
940
+ // through desktop-pulse.vbs → ensure-desktop.ps1, which exits when electron is already up).
941
+ // `schtasks /create` has no way to express a repetition interval at all, so what this writes is
942
+ // a LOGON-ONLY task: toggling auto-launch OFF deletes the hardened task, and toggling it ON
943
+ // again replaces it with one that cannot recover a wedged shell. `summrise update` heals it and
944
+ // says so in its log — "desk: SummriseDesktop hardened (guarded 5-min pulse)" — which is how
945
+ // this collision was found in the first place.
946
+ //
947
+ // Doing it properly means New-ScheduledTaskTrigger through PowerShell (the CLI's shape,
948
+ // duplicated in JS) or delegating to the CLI; neither is a one-liner, and the update-time repair
949
+ // bounds the damage to "until the next update". Recorded where the downgrade happens, so the
950
+ // next reader sees the trap instead of rediscovering it from a shell that stopped recovering.
951
+ // `schtasksCreateArgs` carries the quoting trap the crate documents: `schtasks` re-parses the
952
+ // /tr VALUE as a command line, so the script path needs its OWN inner quotes while the spawn
953
+ // array keeps the outer argument intact. It is also where the `/ru Administrator` and the
954
+ // logon-only SHAPE are recorded — the toggle cannot reproduce what `summrise` registers, and the
955
+ // crate says so where the downgrade happens.
956
+ const r = await runSchtasks((0, summrise_shell_policy_1.schtasksCreateArgs)(AUTOSTART_TASK, AUTOSTART_SCRIPT));
957
+ if (!r.ok)
958
+ return r;
959
+ break;
960
+ }
961
+ case "remove": {
962
+ // End the task first (a RUNNING task's process tree is terminated on delete) then remove it —
963
+ // the current electron instance must survive.
964
+ await runSchtasks((0, summrise_shell_policy_1.schtasksEndArgs)(AUTOSTART_TASK));
965
+ const r = await runSchtasks((0, summrise_shell_policy_1.schtasksDeleteArgs)(AUTOSTART_TASK));
966
+ if (!r.ok)
967
+ return r;
968
+ break;
969
+ }
970
+ default: break; // "nothing" — already in the state that was asked for
922
971
  }
923
972
  return { ok: true, enabled };
924
973
  }
@@ -936,21 +985,21 @@ ipcHandle("desktop:set-auto-launch", async (enabled) => autoLaunchTaskSet(!!enab
936
985
  // connect succeeding, so the watchdog's miss counter NEVER reached the gate,
937
986
  // the wait page never showed, and a stuck device stayed stuck in silence.
938
987
  // Liveness = GET /api/status answers 200 within the budget.
939
- function agentResponds(timeoutMs = 2000) {
988
+ function agentResponds(timeoutMs = AGENT_PROBE_DEFAULT_MS) {
940
989
  return new Promise((res) => {
941
990
  let done = false;
942
991
  const finish = (v) => { if (!done) {
943
992
  done = true;
944
993
  res(v);
945
994
  } };
946
- const req = http.get(`${(0, url_policy_1.agentBase)()}/api/status`, { timeout: timeoutMs }, (r) => {
995
+ const req = http.get(`${(0, summrise_url_policy_1.agentBase)()}/api/status`, { timeout: timeoutMs }, (r) => {
947
996
  r.resume();
948
997
  // ANY HTTP response = the accept loop is alive (this is the exact thing
949
998
  // the TCP probe could not see). Do NOT require 200: /api/status is
950
999
  // token-gated, so a HEALTHY agent answers 401 to the shell's
951
1000
  // credential-less probe — requiring 200 made a live agent look dead and
952
1001
  // sent the watchdog into a restart loop (self-caught regression).
953
- finish(typeof r.statusCode === "number" && r.statusCode > 0);
1002
+ finish((0, summrise_shell_policy_1.statusIsAlive)(r.statusCode));
954
1003
  });
955
1004
  req.on("timeout", () => { req.destroy(); finish(false); });
956
1005
  req.on("error", () => finish(false));
@@ -964,7 +1013,7 @@ async function startAgentTask() {
964
1013
  // review #1 (HIGH): sync execSync on the wait-page button / IPC / HTTP
965
1014
  // paths — one wedged schtasks froze the WHOLE main process (the exact
966
1015
  // hang class already fixed for the watchdog). Same bounded async runner.
967
- return runSchtasks(["/run", "/tn", "SummriseAgent"]);
1016
+ return runSchtasks((0, summrise_shell_policy_1.schtasksRunArgs)(AGENT_TASK));
968
1017
  }
969
1018
  // The SPA asks the shell for agent status / to (re)start the agent task.
970
1019
  // IPC audit #6: shell:agent-status / shell:start-agent had ZERO consumers
@@ -972,7 +1021,12 @@ async function startAgentTask() {
972
1021
  // Dead handlers removed — every exposed channel is attack surface.
973
1022
  // Fallback HTTP path (plain browser / no preload): same core, CORS-open.
974
1023
  const httpServer = http.createServer((req, res) => {
975
- const u = new URL(req.url || "/", "http://127.0.0.1");
1024
+ // THE REQUEST PARSE AND THE ROUTER ARE THE POLICY CRATE'S. `controlPath` resolves the request target
1025
+ // against the loopback base the way `new URL(req.url, "http://127.0.0.1")` did, and — the ONE named
1026
+ // divergence in that crate — a malformed request target answers `undefined` instead of throwing out of
1027
+ // this listener, which in the original took the whole Electron main process with it. `undefined` falls
1028
+ // through to the 404 below.
1029
+ const pathname = (0, summrise_shell_policy_1.controlPath)(req.url) ?? "";
976
1030
  const send = (obj, code = 200) => {
977
1031
  // review #8: was ACAO:* with no origin check — ANY web page open in ANY
978
1032
  // local browser could POST /api/shell/start-agent or spam
@@ -981,14 +1035,18 @@ const httpServer = http.createServer((req, res) => {
981
1035
  // origins (reads included — the GET routes expose session URLs and
982
1036
  // device facts). "null" (our data: wait page) and absent
983
1037
  // (native tooling/curl) stay allowed.
984
- res.setHeader("access-control-allow-origin", req.headers.origin || "*");
985
- res.setHeader("access-control-allow-methods", "GET, POST, OPTIONS");
986
- res.setHeader("access-control-allow-headers", "content-type, authorization");
987
- res.setHeader("vary", "Origin");
1038
+ res.setHeader("access-control-allow-origin", (0, summrise_shell_policy_1.corsAllowOrigin)(req.headers.origin));
1039
+ res.setHeader("access-control-allow-methods", K.CORS_ALLOW_METHODS);
1040
+ res.setHeader("access-control-allow-headers", K.CORS_ALLOW_HEADERS);
1041
+ res.setHeader("vary", K.CORS_VARY);
988
1042
  res.writeHead(code, { "content-type": "application/json", "cache-control": "no-store" });
989
1043
  res.end(JSON.stringify(obj));
990
1044
  };
991
- if (req.method === "OPTIONS")
1045
+ // A PREFLIGHT IS ANSWERED BEFORE THE VETO, and that ordering is the policy crate's
1046
+ // (`controlIsPreflight`) rather than a detail of this function: moving the check below the veto would
1047
+ // start 403-ing legitimate CORS preflights and the plain-browser path would stop working with no other
1048
+ // symptom.
1049
+ if ((0, summrise_shell_policy_1.controlIsPreflight)(req.method ?? ""))
992
1050
  return send({ ok: true });
993
1051
  // Foreign-origin veto applies to READS too: the GET routes
994
1052
  // (/api/browser-session/list, /api/shell/agent-status,
@@ -1000,34 +1058,30 @@ const httpServer = http.createServer((req, res) => {
1000
1058
  // this set — no live extension caller exists (the extension only talks
1001
1059
  // to its configured gateway origin, never to 127.0.0.1:9444), so the
1002
1060
  // veto stays as-is; an extension caller needs an explicit entry here.
1003
- {
1004
- // THE DECISION LIVES IN url-policy.ts, beside its siblings. This was an
1005
- // inline regex with NO `$` ANCHOR, so `http://127.0.0.1.evil.com` passed —
1006
- // the same class as the `startsWith(BASE)` bug that module exists to kill,
1007
- // whose own test pins the lookalike for the IPC twin. Delegating also means
1008
- // the predicate finally has a test, because the shell's suite never ran.
1009
- if (!(0, url_policy_1.controlOriginOk)(req.headers.origin)) {
1010
- return send({ ok: false, error: "forbidden origin" }, 403);
1011
- }
1061
+ // THE DECISION ITSELF IS RUST (agent/summrise-url-policy) and was an inline regex with NO `$` ANCHOR,
1062
+ // so `http://127.0.0.1.evil.com` passed — the same class as the `startsWith(BASE)` bug that crate
1063
+ // exists to kill.
1064
+ if (!(0, summrise_url_policy_1.controlOriginOk)(req.headers.origin)) {
1065
+ return send({ ok: false, error: K.FORBIDDEN_ORIGIN }, 403);
1012
1066
  }
1013
1067
  try {
1014
- if (u.pathname === "/api/browser-session/open" && req.method === "POST")
1015
- return send(browserOpen(u.searchParams.get("url") || "about:blank"));
1016
- if (u.pathname === "/api/browser-session/close" && req.method === "POST")
1017
- return send(browserClose(u.searchParams.get("id") || ""));
1018
- if (u.pathname === "/api/browser-session/list")
1019
- return send(browserList());
1020
- if (u.pathname === "/api/shell/start-agent" && req.method === "POST")
1021
- return (async () => send(await startAgentTask()))();
1022
- if (u.pathname === "/api/shell/agent-status")
1023
- return (async () => send({ ok: true, running: await agentResponds(1000) }))();
1024
- if (u.pathname === "/api/shell/icon-status")
1025
- return send({ ok: true, icons: iconReport,
1068
+ // THE ROUTE TABLE IS THE POLICY CRATE'S, INCLUDING ITS TWO ASYMMETRIES, which are the original
1069
+ // behaviour and not accidents to tidy: `/list`, `/agent-status` and `/icon-status` have NO method
1070
+ // guard (a POST reaches the same reader), while `/open`, `/close` and `/start-agent` require POST and
1071
+ // a GET to one of them falls through to the 404.
1072
+ 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());
1076
+ case "start-agent": return (async () => send(await startAgentTask()))();
1077
+ case "agent-status": return (async () => send({ ok: true, running: await agentResponds(CONTROL_PROBE_MS) }))();
1078
+ case "icon-status": return send({ ok: true, icons: iconReport,
1026
1079
  native: {
1027
1080
  tray: probeImage(String(iconReport["tray"]?.path || "")),
1028
1081
  window: probeImage(String(iconReport["window"]?.path || "")),
1029
1082
  } });
1030
- send({ ok: false, error: "not found" }, 404);
1083
+ default: send({ ok: false, error: K.NOT_FOUND }, 404);
1084
+ }
1031
1085
  }
1032
1086
  catch (e) {
1033
1087
  send({ ok: false, error: String(e) }, 500);
@@ -1061,22 +1115,27 @@ if (gotTheLock) {
1061
1115
  // (The same shortcut association is also the documented precondition for the hide-to-tray
1062
1116
  // toast below: Electron requires a Start Menu shortcut carrying the ID and a
1063
1117
  // ToastActivatorCLSID, and this one carries neither — unchanged by this call.)
1064
- const AUMID = "online.saisi.summrise.desktop";
1118
+ // THE VALUE, THE PLATFORM TEST AND THE TWO REPORT STRINGS ARE THE POLICY CRATE'S. It is a COPY of the
1119
+ // CLI's `DESKTOP_AUMID` and the two must stay in step — `agent/tests/shared_literals.rs` now compares
1120
+ // them from both sources and refuses a disagreement, which is what "KEEP THE TWO IN STEP" needed to
1121
+ // stop being a comment. `app.setAppUserModelId` is this file's.
1065
1122
  try {
1066
- if (process.platform === "win32")
1067
- electron_1.app.setAppUserModelId(AUMID);
1068
- iconReport["appUserModelId"] = process.platform === "win32" ? AUMID : "(non-windows)";
1123
+ if ((0, summrise_shell_policy_1.usesAppUserModelId)(process.platform))
1124
+ electron_1.app.setAppUserModelId(K.DESKTOP_AUMID);
1125
+ iconReport["appUserModelId"] = (0, summrise_shell_policy_1.aumidReport)(process.platform);
1069
1126
  }
1070
1127
  catch {
1071
- iconReport["appUserModelId"] = "(set-failed)";
1128
+ iconReport["appUserModelId"] = K.AUMID_SET_FAILED;
1072
1129
  }
1073
1130
  electron_1.app.whenReady().then(async () => {
1074
1131
  // Custom-port installs: pin every origin predicate + probe/load URL to
1075
1132
  // the agent's actual bind port BEFORE any window or probe exists.
1076
- (0, url_policy_1.setAgentPort)(resolveAgentPort());
1133
+ (0, summrise_url_policy_1.setAgentPort)(bootAgentPort());
1077
1134
  // The DSH view's door, pinned the same way and for the same reason: a predicate that ran
1078
1135
  // before this line would check a port nothing is listening on.
1079
- (0, url_policy_1.setDshPort)(resolveDshPort());
1136
+ (0, summrise_url_policy_1.setDshPort)(bootDshPort());
1137
+ // Every host's forward is a door, admitted from the agent's table rather than from configuration here.
1138
+ loadHarnessDoors();
1080
1139
  // review #7: with no handler Electron AUTO-GRANTS every permission
1081
1140
  // request (media/geolocation/clipboard) — deny by default for all
1082
1141
  // windows, esp. the remote-browser ones loading arbitrary pages.
@@ -1085,8 +1144,8 @@ if (gotTheLock) {
1085
1144
  electron_1.session.defaultSession.setPermissionCheckHandler(() => false);
1086
1145
  }
1087
1146
  catch { /* non-fatal */ }
1088
- httpServer.listen(CTRL_PORT, "127.0.0.1");
1089
- console.log(`[summrise] browser-session control: http://127.0.0.1:${CTRL_PORT}`);
1147
+ httpServer.listen(K.CTRL_PORT, "127.0.0.1");
1148
+ console.log(`[summrise] browser-session control: http://127.0.0.1:${K.CTRL_PORT}`);
1090
1149
  // Native application menu (stage-l): menu commands → SPA via summrise-menu.
1091
1150
  electron_1.Menu.setApplicationMenu(buildMenu());
1092
1151
  win = new electron_1.BrowserWindow({
@@ -1138,7 +1197,7 @@ if (gotTheLock) {
1138
1197
  // audit #1: PARSED-origin veto. The data: carve-out was unnecessary
1139
1198
  // (programmatic loadURL — including the wait page — never fires
1140
1199
  // will-navigate) and only widened the hole.
1141
- if (!(0, url_policy_1.isBaseOrigin)(url))
1200
+ if (!(0, summrise_url_policy_1.isBaseOrigin)(url))
1142
1201
  e.preventDefault();
1143
1202
  });
1144
1203
  // round-258 (device-caught): CDP-driven navigation (an attached
@@ -1152,17 +1211,17 @@ if (gotTheLock) {
1152
1211
  win.webContents.on("did-navigate", (_e, url) => {
1153
1212
  if (snappingBack)
1154
1213
  return;
1155
- // Parsed-origin + parsed-pathname allow-list (url-policy): the string
1156
- // startsWith was the exact class IPC audit #1 flagged. The data: wait
1157
- // page and about:blank stay allowed at the caller.
1158
- if ((0, url_policy_1.isDesktopSpaUrl)(url) || url.startsWith("data:") || url === "about:blank")
1159
- return;
1160
- if (url === "about:blank")
1214
+ // Parsed-origin + parsed-pathname allow-list: the string startsWith was the exact class IPC audit
1215
+ // #1 flagged, so `isDesktopSpaUrl` (the URL policy's) supplies that half and the policy crate
1216
+ // composes it with the two literal carve-outs — the data: wait page and about:blank.
1217
+ //
1218
+ // The port deleted a dead `if (url === "about:blank") return;` here — the line above already returns for it.
1219
+ if ((0, summrise_shell_policy_1.tripwireAllows)(url, (0, summrise_url_policy_1.isDesktopSpaUrl)(url)))
1161
1220
  return;
1162
- console.log(`[summrise] main-window tripwire: blocked stray navigation to ${url.slice(0, 80)}`);
1221
+ console.log((0, summrise_shell_policy_1.tripwireLog)(url));
1163
1222
  snappingBack = true;
1164
- win?.loadURL(`${(0, url_policy_1.agentBase)()}/desktop/`).catch(() => { }).finally(() => {
1165
- setTimeout(() => { snappingBack = false; }, 2000);
1223
+ win?.loadURL(`${(0, summrise_url_policy_1.agentBase)()}/desktop/`).catch(() => { }).finally(() => {
1224
+ setTimeout(() => { snappingBack = false; }, TRIPWIRE_BACKOFF_MS);
1166
1225
  });
1167
1226
  });
1168
1227
  // review #7: a target=_blank from the SPA must NOT get a preload-bearing
@@ -1183,7 +1242,7 @@ if (gotTheLock) {
1183
1242
  // agentReady() stayed true, so the miss counter below NEVER incremented
1184
1243
  // and the watchdog could not fire (the device stayed stuck in silence,
1185
1244
  // the exact failure the watchdog exists to prevent).
1186
- const agentReady = async () => agentResponds(800);
1245
+ const agentReady = async () => agentResponds(AGENT_PROBE_BOOT_MS);
1187
1246
  // stage-n: the wait page carries a "Start Agent" action — the header
1188
1247
  // comment promised it but it never existed. The button calls the shell's
1189
1248
  // own /api/shell/start-agent (schtasks /run SummriseAgent — the only
@@ -1246,12 +1305,12 @@ if (gotTheLock) {
1246
1305
  </style>
1247
1306
  <div>
1248
1307
  <h2>The Summrise Agent isn&#39;t answering</h2>
1249
- <p id="status">no reply from ${(0, url_policy_1.agentBase)().replace("http://", "")}</p>
1308
+ <p id="status">no reply from ${(0, summrise_shell_policy_1.agentHostLabel)((0, summrise_url_policy_1.agentBase)())}</p>
1250
1309
  <p id="action" hidden></p>
1251
1310
  <button id="start">Start Agent</button>
1252
1311
  </div>
1253
1312
  <script>
1254
- var BASE = ${JSON.stringify((0, url_policy_1.agentBase)().replace("http://", ""))};
1313
+ var BASE = ${JSON.stringify((0, summrise_shell_policy_1.agentHostLabel)((0, summrise_url_policy_1.agentBase)()))};
1255
1314
  var CTRL = "http://127.0.0.1:9444";
1256
1315
  var btn = document.getElementById("start");
1257
1316
  var st = document.getElementById("status");
@@ -1305,13 +1364,17 @@ if (gotTheLock) {
1305
1364
  // DO NOT RE-LOAD A PAGE THAT IS ALREADY SHOWING (see the note above): the
1306
1365
  // reload wiped the button's answer and reset the evidence line, which is
1307
1366
  // what made the only control on this screen unfalsifiable.
1308
- if (win?.webContents.getURL().startsWith("data:text/html"))
1367
+ // `isWaitPage` is the policy crate's, and the prefix test is SAFE because the URL it tests is one
1368
+ // this file built — no page can make itself match.
1369
+ if ((0, summrise_shell_policy_1.isWaitPage)(win?.webContents.getURL() ?? ""))
1309
1370
  return;
1310
1371
  win?.loadURL(`data:text/html;charset=utf-8,${encodeURIComponent(waitPageHtml(checks))}`).catch(() => { });
1311
1372
  };
1312
- let retryMs = 2000;
1313
- const nextRetry = () => { retryMs = Math.min(retryMs * 2, 30000); return retryMs; };
1314
- const resetRetry = () => { retryMs = 2000; };
1373
+ // THE BACKOFF IS THE POLICY CRATE'S: double, capped at `RETRY_CAP_MS`, and reset to `RETRY_START_MS`
1374
+ // on a successful probe.
1375
+ let retryMs = RETRY_START_MS;
1376
+ const nextRetry = () => { retryMs = (0, summrise_shell_policy_1.nextRetryMs)(retryMs); return retryMs; };
1377
+ const resetRetry = () => { retryMs = RETRY_START_MS; };
1315
1378
  // NO setVersionTitle() HERE, DELETED. It fetched /api/status with the device token and
1316
1379
  // called win.setTitle("Summrise — v1.2.x"), and that title NEVER REACHED THE SCREEN: the
1317
1380
  // SPA owns document.title (`useAttentionTitle` assigns it on every attention change,
@@ -1333,13 +1396,11 @@ if (gotTheLock) {
1333
1396
  // free — the same hang class that killed the old auto-launch toggle.
1334
1397
  let agentMissCount = 0;
1335
1398
  let lastAutoStartAt = 0;
1336
- const AUTO_START_AFTER_MISSES = 5;
1337
- const AUTO_START_MIN_GAP_MS = 5 * 60 * 1000;
1338
1399
  const loadDesktop = async () => {
1339
1400
  if (await agentReady()) {
1340
1401
  agentMissCount = 0;
1341
1402
  resetRetry();
1342
- win?.loadURL(`${(0, url_policy_1.agentBase)()}/desktop/`).catch(() => { });
1403
+ win?.loadURL(`${(0, summrise_url_policy_1.agentBase)()}/desktop/`).catch(() => { });
1343
1404
  }
1344
1405
  else {
1345
1406
  // THE MISS IS COUNTED BEFORE THE PAGE IS HANDED THE NUMBER, because this
@@ -1347,11 +1408,14 @@ if (gotTheLock) {
1347
1408
  // times the shell has asked and got nothing, including this one.
1348
1409
  agentMissCount += 1;
1349
1410
  loadWaitPage(agentMissCount);
1350
- if (agentMissCount >= AUTO_START_AFTER_MISSES && Date.now() - lastAutoStartAt > AUTO_START_MIN_GAP_MS) {
1411
+ // THE GATE IS THE POLICY CRATE'S: five consecutive misses AND at least five minutes since the
1412
+ // last self-start. The argv is its too, which is what makes the task name this runs and the name
1413
+ // the CLI registers one fact instead of two.
1414
+ if ((0, summrise_shell_policy_1.watchdogShouldStart)(agentMissCount, lastAutoStartAt, Date.now())) {
1351
1415
  lastAutoStartAt = Date.now();
1352
1416
  agentMissCount = 0;
1353
- void runSchtasks(["/run", "/tn", "SummriseAgent"]).then((r) => {
1354
- console.log(`[summrise] agent watchdog: schtasks /run SummriseAgent → ${r.ok ? "ok" : "failed: " + r.error}`);
1417
+ void runSchtasks((0, summrise_shell_policy_1.schtasksRunArgs)(AGENT_TASK)).then((r) => {
1418
+ console.log((0, summrise_shell_policy_1.watchdogLog)(r.ok, r.error));
1355
1419
  });
1356
1420
  }
1357
1421
  setTimeout(() => { loadDesktop(); }, nextRetry());
@@ -1364,7 +1428,10 @@ if (gotTheLock) {
1364
1428
  // ANOTHER loadDesktop chain (accelerated miss counting + duplicate
1365
1429
  // loadURLs). Only a real main-frame failure (not ABORTED) should retry.
1366
1430
  win.webContents.on("did-fail-load", (_e, errorCode, _desc, _url, isMainFrame) => {
1367
- if (!isMainFrame || errorCode === -3)
1431
+ // `-3` is ERR_ABORTED and it is EXCLUDED on purpose: a same-URL reload dispatches it on the MAIN
1432
+ // frame, and retrying it stacked a second `loadDesktop` chain with accelerated miss counting. The
1433
+ // predicate is the policy crate's.
1434
+ if (!(0, summrise_shell_policy_1.shouldRetryLoad)(!!isMainFrame, errorCode))
1368
1435
  return;
1369
1436
  setTimeout(() => { loadDesktop(); }, nextRetry());
1370
1437
  });
@@ -1373,7 +1440,7 @@ if (gotTheLock) {
1373
1440
  win.webContents.on("did-finish-load", () => {
1374
1441
  if (menuFlushTimer)
1375
1442
  clearTimeout(menuFlushTimer);
1376
- menuFlushTimer = setTimeout(flushMenuQueue, 1000);
1443
+ menuFlushTimer = setTimeout(flushMenuQueue, MENU_FLUSH_MS);
1377
1444
  });
1378
1445
  await loadDesktop();
1379
1446
  // round-259: eagerly create the EMBEDDED view at startup (hidden) so an
@@ -1390,33 +1457,37 @@ if (gotTheLock) {
1390
1457
  embeddedViewEnsure();
1391
1458
  }
1392
1459
  catch { /* non-fatal */ }
1393
- console.log(`[summrise] CDP endpoint: http://127.0.0.1:${CDP_PORT} (playwright connectOverCDP)`);
1460
+ console.log(`[summrise] CDP endpoint: ${(0, summrise_shell_policy_1.cdpEndpoint)(K.CDP_PORT)} (playwright connectOverCDP)`);
1394
1461
  // stage-n: CDP self-check — if 9333 is occupied by another process
1395
1462
  // (a second browser/electron), remote-debugging-port silently fails and
1396
1463
  // AI driving would hit the WRONG target. Probe /json/version and verify
1397
1464
  // the User-Agent belongs to this app.
1465
+ // THE QUESTION IS THE POLICY CRATE'S, THE REQUEST IS THIS FILE'S. It exists because "if 9333 is
1466
+ // occupied by another process (a second browser/electron), `remote-debugging-port` silently fails and
1467
+ // AI driving would hit the WRONG target" — so `/json/version` is probed and the `User-Agent` is
1468
+ // compared against this app's marker. The three messages are the crate's so that the wording a person
1469
+ // reads off a device's log is pinned by a test.
1398
1470
  (async () => {
1399
1471
  try {
1400
1472
  const ctrl = new AbortController();
1401
- const t = setTimeout(() => ctrl.abort(), 3000);
1402
- const r = await fetch(`http://127.0.0.1:${CDP_PORT}/json/version`, { signal: ctrl.signal });
1473
+ const t = setTimeout(() => ctrl.abort(), CDP_CHECK_MS);
1474
+ const r = await fetch(`http://127.0.0.1:${K.CDP_PORT}/json/version`, { signal: ctrl.signal });
1403
1475
  clearTimeout(t);
1404
1476
  if (r.ok) {
1405
- const j = await r.json();
1406
- const ua = j["User-Agent"] || "";
1407
- if (ua.includes("summrise-desktop-electron")) {
1408
- console.log(`[summrise] CDP self-check OK: 127.0.0.1:${CDP_PORT} is this app`);
1477
+ const ua = (0, summrise_shell_policy_1.cdpUserAgent)(await r.text());
1478
+ if ((0, summrise_shell_policy_1.cdpUserAgentOurs)(ua)) {
1479
+ console.log((0, summrise_shell_policy_1.cdpSelfCheckOk)(K.CDP_PORT));
1409
1480
  }
1410
1481
  else {
1411
- console.warn(`[summrise] CDP WARNING: port ${CDP_PORT} answered by another browser (UA=${ua.slice(0, 60)}…) — AI driving may target the wrong process`);
1482
+ console.warn((0, summrise_shell_policy_1.cdpWarningForeign)(K.CDP_PORT, ua));
1412
1483
  }
1413
1484
  }
1414
1485
  else {
1415
- console.warn(`[summrise] CDP WARNING: ${CDP_PORT} not responding — remote debugging may be off`);
1486
+ console.warn((0, summrise_shell_policy_1.cdpWarningNotResponding)(K.CDP_PORT));
1416
1487
  }
1417
1488
  }
1418
1489
  catch {
1419
- console.warn(`[summrise] CDP WARNING: could not reach ${CDP_PORT} — remote debugging may be off`);
1490
+ console.warn((0, summrise_shell_policy_1.cdpWarningUnreachable)(K.CDP_PORT));
1420
1491
  }
1421
1492
  })();
1422
1493
  win.on("close", (e) => {
@@ -1446,88 +1517,54 @@ if (gotTheLock) {
1446
1517
  let trayAgentRunning = false;
1447
1518
  // stage-n: guards the single loadDesktop re-entry loop (see tray hook).
1448
1519
  let agentWatchActive = false;
1449
- let trayAgentVersion = "";
1450
- let trayAgentUptime = "";
1451
- let trayAgentSessions = 0;
1452
- // stage-n: vitals mirrored from /api/status into the tray (the SPA
1453
- // status strip shows the same pair).
1454
- let trayAgentCpu = null;
1455
- let trayAgentMem = null;
1520
+ /** WHAT THE TRAY KNOWS, carried between polls: the facts the last `/api/status` answer left behind.
1521
+ * The RULE — a poll that fails, or an answer that omits a field, KEEPS what the last one said — is
1522
+ * the policy crate's; the STATE is this file's, because a poll is the only thing that observes it.
1523
+ * The two formatters this file used to carry (`fmtUptime`, `fmtClock`) are that crate's too, so
1524
+ * there is no clock face or uptime string built here at all. */
1525
+ let trayFacts = { version: "", uptime: "", sessions: 0, cpu: null, mem: null };
1456
1526
  /** WHEN /api/status last ANSWERED (null = it never has since launch) — the tray's
1457
- * tooltip reports this instead of asserting a state it cannot date. */
1527
+ * tooltip reports this instead of asserting a state it cannot date. The policy crate decides what
1528
+ * it MEANS; this file only records it. */
1458
1529
  let trayLastAnsweredAt = null;
1459
- /** "3m 24s" / "1h 5m" / "2d 3h" — compact uptime for the tray. */
1460
- const fmtUptime = (secs) => {
1461
- if (secs < 60)
1462
- return `${secs}s`;
1463
- if (secs < 3600)
1464
- return `${Math.floor(secs / 60)}m ${secs % 60}s`;
1465
- if (secs < 86400)
1466
- return `${Math.floor(secs / 3600)}h ${Math.floor((secs % 3600) / 60)}m`;
1467
- return `${Math.floor(secs / 86400)}d ${Math.floor((secs % 86400) / 3600)}h`;
1468
- };
1469
- /** "14:02:31" local — the WHEN of an observation, for the one surface that cannot
1470
- * re-derive it (a tray tooltip is a string set once per poll, not a render). */
1471
- const fmtClock = (at) => new Date(at).toTimeString().slice(0, 8);
1472
1530
  const refreshTray = async () => {
1473
1531
  // HTTP liveness, not the TCP probe (see agentReady above): a wedged-
1474
1532
  // but-listening agent must show as STOPPED, not "running".
1475
- const running = await agentResponds(1000);
1533
+ const running = await agentResponds(AGENT_PROBE_TRAY_MS);
1476
1534
  const observedAt = Date.now();
1477
- if (running)
1478
- trayLastAnsweredAt = observedAt;
1479
- let version = trayAgentVersion;
1480
- let uptime = trayAgentUptime;
1481
- let sessions = trayAgentSessions;
1482
- let cpu = trayAgentCpu;
1483
- let mem = trayAgentMem;
1535
+ // THE PARSE, THE KEEP-LAST RULE, THE VITALS LINE AND THE TOOLTIP'S WORDING ARE ONE CALL INTO THE
1536
+ // POLICY CRATE, and this file hands over the RAW BODY rather than a parsed object: `r.json()`
1537
+ // throwing into a `catch { keep last }` IS the keep-last rule, so the parse belongs where the rule
1538
+ // is. `r.text()` moves the throw across the boundary.
1539
+ let statusText = "";
1484
1540
  if (running) {
1485
1541
  try {
1486
1542
  const ctrl = new AbortController();
1487
- const t = setTimeout(() => ctrl.abort(), 2500);
1488
- const r = await fetch(`${(0, url_policy_1.agentBase)()}/api/status`, { signal: ctrl.signal, headers: authHeaders() });
1543
+ const t = setTimeout(() => ctrl.abort(), STATUS_FETCH_MS);
1544
+ const r = await fetch(`${(0, summrise_url_policy_1.agentBase)()}/api/status`, { signal: ctrl.signal, headers: authHeaders() });
1489
1545
  clearTimeout(t);
1490
- if (r.ok) {
1491
- const j = await r.json();
1492
- // npm RELEASE version first (the number that changes per release,
1493
- // written by update/setup), Cargo protocol `version` as fallback.
1494
- const v = j.release || j.version;
1495
- if (v)
1496
- version = v;
1497
- if (typeof j.uptime_secs === "number")
1498
- uptime = fmtUptime(j.uptime_secs);
1499
- if (typeof j.live_sessions === "number")
1500
- sessions = j.live_sessions;
1501
- if (typeof j.cpu_pct === "number")
1502
- cpu = j.cpu_pct;
1503
- if (typeof j.mem_pct === "number")
1504
- mem = j.mem_pct;
1505
- }
1546
+ if (r.ok)
1547
+ statusText = await r.text();
1506
1548
  }
1507
1549
  catch { /* keep last */ }
1508
1550
  }
1551
+ // THE LOCAL TIME ZONE IS A HOST FACT AND IT IS PASSED PER INSTANT: wasm has no clock and no zone
1552
+ // database, and the two clock faces the tooltip can print are up to a DST boundary apart — one
1553
+ // offset for both would put the wrong hour on one of them for exactly one poll, which is the
1554
+ // "a timestamp does not decay" promise the wording was rewritten to keep.
1555
+ const next = JSON.parse((0, summrise_shell_policy_1.refreshTrayHealth)(JSON.stringify({
1556
+ running,
1557
+ statusText,
1558
+ prev: trayFacts,
1559
+ observedAt,
1560
+ lastAnsweredAt: trayLastAnsweredAt,
1561
+ tzOffsetMin: new Date(observedAt).getTimezoneOffset(),
1562
+ lastTzOffsetMin: new Date(trayLastAnsweredAt ?? observedAt).getTimezoneOffset(),
1563
+ })));
1564
+ trayFacts = next.facts;
1565
+ trayLastAnsweredAt = next.lastAnsweredAt;
1566
+ const health = next.health;
1509
1567
  trayAgentRunning = running;
1510
- trayAgentVersion = version;
1511
- trayAgentUptime = uptime;
1512
- trayAgentSessions = sessions;
1513
- trayAgentCpu = cpu;
1514
- trayAgentMem = mem;
1515
- const vitals = running && mem !== null
1516
- ? `, CPU ${cpu === null ? "?" : Math.round(cpu)}% · MEM ${Math.round(mem)}%`
1517
- : "";
1518
- // THE TOOLTIP NAMES THE OBSERVATION, NOT A VERDICT (round-275). It used to print
1519
- // "Agent stopped" as present tense from ONE un-timestamped probe, recomputed every
1520
- // 30 s — half a minute stale while reading as now, the shape the panel's liveness
1521
- // strings were already fixed for (`checked 12s ago`, `reachability NOT VERIFIED`).
1522
- // The WHEN is a CLOCK rather than "answered 12s ago", deliberately: a tooltip is a
1523
- // string written once per poll and Electron gives it no way to recompute, so a
1524
- // relative age would freeze at the value it had when written and read as "now" for
1525
- // the next 30 s — the very failure this line removes. A timestamp does not decay.
1526
- const health = running
1527
- ? `answered ${fmtClock(observedAt)} · v${version || "?"}${uptime ? `, up ${uptime}` : ""}${sessions ? `, ${sessions} session${sessions === 1 ? "" : "s"}` : ""}${vitals}`
1528
- : trayLastAnsweredAt === null
1529
- ? "reachability NOT VERIFIED · no reply since launch"
1530
- : `not answering · last reply ${fmtClock(trayLastAnsweredAt)}`;
1531
1568
  // stage-n: the agent died WHILE the window was showing the SPA (the
1532
1569
  // wait page only renders when the boot probe fails). Swap the window
1533
1570
  // to the wait page — its "Start Agent" button is now actually visible
@@ -1535,13 +1572,22 @@ if (gotTheLock) {
1535
1572
  // loadDesktop retry loop, whose watchdog auto-runs `schtasks /run
1536
1573
  // SummriseAgent` after ~60 s of misses. One loop guard so repeated tray
1537
1574
  // polls cannot stack retries.
1538
- if (!running && !agentWatchActive && win && !win.isDestroyed()
1539
- && (0, url_policy_1.isBaseOrigin)(win.webContents.getURL())) {
1575
+ //
1576
+ // The CONJUNCTION is the policy crate's; the ORIGIN half is the URL policy's, evaluated only when
1577
+ // the window is live so the original's short-circuit survives (a destroyed window never reaches a
1578
+ // `getURL()`).
1579
+ const liveWindow = win && !win.isDestroyed() ? win : null;
1580
+ if ((0, summrise_shell_policy_1.trayShouldWatch)(running, agentWatchActive, liveWindow !== null, liveWindow ? (0, summrise_url_policy_1.isBaseOrigin)(liveWindow.webContents.getURL()) : false)) {
1540
1581
  agentWatchActive = true;
1541
1582
  void loadDesktop();
1542
1583
  }
1543
1584
  if (running && agentWatchActive)
1544
1585
  agentWatchActive = false;
1586
+ // THE TOOLTIP NAMES THE OBSERVATION, NOT A VERDICT (round-275), and the crate computes it: it used
1587
+ // to print "Agent stopped" as present tense from ONE un-timestamped probe, recomputed every 30 s —
1588
+ // half a minute stale while reading as now. The WHEN is a CLOCK rather than "answered 12s ago"
1589
+ // because a tooltip is a string written once per poll and Electron gives it no way to recompute; a
1590
+ // relative age would freeze and read as "now" for the next 30 s. A timestamp does not decay.
1545
1591
  tray.setToolTip(`Summrise — ${health}`);
1546
1592
  // THE FOUR "New …" ROWS ARE GONE (round-275): they duplicated the desktop
1547
1593
  // header's `+ New` menu item for item (pty/ssh/serial/browser), one click
@@ -1556,7 +1602,7 @@ if (gotTheLock) {
1556
1602
  ]));
1557
1603
  };
1558
1604
  refreshTray();
1559
- setInterval(() => { refreshTray(); }, 30000);
1605
+ setInterval(() => { refreshTray(); }, TRAY_POLL_MS);
1560
1606
  });
1561
1607
  }
1562
1608
  electron_1.app.on("before-quit", () => {